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:
co-authored by
Claude Opus 4.8
parent
b800dcd403
commit
f8504f3bd0
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user