fix: audit batch — pagination truncation, MFA/dead-code cleanup, mark-paid fail-closed (#841)

* fix(reports): paginate 8 more report/ledger queries (1000-row truncation)

Raw .select() without fetchAllRows() silently caps at PostgREST's 1000-row
limit, producing wrong statutory output for high-volume companies. Following
#806 (trial-balance/VAT), wrap the remaining offenders in
fetchAllRows + a stable .order('id') + dedupeBy:

- ink2-engine / ne-engine: INK2 & NE-bilaga tax declarations under-counted
- ar-reconciliation (1510/1513), supplier-reconciliation (2440): phantom
  "Ej avstämd" gaps
- full-archive-export: 7-year DR archive (added a unique total order so rows
  are not silently skipped/duplicated across pages)
- avgifter-basis, currency-revaluation, vat-declaration

Adds a regression guard test asserting >1000 ledger lines are summed, not
truncated at 1000.

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

* fix(api): close extension-dispatcher MFA gap, scope /api/events to API key, sweep dead code

Security/correctness:
- ext/[...path] dispatcher now uses requireAuth() instead of inline
  supabase.auth.getUser(), enforcing MFA (AAL2) on hosted across the whole
  enabled-extension surface (banking sync, document upload/booking, supplier
  invoices, migration). Ratchets antipatterns-baseline raw-route-auth 168->165.
- /api/events now filters by the API key's bound company_id instead of the
  user's active company (was a cross-company read with a scoped key).
- enable-banking OAuth callback calls ensureInitialized() at module load so
  the PSD2 consent audit event (ASVS V16 / GDPR Art.30) isn't dropped on a
  cold-start instance.

Dead-code sweep (all confirmed zero importers):
- delete lib/tax/calculator.ts, lib/salary/engangsskatt.ts (+test),
  lib/email/resend.ts, lib/salary/salary-transaction-matcher.ts,
  lib/webhooks/diff.ts, lib/salary/effective-values.ts,
  lib/bookkeeping/template-prompt.ts
- trim unused lib/vat/eu-countries.ts helpers (keep EU_COUNTRIES)
- remove dead getAutomaticStatus() and the abandoned Activepieces CSP entry

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

* fix(invoices): fail closed when a payment journal entry doesn't post

Three mark-paid paths (legacy route, v1 API, agent commit) diverged on the
"mark paid but the JE failed" case — two would flip the invoice to paid (or
leave an orphaned posted voucher) with no booking, silently diverging the GL
from the AR/AP sub-ledger. Unify on fail-closed:

- legacy + v1 + agent commitMarkInvoicePaid: never mark paid without a posted
  voucher; on a null/failed JE return INVOICE_PAID_BOOK_FAILED before any
  state mutation (v1 mirrors the match-invoice strict mode).
- agent path: add the .in('status',[...]).select('id') CAS guard and cancel
  the orphaned voucher (cancelOrphanedPaymentEntry) on a lost race or update
  error, matching the web route.
- legacy route: cancel the orphan on a non-race update error too (was only
  handled on the race branch).
- supplier mark-paid: stop swallowing a failed supplier_invoice_payments
  insert — that row drives the reversal amount in payment-sync; roll back the
  status flip and cancel the voucher instead.
- pending-ops orchestrator: error-check the terminal 'committed' write so an
  op stranded in 'committing' (the expire sweep only targets 'pending') is at
  least logged loudly.

Adds a guard test for the legacy fail-closed path. Full unit suite green.

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

* fix(ci): unblock core build + address compliance-review findings

- avgifter-basis.ts: fix the core-build TypeScript error — PostgREST's
  type-level select parser models the salary_run embed as an array, which
  wasn't assignable to the object-typed generic. Type it `unknown` (rows are
  read via an explicit cast), making it robust across postgrest-js versions.
- /api/events: add a non-null companyId guard before the event_log query
  (defense-in-depth for the API-key-bound scope) — addresses ASVS V8.2.1 /
  ISO A.5.15.
- supplier mark-paid: add a CAS guard (.eq('status', newStatus)) to the
  payment-insert-failure rollback so a concurrent settlement can't be
  clobbered — addresses ASVS V2.3.
- dispatcher: add an AAL2 regression test asserting a non-MFA session is
  rejected (403) and the extension handler never runs — addresses the
  GDPR Art.32 review ask for the single extension chokepoint.

Verified deletions are safe: effective-values.ts was a dead duplicate — the
live AGI/payslip path inlines the same `?? override` coalescing
(generate-declaration.ts), so AGI correctness is unaffected.

next build: exit 0. Full unit suite: 6147 passing. ESLint clean.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-30 14:34:23 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b800dcd403
commit f8504f3bd0
32 changed files with 522 additions and 870 deletions
@@ -127,7 +127,17 @@ function buildFilterChain(data: unknown[]) {
return chain
})
// Make it thenable for await
// Paging stability order — no-op in the mock (data is already deterministic).
chain.order = vi.fn().mockImplementation(() => chain)
// fetchAllRows paginates via .range(from, to); slice so pagination terminates
// correctly even when a test supplies more than one page of rows.
chain.range = vi.fn().mockImplementation((from: number, to: number) => ({
then: (resolve: (val: unknown) => void) =>
resolve({ data: filtered.slice(from, to + 1), error: null }),
}))
// Make it thenable for await (used by callers that don't paginate)
chain.then = (resolve: (val: unknown) => void) => {
resolve({ data: filtered, error: null })
}
+39 -24
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
@@ -24,19 +25,26 @@ export async function getOpenForeignCurrencyReceivables(
supabase: SupabaseClient,
companyId: string
): Promise<Invoice[]> {
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (error) {
throw new BookkeepingDatabaseError('fetch_currency_receivables', error.message)
try {
// Paginated with a stable id order so a company with >1000 open FX invoices
// is fully revalued rather than silently truncated at 1000 rows.
return await fetchAllRows<Invoice>(({ from, to }) =>
supabase
.from('invoices')
.select('*')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (i) => i.id })
} catch (err) {
throw new BookkeepingDatabaseError(
'fetch_currency_receivables',
err instanceof Error ? err.message : 'fetch failed'
)
}
return (data || []) as Invoice[]
}
/**
@@ -48,19 +56,26 @@ export async function getOpenForeignCurrencyPayables(
supabase: SupabaseClient,
companyId: string
): Promise<SupplierInvoice[]> {
const { data, error } = await supabase
.from('supplier_invoices')
.select('*')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (error) {
throw new BookkeepingDatabaseError('fetch_currency_payables', error.message)
try {
// Paginated with a stable id order so a company with >1000 open FX payables
// is fully revalued rather than silently truncated at 1000 rows.
return await fetchAllRows<SupplierInvoice>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('*')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (i) => i.id })
} catch (err) {
throw new BookkeepingDatabaseError(
'fetch_currency_payables',
err instanceof Error ? err.message : 'fetch failed'
)
}
return (data || []) as SupplierInvoice[]
}
/**
-31
View File
@@ -1,31 +0,0 @@
/**
* Template Prompt Builder
*
* Generates the booking template list section for AI extraction prompts.
* Used by both receipt-analyzer and invoice-analyzer to stay in sync.
*/
import { BOOKING_TEMPLATES } from './booking-templates'
/**
* Build the template list section for AI prompts.
* Lists all expense templates with their Swedish name, primary debit account, and VAT rate.
*/
export function buildTemplatePromptSection(): string {
const expenseTemplates = BOOKING_TEMPLATES.filter((t) => t.direction === 'expense')
const lines = expenseTemplates.map((t) => {
const vatInfo = t.vat_rate > 0 ? `moms ${t.vat_rate * 100}%` : 'momsfri'
return `- ${t.id}: ${t.name_sv} (konto ${t.debit_account}, ${vatInfo})`
})
return `BOKFÖRINGSMALLAR (välj den mest passande suggestedTemplateId):
${lines.join('\n')}`
}
/**
* Build a compact template ID list for validation.
*/
export function getValidTemplateIds(): string[] {
return BOOKING_TEMPLATES.map((t) => t.id)
}
-31
View File
@@ -56,37 +56,6 @@ export function daysUntilDeadline(dueDate: string): number {
return Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
/**
* Determine the automatic status based on deadline date
*/
export function getAutomaticStatus(
dueDate: string,
currentStatus: DeadlineStatus
): DeadlineStatus | null {
const daysUntil = daysUntilDeadline(dueDate)
// Already in terminal or user-controlled state
if (['submitted', 'confirmed', 'in_progress'].includes(currentStatus)) {
// Check for overdue on submitted (shouldn't happen often)
if (currentStatus === 'submitted' && daysUntil < 0) {
return null // Keep as submitted, don't change to overdue
}
return null
}
// Past deadline without submission
if (daysUntil < 0 && currentStatus !== 'overdue') {
return 'overdue'
}
// Within action needed threshold
if (daysUntil <= ACTION_NEEDED_THRESHOLD_DAYS && currentStatus === 'upcoming') {
return 'action_needed'
}
return null
}
/**
* Update deadline statuses automatically (called by daily cron)
*/
-17
View File
@@ -1,17 +0,0 @@
/**
* @deprecated Import from '@/lib/email/service' instead.
* This file exists only for backward compatibility.
*/
import { getEmailService } from './service'
import type { SendEmailOptions, SendEmailResult } from './service'
export type { SendEmailOptions, SendEmailResult }
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
return getEmailService().sendEmail(options)
}
export function isResendConfigured(): boolean {
return getEmailService().isConfigured()
}
+61 -3
View File
@@ -28,6 +28,7 @@ import {
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
import type { CommitActor } from '@/lib/bookkeeping/actor-context'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
@@ -1028,16 +1029,59 @@ async function commitMarkInvoicePaid(
)
journalEntryId = je?.id ?? null
}
// Fail closed: a real invoice must produce a posted payment voucher.
// Marking it paid with no journal entry orphans the receivable and
// diverges the GL from the AR sub-ledger. Nothing was posted (the helper
// returned null), so there is no voucher to cancel.
if (!journalEntryId) {
return {
error:
'Betalningen kunde inte bokföras (ingen verifikation skapades — t.ex. stängd räkenskapsperiod). ' +
'Fakturan har inte markerats som betald.',
status: 422,
}
}
}
const now = new Date().toISOString()
const { error: updateError } = await supabase
// CAS guard: only flip from a payable status so a concurrently-settled
// invoice no-ops here instead of double-booking the payment.
const { data: updateResult, error: updateError } = await supabase
.from('invoices')
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
.eq('id', invoiceId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.select('id')
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
if (updateError) {
// The payment voucher already posted but the invoice row did not flip;
// cancel the orphan so the GL doesn't diverge from the sub-ledger.
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase, companyId, userId, journalEntryId,
'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning',
)
}
return { error: 'Failed to update invoice status', status: 500 }
}
if (!updateResult || updateResult.length === 0) {
// Race lost: the invoice was settled concurrently between our read and
// write. Cancel the orphaned payment voucher and document the gap rather
// than leaving a double booking.
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase, companyId, userId, journalEntryId,
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
)
}
return {
error: 'Invoice can only be marked as paid when status is "sent" or "overdue"',
status: 409,
}
}
return { data: { status: 'paid', journal_entry_id: journalEntryId } }
}
@@ -3810,7 +3854,7 @@ async function commitPendingOperationInner(
}
const now = new Date().toISOString()
await supabase
const { error: finalizeError } = await supabase
.from('pending_operations')
.update({
status: 'committed',
@@ -3819,6 +3863,20 @@ async function commitPendingOperationInner(
})
.eq('id', pendingOp.id)
if (finalizeError) {
// The executor's side-effects already committed (and are immutable); only
// the terminal status write failed. Without surfacing this, the row would
// sit in 'committing' indefinitely — the expire sweep only targets
// 'pending' ops, so nothing would ever reconcile it. Log loudly with the
// ids needed to finalize manually; the response still reports success
// because the actual work is done.
log.error('failed to finalize pending_operation to committed (left in committing)', finalizeError, {
pendingOperationId: pendingOp.id,
operationType: pendingOp.operation_type,
companyId,
})
}
return {
status: 'committed',
data: result.data,
@@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
@@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
@@ -71,6 +71,25 @@ describe('generateReconciliation', () => {
expect(result.is_reconciled).toBe(true)
})
it('paginates the 2440 ledger query — sums >1000 lines instead of truncating at 1000', async () => {
// Regression guard for the silent PostgREST 1000-row cap: fetchAllRows must
// page through ALL ledger lines. A full first page (length === PAGE_SIZE)
// forces a second fetch.
// Unique ids so the dedupe-by-id safety net doesn't collapse rows.
const page1 = Array.from({ length: 1000 }, (_, i) => ({ id: `p1-${i}`, debit_amount: 0, credit_amount: 10 }))
const page2 = Array.from({ length: 500 }, (_, i) => ({ id: `p2-${i}`, debit_amount: 0, credit_amount: 10 }))
results = [
{ data: [], error: null }, // 0: supplier_invoices — none open
{ data: page1, error: null }, // 1: 2440 lines page 1 (full → triggers next page)
{ data: page2, error: null }, // 2: 2440 lines page 2 (partial → stop)
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
// 1500 lines × 10 = 15 000. A 1000-row truncation would wrongly yield 10 000.
expect(result.account_2440_balance).toBe(15000)
})
it('detects mismatch when difference != 0', async () => {
results = [
// 0: supplier_invoices — total 5000
+46 -24
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface ARReconciliationResult {
ar_ledger_total: number
@@ -40,11 +41,23 @@ export async function generateARReconciliation(
// total/paid_amount are stored in invoice currency; account 1510 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
const { data: invoices } = await supabase
.from('invoices')
.select('total, paid_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
// Paginated: a company with >1000 open invoices would otherwise be silently
// truncated, manufacturing a phantom reconciliation gap.
const invoices = await fetchAllRows<{
id: string
total: number | null
paid_amount: number | null
currency: string | null
exchange_rate: number | null
}>(({ from, to }) =>
supabase
.from('invoices')
.select('id, total, paid_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.order('id', { ascending: true })
.range(from, to)
)
let unconvertedFxCount = 0
const arLedgerTotal = (invoices || [])
@@ -73,28 +86,37 @@ export async function generateARReconciliation(
// trial balance / balance sheet use. A corrected invoice flips its original to
// status='reversed'; that reversed leg is cancelled by the posted storno, so
// both must be summed or a corrected invoice manufactures a phantom gap.
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.in('account_number', ['1510', '1513'])
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Paginated with a stable id order (+ dedupe defense) so a period with >1000
// ledger lines on 1510/1513 isn't silently truncated into a phantom gap.
const journalLines = await fetchAllRows<{
id: string
debit_amount: number | null
credit_amount: number | null
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.in('account_number', ['1510', '1513'])
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (l) => l.id })
// Both 1510 and 1513 are debit-normal assets: balance = debits - credits
let account1510Balance = 0
if (journalLines) {
for (const line of journalLines) {
account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100
}
for (const line of journalLines) {
account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100
}
const difference = Math.round((arLedgerTotal - account1510Balance) * 100) / 100
+29 -13
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Arbetsgivaravgiftsunderlag — Employer contribution basis report.
@@ -53,21 +54,36 @@ export async function generateAvgifterBasis(
): Promise<AvgifterBasisReport> {
const r = (x: number) => Math.round(x * 100) / 100
// Load all salary run employees for booked runs this year
const { data: runEmployees, error } = await supabase
.from('salary_run_employees')
.select(`
avgifter_basis,
avgifter_amount,
avgifter_rate,
salary_run:salary_runs!inner(period_year, period_month, status)
`)
.eq('company_id', companyId)
if (error) throw new Error(`Failed to load avgifter data: ${error.message}`)
// Load all salary run employees for booked runs this year.
// Paginated with a stable id order: a multi-year/high-headcount company can
// exceed PostgREST's 1000-row cap, and a silent truncation here would
// under-report the arbetsgivaravgifter basis reconciled against AGI filings.
const runEmployees = await fetchAllRows<{
id: string
avgifter_basis: number
avgifter_amount: number
avgifter_rate: number
// PostgREST's type-level select parser models an embedded resource as an
// array, so keep this `unknown` (the rows are read via an explicit cast
// below) to stay assignable regardless of postgrest-js version.
salary_run: unknown
}>(({ from, to }) =>
supabase
.from('salary_run_employees')
.select(`
id,
avgifter_basis,
avgifter_amount,
avgifter_rate,
salary_run:salary_runs!inner(period_year, period_month, status)
`)
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Filter to booked runs for the year
const bookedForYear = (runEmployees || []).filter(sre => {
const bookedForYear = runEmployees.filter(sre => {
const run = sre.salary_run as unknown as { period_year: number; period_month: number; status: string } | null
return run && run.period_year === year && run.status === 'booked'
})
+8 -3
View File
@@ -686,13 +686,18 @@ async function writeMasterData(
await Promise.all(
tables.map(async (t) => {
try {
const rows = await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
const rows = await fetchAllRows<{ id: string } & Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
}
return q.range(from, to)
})
// Always end on the unique PK so paging has a stable TOTAL order. A
// non-unique display order (e.g. created_at) or no order at all
// silently SKIPS/DUPLICATES rows across page boundaries — data loss in
// a statutory 7-year retention archive. (All these tables have an
// `id uuid` PK.) dedupeBy is defense-in-depth against the duplicate case.
return q.order('id', { ascending: true }).range(from, to)
}, { dedupeBy: (r) => r.id })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
data.file(
+14 -11
View File
@@ -727,17 +727,20 @@ export async function generateINK2Declaration(
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch all posted journal entries with lines for this period.
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
// truncation here would under-report the INK2 tax declaration. PostgREST
// ranges count parent rows, so the embedded lines come with each entry.
const entries = await fetchAllRows<JournalEntry>(({ from, to }) =>
supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
+14 -11
View File
@@ -195,17 +195,20 @@ export async function generateNEDeclaration(
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch all posted journal entries with lines for this period.
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
// truncation here would under-report the NE-bilaga tax declaration. PostgREST
// ranges count parent rows, so the embedded lines come with each entry.
const entries = await fetchAllRows<JournalEntry>(({ from, to }) =>
supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
+45 -24
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface ReconciliationResult {
supplier_ledger_total: number
@@ -33,11 +34,22 @@ export async function generateReconciliation(
// remaining_amount is stored in invoice currency; account 2440 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('remaining_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
// Paginated: a company with >1000 open supplier invoices would otherwise be
// silently truncated, manufacturing a phantom reconciliation gap.
const invoices = await fetchAllRows<{
id: string
remaining_amount: number | null
currency: string | null
exchange_rate: number | null
}>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, remaining_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
.order('id', { ascending: true })
.range(from, to)
)
let unconvertedFxCount = 0
const supplierLedgerTotal = (invoices || [])
@@ -68,29 +80,38 @@ export async function generateReconciliation(
// debit balance. (This is exactly the false −41 121,25 kr "Ej avstämd" gap a
// fully-paid, fully-corrected company hit: posted-only = −41 121,25, but
// posted+reversed = 0, matching the leverantörsreskontra.)
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '2440')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Paginated with a stable id order (+ dedupe defense) so a period with >1000
// ledger lines on 2440 isn't silently truncated into a phantom gap.
const journalLines = await fetchAllRows<{
id: string
debit_amount: number | null
credit_amount: number | null
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '2440')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (l) => l.id })
// Account 2440 is a liability: credit normal balance
// Balance = credits - debits
let account2440Balance = 0
if (journalLines) {
for (const line of journalLines) {
account2440Balance = Math.round((account2440Balance + (line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100
}
for (const line of journalLines) {
account2440Balance = Math.round((account2440Balance + (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)) * 100) / 100
}
const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100
+15 -9
View File
@@ -337,21 +337,27 @@ export async function calculateVatDeclaration(
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
.from('journal_entries')
.select('source_type')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end)
// Count journal entries by source type for metadata.
// Paginated with a stable id order so the invoice/transaction counts don't
// silently truncate at 1000 entries for a busy VAT period.
const entryCounts = await fetchAllRows<{ id: string; source_type: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, source_type')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end)
.order('id', { ascending: true })
.range(from, to)
, { dedupeBy: (e) => e.id })
const invoiceSources = new Set([
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
])
let invoiceCount = 0
let transactionCount = 0
for (const e of entryCounts || []) {
for (const e of entryCounts) {
if (invoiceSources.has(e.source_type)) invoiceCount++
else if (e.source_type === 'bank_transaction') transactionCount++
}
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest'
import { calculateEngangsskatt } from '../engangsskatt'
describe('calculateEngangsskatt', () => {
it('calculates 0% tax for very low annual income', () => {
const result = calculateEngangsskatt(5000, 1000)
// Annual: 1000 × 12 + 5000 = 17,000 → bracket 0-20,000 → 0%
expect(result.taxRate).toBe(0.00)
expect(result.taxAmount).toBe(0)
})
it('calculates tax for moderate annual income', () => {
const result = calculateEngangsskatt(10000, 25000)
// Annual: 25000 × 12 + 10000 = 310,000 → bracket 200,001-350,000 → 30%
expect(result.taxRate).toBe(0.30)
expect(result.taxAmount).toBe(3000)
})
it('calculates higher tax when annual income crosses state tax threshold', () => {
const result = calculateEngangsskatt(50000, 55000)
// Annual: 55000 × 12 + 50000 = 710,000 → bracket 660,401-950,000 → 52%
expect(result.taxRate).toBe(0.52)
expect(result.taxAmount).toBe(26000)
})
it('uses total annual income including bonus for bracket lookup', () => {
const result = calculateEngangsskatt(100000, 40000)
// Annual: 40000 × 12 + 100000 = 580,000 → bracket 500,001-660,400 → 34%
expect(result.taxRate).toBe(0.34)
expect(result.taxAmount).toBe(34000)
})
it('returns calculation steps for transparency', () => {
const result = calculateEngangsskatt(10000, 30000)
expect(result.steps.length).toBe(2)
expect(result.steps[0].label).toBe('Beräknad årsinkomst')
expect(result.annualIncomeEstimate).toBe(370000) // 30000 × 12 + 10000
})
it('handles very high income bracket', () => {
const result = calculateEngangsskatt(200000, 120000)
// Annual: 120000 × 12 + 200000 = 1,640,000 → bracket ≥1,500,001 → 57%
expect(result.taxRate).toBe(0.57)
expect(result.taxAmount).toBe(114000)
})
})
-28
View File
@@ -1,28 +0,0 @@
/**
* Effective salary values — coalesce per-employee overrides over the
* engine-computed defaults.
*
* Used by booking (salary-entries.ts) and AGI (agi/generate-declaration.ts)
* so a manual adjustment for FoU-avdrag or jämkning flows through to both
* the ledger and the Skatteverket declaration.
*/
export interface SalaryRunEmployeeWithOverrides {
tax_withheld: number
tax_withheld_override?: number | null
avgifter_amount: number
avgifter_amount_override?: number | null
avgifter_basis: number
avgifter_basis_override?: number | null
}
export function effectiveTax(sre: SalaryRunEmployeeWithOverrides): number {
return sre.tax_withheld_override ?? sre.tax_withheld
}
export function effectiveAvgifter(sre: SalaryRunEmployeeWithOverrides): number {
return sre.avgifter_amount_override ?? sre.avgifter_amount
}
export function effectiveAvgifterBasis(sre: SalaryRunEmployeeWithOverrides): number {
return sre.avgifter_basis_override ?? sre.avgifter_basis
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Engångsskatt — tax on one-time payments (bonuses, retroactive pay, etc.)
*
* Per Skatteverket: One-time payments use a percentage-based tax table,
* not the regular monthly tax table. The rate depends on the employee's
* estimated annual income level.
*
* Skatteverket publishes "Tabell för beräkning av skatteavdrag på
* engångsbelopp" annually.
*
* Simplified 2026 brackets (based on published rates):
*/
export interface EngangsskattResult {
taxRate: number
taxAmount: number
annualIncomeEstimate: number
steps: { label: string; formula: string; output: number }[]
}
/**
* 2026 engångsskatt brackets.
* Rate includes both kommunalskatt and statlig skatt.
* Based on average kommunalskatt (~32.5%).
*/
const ENGANGSSKATT_BRACKETS_2026: Array<{ fromAnnual: number; toAnnual: number; rate: number }> = [
{ fromAnnual: 0, toAnnual: 20000, rate: 0.00 },
{ fromAnnual: 20001, toAnnual: 50000, rate: 0.10 },
{ fromAnnual: 50001, toAnnual: 100000, rate: 0.20 },
{ fromAnnual: 100001, toAnnual: 200000, rate: 0.25 },
{ fromAnnual: 200001, toAnnual: 350000, rate: 0.30 },
{ fromAnnual: 350001, toAnnual: 500000, rate: 0.32 },
{ fromAnnual: 500001, toAnnual: 660400, rate: 0.34 },
{ fromAnnual: 660401, toAnnual: 950000, rate: 0.52 }, // State tax kicks in
{ fromAnnual: 950001, toAnnual: 1500000, rate: 0.55 },
{ fromAnnual: 1500001, toAnnual: Infinity, rate: 0.57 },
]
/**
* Calculate engångsskatt for a one-time payment.
*
* @param oneTimeAmount - The bonus/one-time payment amount
* @param monthlySalary - Employee's regular monthly salary (for estimating annual income)
* @param monthsWorkedThisYear - Months already worked (for pro-rata annual estimate)
*/
export function calculateEngangsskatt(
oneTimeAmount: number,
monthlySalary: number,
monthsWorkedThisYear: number = 12
): EngangsskattResult {
const r = (x: number) => Math.round(x * 100) / 100
// Estimate annual income = regular salary × 12 + one-time amount
const annualRegular = monthlySalary * 12
const annualIncomeEstimate = r(annualRegular + oneTimeAmount)
// Find the bracket based on total annual income including the one-time payment
let taxRate = 0.30 // default fallback
for (const bracket of ENGANGSSKATT_BRACKETS_2026) {
if (annualIncomeEstimate >= bracket.fromAnnual && annualIncomeEstimate <= bracket.toAnnual) {
taxRate = bracket.rate
break
}
}
const taxAmount = r(oneTimeAmount * taxRate)
return {
taxRate,
taxAmount,
annualIncomeEstimate,
steps: [
{
label: 'Beräknad årsinkomst',
formula: 'monthly × 12 + engångsbelopp',
output: annualIncomeEstimate,
},
{
label: `Engångsskatt (${(taxRate * 100).toFixed(0)}%)`,
formula: `engångsbelopp × ${(taxRate * 100).toFixed(0)}%`,
output: taxAmount,
},
],
}
}
-97
View File
@@ -1,97 +0,0 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('salary-transaction-matcher')
/**
* Auto-match salary payment bank transactions to salary journal entries.
*
* When bank transactions arrive (via Enable Banking sync or CSV import),
* this matcher looks for transactions that correspond to salary net payments:
* - Transaction date matches salary run payment_date
* - Transaction amount matches -total_net (negative = outgoing payment)
* - Transaction is not already categorized
*
* On match, links the transaction to the salary journal entry via
* the existing reconciliation system.
*
* Per BFL 5 kap: Bank statement reconciliation is required.
* Per BFNAR 2013:2: Automated matching must be logged.
*/
export async function matchSalaryTransactions(
supabase: SupabaseClient,
companyId: string,
transactionIds: string[]
): Promise<{ matched: number }> {
if (transactionIds.length === 0) return { matched: 0 }
// Load unmatched transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('id, date, amount, description')
.eq('company_id', companyId)
.in('id', transactionIds)
.is('journal_entry_id', null)
.lt('amount', 0) // Only outgoing payments
if (txError || !transactions || transactions.length === 0) return { matched: 0 }
// Load booked salary runs for matching
const { data: salaryRuns, error: srError } = await supabase
.from('salary_runs')
.select('id, payment_date, total_net, salary_entry_id, status')
.eq('company_id', companyId)
.eq('status', 'booked')
if (srError || !salaryRuns || salaryRuns.length === 0) return { matched: 0 }
let matched = 0
for (const tx of transactions) {
// Look for salary run where:
// - payment_date matches transaction date
// - total_net matches |transaction.amount| (within 1 SEK tolerance for öresavrundning)
const txAmount = Math.abs(tx.amount)
const matchingRun = salaryRuns.find(run => {
if (run.payment_date !== tx.date) return false
const diff = Math.abs(run.total_net - txAmount)
return diff <= 1 // 1 SEK tolerance for rounding
})
if (matchingRun && matchingRun.salary_entry_id) {
// Link transaction to the salary journal entry
const { error: linkError } = await supabase
.from('transactions')
.update({
journal_entry_id: matchingRun.salary_entry_id,
is_business: true,
category: 'salary',
})
.eq('id', tx.id)
.is('journal_entry_id', null) // CAS guard
if (!linkError) {
matched++
log.info(`Matched salary transaction ${tx.id} to run ${matchingRun.id} (${txAmount} SEK)`)
// Log the match in payment_match_log for audit trail
await supabase.from('payment_match_log').insert({
company_id: companyId,
transaction_id: tx.id,
match_type: 'salary_payment',
matched_entity_id: matchingRun.id,
matched_entity_type: 'salary_run',
amount: txAmount,
auto_matched: true,
})
}
}
}
if (matched > 0) {
log.info(`Auto-matched ${matched} salary transaction(s) for company ${companyId}`)
}
return { matched }
}
-241
View File
@@ -1,241 +0,0 @@
import type { EntityType, TaxEstimate } from '@/types'
// Current Swedish tax rates (2026)
const TAX_RATES = {
egenavgifter: 0.2897, // 28.97% for EF
bolagsskatt: 0.206, // 20.6% for AB
arbetsgivaravgifter: 0.3142, // 31.42% employer contributions
municipalTax: 0.3238, // 32.38% average municipal tax for 2026
stateTax: 0.20, // 20% state income tax on high incomes
}
// State income tax threshold (brytpunkt) for 2026
const STATE_TAX_THRESHOLD = 643100 // Taxable income above this gets +20% state tax
// Note: The "brytpunkt" is 660,400 kr but that includes grundavdrag
/**
* Calculate progressive grundavdrag (basic deduction) for 2026
* Based on Skatteverket's table - varies by income level
*
* Income ranges and corresponding grundavdrag:
* - 0 - 25,100: grundavdrag = income (no tax)
* - 25,100 - 58,900: 25,100 kr
* - 58,900 - 161,000: Progressive increase up to 45,600 kr
* - 161,000 - 184,900: 45,600 kr (maximum)
* - 184,900 - 466,000: Progressive decrease
* - 466,000+: 17,400 kr (minimum for high earners)
*/
export function calculateGrundavdrag(taxableIncome: number): number {
if (taxableIncome <= 0) return 0
// Very low income - grundavdrag equals income (no tax)
if (taxableIncome <= 25100) {
return taxableIncome
}
// Low income - minimum grundavdrag
if (taxableIncome <= 58900) {
return 25100
}
// Rising phase - interpolate between 25,100 and 45,600
if (taxableIncome <= 161000) {
const progress = (taxableIncome - 58900) / (161000 - 58900)
return Math.round(25100 + progress * (45600 - 25100))
}
// Maximum grundavdrag zone
if (taxableIncome <= 184900) {
return 45600
}
// Declining phase - interpolate between 45,600 and 17,400
if (taxableIncome <= 466000) {
const progress = (taxableIncome - 184900) / (466000 - 184900)
return Math.round(45600 - progress * (45600 - 17400))
}
// High earners - minimum grundavdrag
return 17400
}
/**
* Calculate tax estimates for Enskild Firma
* @param netIncome - Net income after expenses
* @param preliminaryTaxPaidYTD - Preliminary tax paid year to date
* @param _reserved - Reserved for future use (deductions extension)
* @param momsFromUnpaidInvoices - VAT from unpaid invoices that needs to be paid
*/
export function calculateEFTax(
netIncome: number,
preliminaryTaxPaidYTD: number = 0,
_reserved?: unknown,
momsFromUnpaidInvoices: number = 0
): TaxEstimate {
if (netIncome <= 0) {
return {
egenavgifter: 0,
income_tax: 0,
state_tax: 0,
moms_to_pay: momsFromUnpaidInvoices,
total_tax_liability: momsFromUnpaidInvoices,
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: momsFromUnpaidInvoices - preliminaryTaxPaidYTD,
}
}
// Egenavgifter (self-employment contributions) - 28.97%
const egenavgifter = netIncome * TAX_RATES.egenavgifter
// Taxable income (after egenavgifter deduction)
// 25% of egenavgifter is deductible from taxable income
const egenavgifterDeduction = egenavgifter * 0.25
const taxableIncome = netIncome - egenavgifterDeduction
// Calculate progressive grundavdrag based on income level
const grundavdrag = calculateGrundavdrag(taxableIncome)
const incomeAfterGrundavdrag = Math.max(0, taxableIncome - grundavdrag)
// Municipal income tax (~32.38% average for 2026)
const municipalTax = incomeAfterGrundavdrag * TAX_RATES.municipalTax
// State income tax (20% on income above threshold)
// Only applies to taxable income above 643,100 kr
const incomeAboveThreshold = Math.max(0, incomeAfterGrundavdrag - STATE_TAX_THRESHOLD)
const stateTax = incomeAboveThreshold * TAX_RATES.stateTax
const totalIncomeTax = municipalTax + stateTax
const totalTax = egenavgifter + totalIncomeTax + momsFromUnpaidInvoices
return {
egenavgifter: Math.round(egenavgifter),
income_tax: Math.round(municipalTax),
state_tax: Math.round(stateTax),
moms_to_pay: Math.round(momsFromUnpaidInvoices),
total_tax_liability: Math.round(totalTax),
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: Math.round(totalTax - preliminaryTaxPaidYTD),
grundavdrag: Math.round(grundavdrag),
}
}
/**
* Calculate tax estimates for Aktiebolag
* @param profit - Company profit
* @param salaryCostsYTD - Salary costs year to date (for reference)
* @param preliminaryTaxPaidYTD - Preliminary tax paid year to date
* @param momsFromUnpaidInvoices - VAT from unpaid invoices
*/
export function calculateABTax(
profit: number,
salaryCostsYTD: number = 0,
preliminaryTaxPaidYTD: number = 0,
momsFromUnpaidInvoices: number = 0
): TaxEstimate {
// Bolagsskatt on profit
const bolagsskatt = profit > 0 ? profit * TAX_RATES.bolagsskatt : 0
// Arbetsgivaravgifter on salaries (already included in salary costs usually)
// This is just for reference
const totalTax = bolagsskatt + momsFromUnpaidInvoices
return {
bolagsskatt: Math.round(bolagsskatt),
moms_to_pay: Math.round(momsFromUnpaidInvoices),
total_tax_liability: Math.round(totalTax),
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: Math.round(totalTax - preliminaryTaxPaidYTD),
}
}
/**
* Calculate available balance (after tax reservations)
*/
export function calculateAvailableBalance(
currentBalance: number,
taxEstimate: TaxEstimate
): number {
const taxReservation = Math.max(0, taxEstimate.total_tax_liability - taxEstimate.preliminary_paid_ytd)
return Math.max(0, currentBalance - taxReservation)
}
/**
* Format tax breakdown for display
*/
export function formatTaxBreakdown(
entityType: EntityType,
taxEstimate: TaxEstimate
): { label: string; amount: number }[] {
const items: { label: string; amount: number }[] = []
if (entityType === 'enskild_firma') {
if (taxEstimate.egenavgifter) {
items.push({ label: 'Egenavgifter (28,97%)', amount: taxEstimate.egenavgifter })
}
if (taxEstimate.income_tax) {
items.push({ label: 'Kommunalskatt (~32%)', amount: taxEstimate.income_tax })
}
if (taxEstimate.state_tax && taxEstimate.state_tax > 0) {
items.push({ label: 'Statlig skatt (20%)', amount: taxEstimate.state_tax })
}
} else {
if (taxEstimate.bolagsskatt) {
items.push({ label: 'Bolagsskatt (20,6%)', amount: taxEstimate.bolagsskatt })
}
}
if (taxEstimate.moms_to_pay > 0) {
items.push({ label: 'Moms att betala', amount: taxEstimate.moms_to_pay })
}
return items
}
/**
* Calculate balance breakdown for display
* Shows disponibelt, skatt reservation, and moms reservation separately
*/
export function calculateBalanceBreakdown(
currentBalance: number,
taxEstimate: TaxEstimate
): {
disponibelt: number
skattReservation: number
momsReservation: number
totalLocked: number
} {
// VAT reservation (separate from income tax)
const momsReservation = Math.max(0, taxEstimate.moms_to_pay)
// Income tax/egenavgifter reservation (excluding VAT and what's already paid)
const incomeTaxLiability = taxEstimate.total_tax_liability - momsReservation
const skattReservation = Math.max(0, incomeTaxLiability - taxEstimate.preliminary_paid_ytd)
const totalLocked = skattReservation + momsReservation
const disponibelt = Math.max(0, currentBalance - totalLocked)
return {
disponibelt,
skattReservation,
momsReservation,
totalLocked,
}
}
/**
* Calculate available balance (after tax reservations)
* Enhanced version that returns more details
*/
export function calculateAvailableBalanceDetailed(
currentBalance: number,
taxEstimate: TaxEstimate
): {
availableBalance: number
breakdown: ReturnType<typeof calculateBalanceBreakdown>
} {
const breakdown = calculateBalanceBreakdown(currentBalance, taxEstimate)
return {
availableBalance: breakdown.disponibelt,
breakdown,
}
}
-62
View File
@@ -49,65 +49,3 @@ export const EU_COUNTRIES: EUCountry[] = [
{ code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' },
{ code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' },
]
/** EU country codes excluding Sweden (for intra-community checks) */
export const EU_COUNTRY_CODES_EXCL_SE = EU_COUNTRIES
.filter(c => c.code !== 'SE')
.map(c => c.code)
/** All EU country codes including Sweden */
export const EU_COUNTRY_CODES = EU_COUNTRIES.map(c => c.code)
/**
* Build a lookup set of all known names/codes for EU countries (excluding Sweden).
* Handles ISO codes, English names, and Swedish names — all uppercased for matching.
*/
const EU_LOOKUP_EXCL_SE = new Set(
EU_COUNTRIES
.filter(c => c.code !== 'SE')
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
)
const EU_LOOKUP_INCL_SE = new Set(
EU_COUNTRIES
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
)
/**
* Check if a country value is an EU member state (excluding Sweden).
* Accepts ISO codes ("DE"), English names ("Germany"), or Swedish names ("Tyskland").
*/
export function isEUCountry(country: string): boolean {
return EU_LOOKUP_EXCL_SE.has(country.trim().toUpperCase())
}
/**
* Check if a country value is an EU member state (including Sweden).
* Accepts ISO codes, English names, or Swedish names.
*/
export function isEUCountryIncludingSE(country: string): boolean {
return EU_LOOKUP_INCL_SE.has(country.trim().toUpperCase())
}
/** Get EU country data by ISO code, English name, or Swedish name */
export function getEUCountry(country: string): EUCountry | undefined {
const upper = country.trim().toUpperCase()
return EU_COUNTRIES.find(
c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper
)
}
/** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */
export function getVatPrefix(countryCode: string): string | undefined {
return getEUCountry(countryCode)?.vatPrefix
}
/**
* Normalize a country value to its ISO 3166-1 alpha-2 code.
* Accepts ISO codes, English names, or Swedish names.
* Returns the input uppercased if no match is found.
*/
export function toCountryCode(country: string): string {
const found = getEUCountry(country)
return found ? found.code : country.trim().toUpperCase()
}
-49
View File
@@ -1,49 +0,0 @@
/**
* Compute previous_attributes for update-style webhook events.
*
* Stripe pattern: when an entity changes, the webhook payload carries the
* NEW state in `data.object` and a `previous_attributes` field that holds
* ONLY the fields whose values changed, with their PRIOR values. This lets
* receivers diff without an extra GET round-trip.
*
* We compute this from a (priorRow, currentRow) pair captured by the route
* handler before/after its mutation. A field is considered "changed" if
* the JSON-serialised values differ.
*
* Phase 6 PR-1 only emits this for events that fundamentally describe
* mutations of an existing resource (invoice.paid, supplier_invoice.paid,
* supplier_invoice.approved, period.locked, period.unlocked,
* period.year_closed, salary_run.approved, salary_run.booked, ...). Pure
* "created" events leave previous_attributes null.
*/
export function computePreviousAttributes<T extends Record<string, unknown>>(
prior: T | null | undefined,
current: T | null | undefined,
): Record<string, unknown> | null {
if (!prior || !current) return null
const diff: Record<string, unknown> = {}
// Iterate over the union of keys so a removed field is also surfaced.
const keys = new Set([...Object.keys(prior), ...Object.keys(current)])
for (const k of keys) {
const a = prior[k]
const b = current[k]
if (!shallowEquals(a, b)) {
diff[k] = a
}
}
return Object.keys(diff).length > 0 ? diff : null
}
function shallowEquals(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a === null || b === null || a === undefined || b === undefined) return false
// Cheap structural check via JSON; sufficient for the row-shaped objects
// we diff. Field order is stable because both sides are projected from
// the same SELECT.
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}