Fix/m sprint fixes (#613)

* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

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

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

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

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
+34
View File
@@ -402,6 +402,19 @@ export const MarkSupplierInvoicePaidSchema = z.object({
exchange_rate_difference: z.number().optional(),
notes: z.string().optional(),
force: z.boolean().optional(),
// Which BAS account to credit for the payment. Defaults to 1930 to preserve
// the historical behaviour for MCP / agent callers that don't supply it.
payment_account: accountNumber.optional(),
// Optional user-edited journal entry rows. When present they override the
// default 2440-clearing / cash booking. Server validates balance and posts
// via createJournalEntry directly. source_type still derives from the
// routing decision so downstream payment-sync keeps working.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
})
export const UpdateSupplierInvoiceSchema = z.object({
@@ -492,6 +505,17 @@ export const MatchInvoiceSchema = z
// specific, user-seen duplicate so an automation can't sweep through
// force=true to bypass the guard without ever consulting the candidate.
expected_journal_entry_id: uuid.optional(),
// Optional user-edited journal entry lines. When present they override
// the default clearing/cash booking — the route validates balance and
// posts via createJournalEntry directly. Source_type is still set from
// the routing decision (invoice_paid vs invoice_cash_payment) so
// downstream payment-sync continues to work.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
@@ -639,6 +663,15 @@ export const CreateTransactionFromDocumentSchema = z.object({
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
// Same purpose as MatchInvoiceSchema.lines — user-edited rows override
// the default 2440-clearing / cash booking. Route validates balance and
// posts via createJournalEntry; source_type still derives from routing.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
})
@@ -1314,6 +1347,7 @@ export const AbsenceTypeSchema = z.enum([
'pregnancy',
'care_relative',
'study',
'unpaid_leave',
'other_leave',
])
@@ -29,6 +29,7 @@ function makeTx(overrides: Partial<Transaction> = {}): Transaction {
receipt_id: null,
document_id: null,
reconciliation_method: null,
is_ignored: false,
import_source: 'enable_banking',
reference: null,
counterparty_iban: 'SE9550000000054910000003',
@@ -0,0 +1,159 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { isPaymentSourceType, syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { JournalEntry } from '@/types'
describe('isPaymentSourceType', () => {
it.each([
'invoice_paid',
'invoice_cash_payment',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
])('recognises %s as payment', (sourceType) => {
expect(isPaymentSourceType(sourceType)).toBe(true)
})
it.each(['manual', 'invoice_created', 'supplier_invoice_registered', '', null, undefined])(
'rejects %s',
(sourceType) => {
expect(isPaymentSourceType(sourceType)).toBe(false)
}
)
})
describe('syncInvoiceStatusFromPaymentEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function entry(overrides: Partial<JournalEntry> = {}): Pick<JournalEntry, 'id' | 'source_type' | 'source_id'> {
return {
id: 'entry-1',
source_type: 'supplier_invoice_paid',
source_id: 'supplier-invoice-1',
...overrides,
} as Pick<JournalEntry, 'id' | 'source_type' | 'source_id'>
}
it('is a no-op when source_type is not a payment', async () => {
const { supabase } = createQueuedMockSupabase()
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'manual' as JournalEntry['source_type'] })
)
expect(supabase.from).not.toHaveBeenCalled()
})
it('is a no-op when source_id is missing', async () => {
const { supabase } = createQueuedMockSupabase()
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_id: null })
)
expect(supabase.from).not.toHaveBeenCalled()
})
it('reverts a fully-paid supplier invoice back to approved', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
// Fully paid before deletion: paid_amount === total_amount
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: null }, // UPDATE result
])
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual([
'supplier_invoice_payments',
'supplier_invoices',
'supplier_invoices',
])
})
it('reverts a partially-paid supplier invoice to partially_paid when paid_amount remains', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 500 } }, // payment being reversed
// Started with 1000 paid (multiple payments), reversing 500
{ data: { paid_amount: 1000, total_amount: 1500, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
// Test passes if the queries fire in the expected order without error
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(3)
})
it('routes customer invoice entries through the invoices table', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'invoice_paid', source_id: 'invoice-1' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['invoice_payments', 'invoices', 'invoices'])
})
it('handles invoice_cash_payment the same way as invoice_paid', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 500 } },
{ data: { paid_amount: 500, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls[0]).toBe('invoice_payments')
expect(fromCalls[1]).toBe('invoices')
})
it('handles supplier_invoice_cash_payment the same way as supplier_invoice_paid', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'supplier_invoice_cash_payment' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls[0]).toBe('supplier_invoice_payments')
expect(fromCalls[1]).toBe('supplier_invoices')
})
it('does not error when no payment row exists for the supplier entry', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null }, // no payment row
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
])
await expect(
syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
).resolves.toBeUndefined()
})
})
@@ -877,6 +877,43 @@ describe('createSupplierInvoicePaymentEntry', () => {
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)')
})
it('credits the provided paymentAccount instead of 1930', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01',
undefined, undefined, '1940'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '1940')[0].credit_amount).toBe(10000)
})
it('falls back to 1930 when paymentAccount is undefined', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
})
it('uses paymentAccount on the FX-difference branch too', async () => {
const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 11500, '2024-07-15',
500, undefined, '2018'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '2018')[0].credit_amount).toBe(11000)
})
it('uses paymentDate not invoice_date as entry_date', async () => {
const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
@@ -936,6 +973,23 @@ describe('createSupplierInvoiceCashEntry', () => {
assertBalanced(input)
})
it('credits the provided paymentAccount instead of 1930', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000, vat_amount: 2000, total: 10000,
})
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business',
undefined, '2018'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '2018')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('domestic zero VAT', async () => {
const invoice = makeSupplierInvoice({
subtotal: 5000,
+7 -86
View File
@@ -12,6 +12,7 @@ import {
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -589,92 +590,12 @@ export async function reverseEntry(
throw new EntryAlreadyReversedError()
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status
const paymentSourceTypes = [
'invoice_paid', 'invoice_cash_payment',
'supplier_invoice_paid', 'supplier_invoice_cash_payment',
]
if (paymentSourceTypes.includes(original.source_type) && original.source_id) {
// The GL reversal is already handled above (line-by-line mirror of the original
// verifikation per BFL 5 kap 5§). Here we sync the business-level invoice state.
// Payment amounts come from the payments table, not from GL line inspection —
// this works identically for kontantmetod and faktureringsmetod.
const entryId = original.id
if (original.source_type.startsWith('supplier_invoice')) {
const { data: payment } = await supabase
.from('supplier_invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('paid_amount, total_amount, due_date')
.eq('id', original.source_id)
.eq('company_id', companyId)
.single()
if (supplierInvoice && payment) {
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
newStatus = 'overdue'
} else {
newStatus = 'approved'
}
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
paid_amount: Math.max(0, newPaidAmount),
remaining_amount: newRemaining,
paid_at: null,
payment_journal_entry_id: null,
})
.eq('id', original.source_id)
.eq('company_id', companyId)
}
} else {
const { data: payment } = await supabase
.from('invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, due_date')
.eq('id', original.source_id)
.eq('company_id', companyId)
.single()
if (customerInvoice) {
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
? 'overdue'
: 'sent'
await supabase
.from('invoices')
.update({
status: revertStatus,
paid_at: null,
paid_amount: Math.max(0, newPaidAmount),
})
.eq('id', original.source_id)
.eq('company_id', companyId)
.in('status', ['paid', 'partially_paid'])
}
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status.
// Helper is shared with the DELETE journal entry route so both code paths leave
// the invoice in a consistent state (BFL 5 kap 5§ requires GL reversal; this
// covers the business-level state that lives outside the GL).
if (isPaymentSourceType(original.source_type)) {
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, original as JournalEntry)
}
// Fetch complete reversal entry with lines
+107
View File
@@ -0,0 +1,107 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { JournalEntry } from '@/types'
export const PAYMENT_SOURCE_TYPES = [
'invoice_paid',
'invoice_cash_payment',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
] as const
export function isPaymentSourceType(sourceType: string | null | undefined): boolean {
if (!sourceType) return false
return (PAYMENT_SOURCE_TYPES as readonly string[]).includes(sourceType)
}
/**
* Revert the business-level paid status on the invoice or supplier invoice
* that a payment journal entry was attached to. Used by both reverseEntry()
* (storno) and the DELETE journal entry route — both paths leave the GL in a
* consistent state but the invoice's status/paid_amount/paid_at would otherwise
* stay stuck on "paid".
*
* Safe to call with any entry — returns early if source_type is not a payment.
*/
export async function syncInvoiceStatusFromPaymentEntry(
supabase: SupabaseClient,
companyId: string,
entry: Pick<JournalEntry, 'id' | 'source_type' | 'source_id'>
): Promise<void> {
if (!isPaymentSourceType(entry.source_type) || !entry.source_id) return
const entryId = entry.id
if (entry.source_type.startsWith('supplier_invoice')) {
const { data: payment } = await supabase
.from('supplier_invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('paid_amount, total_amount, due_date')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
if (supplierInvoice && payment) {
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
newStatus = 'overdue'
} else {
newStatus = 'approved'
}
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
paid_amount: Math.max(0, newPaidAmount),
remaining_amount: newRemaining,
paid_at: null,
payment_journal_entry_id: null,
})
.eq('id', entry.source_id)
.eq('company_id', companyId)
}
} else {
const { data: payment } = await supabase
.from('invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, due_date')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
if (customerInvoice) {
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
? 'overdue'
: 'sent'
await supabase
.from('invoices')
.update({
status: revertStatus,
paid_at: null,
paid_amount: Math.max(0, newPaidAmount),
})
.eq('id', entry.source_id)
.eq('company_id', companyId)
.in('status', ['paid', 'partially_paid'])
}
}
}
+10 -6
View File
@@ -202,8 +202,10 @@ export async function createSupplierInvoicePaymentEntry(
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number,
supplierName?: string
supplierName?: string,
paymentAccount?: string
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
@@ -228,7 +230,7 @@ export async function createSupplierInvoicePaymentEntry(
// Credit: Bank at actual SEK paid
lines.push({
account_number: '1930',
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(actualSekPaid * 100) / 100,
line_description: desc,
@@ -262,7 +264,7 @@ export async function createSupplierInvoicePaymentEntry(
})
lines.push({
account_number: '1930',
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(paymentAmount * 100) / 100,
line_description: desc,
@@ -297,8 +299,10 @@ export async function createSupplierInvoiceCashEntry(
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string,
supplierName?: string
supplierName?: string,
paymentAccount?: string
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
@@ -370,12 +374,12 @@ export async function createSupplierInvoiceCashEntry(
}
}
// Credit: Företagskonto — balance guarantee: ensures sum(debits) === sum(credits)
// Credit: payment account — balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '1930',
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
/**
* Chained-correction invariant: a posted entry of source_type='correction'
* can itself be reversed and corrected — the chain just grows. The UI used
* to block this; the storno-service never did. This test asserts the DB
* layer accepts the full two-level chain (CHECK constraint, FK, immutability
* trigger), so any future migration that accidentally tightens one of those
* will fail loudly here instead of silently breaking BFL 5 kap. 5 § flows.
*
* The flow this mirrors:
* 1. Original posted (manual)
* 2. correctEntry → storno-1 + correction-1, original → reversed
* 3. correctEntry on correction-1 → storno-2 + correction-2,
* correction-1 → reversed
*
* We drive the SQL directly because correctEntry uses the Supabase JS client
* which is out of scope for the pg-real harness (see year-end-invariants.pg
* for the same rationale).
*/
describe('chained correction (pg-real)', () => {
it('accepts a correction whose original is itself a correction', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const pool = getPool()
async function insertDraft(opts: {
sourceType: string
reversesId?: string | null
correctionOfId?: string | null
}): Promise<string> {
const id = randomUUID()
await pool.query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status, reverses_id, correction_of_id)
VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-15', $5, $6, 'draft', $7, $8)`,
[
id,
userId,
companyId,
fiscalPeriodId,
`Entry ${opts.sourceType}`,
opts.sourceType,
opts.reversesId ?? null,
opts.correctionOfId ?? null,
],
)
return id
}
async function insertLines(entryId: string, debitAcc: string, creditAcc: string, amount: number) {
await pool.query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[entryId, debitAcc, amount, creditAcc],
)
}
async function commit(entryId: string): Promise<number> {
const { rows } = await pool.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryId],
)
return rows[0]!.voucher_number
}
async function markReversed(entryId: string, reversedById: string) {
await pool.query(
`UPDATE public.journal_entries
SET status = 'reversed', reversed_by_id = $2
WHERE id = $1 AND status = 'posted'`,
[entryId, reversedById],
)
}
// === Step 1: original posted ===
const originalId = await insertDraft({ sourceType: 'manual' })
await insertLines(originalId, '5410', '1930', 1000)
await commit(originalId)
// === Step 2: first storno + first correction ===
const storno1Id = await insertDraft({ sourceType: 'storno', reversesId: originalId })
await insertLines(storno1Id, '1930', '5410', 1000) // swapped legs
await commit(storno1Id)
await markReversed(originalId, storno1Id)
const correction1Id = await insertDraft({
sourceType: 'correction',
correctionOfId: originalId,
})
await insertLines(correction1Id, '5420', '1930', 1200)
await commit(correction1Id)
// Sanity: original is reversed, correction1 is posted.
const { rows: midRows } = await pool.query<{ id: string; status: string; source_type: string }>(
`SELECT id, status, source_type FROM public.journal_entries
WHERE id = ANY($1::uuid[])`,
[[originalId, correction1Id]],
)
const midState = Object.fromEntries(midRows.map((r) => [r.id, r]))
expect(midState[originalId]?.status).toBe('reversed')
expect(midState[correction1Id]?.status).toBe('posted')
expect(midState[correction1Id]?.source_type).toBe('correction')
// === Step 3: storno + correction OF the first correction ===
// This is the new path. The DB must accept reverses_id and correction_of_id
// pointing at a source_type='correction' entry, and accept a second
// entry of source_type='correction' in the same period.
const storno2Id = await insertDraft({ sourceType: 'storno', reversesId: correction1Id })
await insertLines(storno2Id, '1930', '5420', 1200)
await commit(storno2Id)
await markReversed(correction1Id, storno2Id)
const correction2Id = await insertDraft({
sourceType: 'correction',
correctionOfId: correction1Id,
})
await insertLines(correction2Id, '5430', '1930', 1500)
const correction2Voucher = await commit(correction2Id)
expect(correction2Voucher).toBeGreaterThan(0)
// === Final assertions: full chain is intact ===
const { rows: finalRows } = await pool.query<{
id: string
status: string
source_type: string
reverses_id: string | null
correction_of_id: string | null
reversed_by_id: string | null
}>(
`SELECT id, status, source_type, reverses_id, correction_of_id, reversed_by_id
FROM public.journal_entries
WHERE company_id = $1
ORDER BY voucher_number`,
[companyId],
)
expect(finalRows).toHaveLength(5)
const state = Object.fromEntries(finalRows.map((r) => [r.id, r]))
expect(state[originalId]).toMatchObject({
status: 'reversed',
source_type: 'manual',
reversed_by_id: storno1Id,
})
expect(state[storno1Id]).toMatchObject({
status: 'posted',
source_type: 'storno',
reverses_id: originalId,
})
expect(state[correction1Id]).toMatchObject({
status: 'reversed',
source_type: 'correction',
correction_of_id: originalId,
reversed_by_id: storno2Id,
})
expect(state[storno2Id]).toMatchObject({
status: 'posted',
source_type: 'storno',
reverses_id: correction1Id,
})
expect(state[correction2Id]).toMatchObject({
status: 'posted',
source_type: 'correction',
correction_of_id: correction1Id,
})
})
})
@@ -276,6 +276,55 @@ describe('correctEntry', () => {
expect(result.corrected).toBeDefined()
})
it('accepts a source_type=correction entry as the original (chained correction, BFL 5 kap. 5 §)', async () => {
// The user just corrected entry A → got correction C. They now want to
// correct C. Service must not care about source_type of the original —
// status='posted' is the only constraint.
const correctionAsOriginal = makeJournalEntry({
id: 'correction-1',
status: 'posted',
source_type: 'correction',
correction_of_id: 'orig-A',
description: 'Rättelse: Test purchase',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5420', debit_amount: 1200, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1200 }),
],
})
const secondReversal = makeJournalEntry({ id: 'reversal-2', reverses_id: 'correction-1' })
const secondCorrection = makeJournalEntry({
id: 'correction-2',
correction_of_id: 'correction-1',
source_type: 'correction',
})
results = [
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
{ data: secondReversal, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: secondCorrection, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
{ data: { ...secondReversal, lines: [] }, error: null }, // 9: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 10: fetch final corrected
]
const supabase = makeClient()
const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'correction-1', [
{ account_number: '5430', debit_amount: 1500, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1500 },
])
expect(result.reversal.reverses_id).toBe('correction-1')
expect(result.corrected.correction_of_id).toBe('correction-1')
expect(result.corrected.source_type).toBe('correction')
})
it('emits journal_entry.corrected event', async () => {
setupResults()
@@ -0,0 +1,234 @@
/**
* Regression suite for the Lookma AB support case (2026-05-28).
*
* The bug: gnubok_import_sie + executeSIEImport accepted mappings that
* couldn't cover a single account in the file. The per-voucher loop then
* silently skipped every verifikation, finalizeImportRecord marked the
* sie_imports row 'completed' with transactions_count=0, and the partial
* unique index on (company_id, file_hash) held the slot — blocking retry.
*
* The fix layers three guards:
* 1. Stage-time refusal in gnubok_import_sie (covered in
* extensions/general/mcp-server/__tests__/import-sie-stage.test.ts).
* 2. Defense-in-depth refusal in executeSIEImport (this file).
* 3. Finalizer downgrade of any 0-entry success to 'failed' (this file).
*/
import { describe, it, expect } from 'vitest'
import { executeSIEImport, finalizeImportRecord } from '../sie-import'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ParsedSIEFile, AccountMapping, ImportResult } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
return {
header: {
sieType: 4,
flagga: 0,
program: 'TestProg',
programVersion: '1.0',
generatedDate: '2024-01-01',
format: 'PC8',
companyName: 'Lookma Mock AB',
orgNumber: '5567201701',
address: null,
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
currency: 'SEK',
kontoPlanType: null,
},
accounts: [
{ number: '1930', name: 'Företagskonto' },
{ number: '6110', name: 'Kontorsmaterial' },
],
openingBalances: [{ yearIndex: 0, account: '1930', amount: 50000 }],
closingBalances: [],
resultBalances: [],
vouchers: [
{
series: 'A',
number: 1,
date: new Date(2024, 0, 15),
description: 'Inköp',
lines: [
{ account: '6110', amount: 1000 },
{ account: '1930', amount: -1000 },
],
},
],
issues: [],
stats: {
totalAccounts: 2,
totalVouchers: 1,
totalTransactionLines: 2,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
...overrides,
}
}
function makeMapping(source: string, target: string | null): AccountMapping {
return {
sourceAccount: source,
sourceName: `Account ${source}`,
targetAccount: target as string,
targetName: target ? `Target ${target}` : '',
confidence: target ? 1 : 0,
matchType: target ? 'exact' : 'manual',
isOverride: false,
}
}
describe('executeSIEImport — defense-in-depth coverage check', () => {
it('refuses to insert a sie_imports row when mappings is empty', async () => {
const { supabase } = createQueuedMockSupabase()
const parsed = makeParsedFile()
const result = await executeSIEImport(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
parsed,
[],
{
filename: 'lookma.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: false,
importTransactions: true,
},
)
expect(result.success).toBe(false)
expect(result.importId).toBeNull()
expect(result.errors.join(' ')).toMatch(/täcker inga konton/i)
})
it('refuses when mappings exist but cover none of the file\'s accounts', async () => {
const { supabase } = createQueuedMockSupabase()
const parsed = makeParsedFile()
const result = await executeSIEImport(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
parsed,
[makeMapping('9999', '9999')],
{
filename: 'wrong.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: false,
importTransactions: true,
},
)
expect(result.success).toBe(false)
expect(result.importId).toBeNull()
expect(result.errors.join(' ')).toMatch(/täcker inga konton/i)
})
it('still rejects mappings with targetAccount=null (existing guard)', async () => {
const { supabase } = createQueuedMockSupabase()
const parsed = makeParsedFile()
const result = await executeSIEImport(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
parsed,
[makeMapping('6110', null), makeMapping('1930', null)],
{
filename: 'half.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: false,
importTransactions: true,
},
)
expect(result.success).toBe(false)
expect(result.errors.join(' ')).toMatch(/not mapped/i)
})
})
describe('finalizeImportRecord — 0-entry downgrade', () => {
it('flips a 0-entry success to status=failed and records the reason', async () => {
const { supabase } = createQueuedMockSupabase()
const result: ImportResult = {
success: true,
importId: 'imp-1',
fiscalPeriodId: 'fp-1',
openingBalanceEntryId: null,
journalEntriesCreated: 0,
journalEntryIds: [],
errors: [],
warnings: ['100 verifikationer hoppades över med ej mappade konton'],
replacedPriorImport: null,
}
await finalizeImportRecord(
supabase as unknown as SupabaseClient,
'imp-1',
'company-1',
result,
'#dummy',
)
expect(result.success).toBe(false)
expect(result.errors.join(' ')).toMatch(/0 verifikationer/i)
})
it('leaves a successful run with entries alone', async () => {
const { supabase } = createQueuedMockSupabase()
const result: ImportResult = {
success: true,
importId: 'imp-2',
fiscalPeriodId: 'fp-2',
openingBalanceEntryId: null,
journalEntriesCreated: 42,
journalEntryIds: Array(42).fill('je'),
errors: [],
warnings: [],
replacedPriorImport: null,
}
await finalizeImportRecord(
supabase as unknown as SupabaseClient,
'imp-2',
'company-1',
result,
'#dummy',
)
expect(result.success).toBe(true)
expect(result.errors).toEqual([])
})
it('leaves a 0-voucher run alone when an OB entry was created', async () => {
const { supabase } = createQueuedMockSupabase()
const result: ImportResult = {
success: true,
importId: 'imp-3',
fiscalPeriodId: 'fp-3',
openingBalanceEntryId: 'ob-1',
journalEntriesCreated: 1,
journalEntryIds: ['ob-1'],
errors: [],
warnings: [],
replacedPriorImport: null,
}
await finalizeImportRecord(
supabase as unknown as SupabaseClient,
'imp-3',
'company-1',
result,
'#dummy',
)
expect(result.success).toBe(true)
})
})
@@ -0,0 +1,111 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Migration 20260529120000_sie_imports_undone_release_slot.sql extends
// the partial unique index sie_imports_company_id_file_hash_active_idx
// to also exclude 'undone'. Without this, undo_sie_import marks a row
// 'undone' but the slot stays held — the caller cannot re-import the
// same file.
async function insertSIEImport(params: {
companyId: string
userId: string
fileHash: string
status: 'pending' | 'mapped' | 'completed' | 'failed' | 'replaced' | 'undone'
fiscalPeriodId?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.sie_imports
(id, user_id, company_id, filename, file_hash, sie_type,
fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
status, fiscal_period_id, imported_at)
VALUES ($1, $2, $3, 'undone-test.se', $4, 4,
'2026-01-01', '2026-12-31', 0, 0,
$5, $6, $7)`,
[
id,
params.userId,
params.companyId,
params.fileHash,
params.status,
params.fiscalPeriodId ?? null,
params.status === 'completed' ? new Date().toISOString() : null,
],
)
return id
}
describe('sie_imports partial unique index: undone status releases the slot', () => {
it('still blocks a duplicate active row (regression guard)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const hash = `hash-${randomUUID()}`
await insertSIEImport({ companyId, userId, fileHash: hash, status: 'completed', fiscalPeriodId })
await expect(
insertSIEImport({ companyId, userId, fileHash: hash, status: 'pending', fiscalPeriodId }),
).rejects.toThrow(/sie_imports_company_id_file_hash_active_idx/)
})
it('allows a new pending row once the prior is undone', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const hash = `hash-${randomUUID()}`
const priorId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'completed',
fiscalPeriodId,
})
// Mimic undo_sie_import's terminal write (we don't run the RPC here —
// the RPC also detaches docs + deletes JEs which need richer setup).
await getPool().query(
`UPDATE public.sie_imports SET status = 'undone', replaced_at = now() WHERE id = $1`,
[priorId],
)
const newId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'pending',
fiscalPeriodId,
})
expect(newId).toBeTruthy()
})
it('allows a re-import after a 0-entry vacuous import is backfilled to failed', async () => {
// Mirrors the Lookma AB recovery path: 0-entry 'completed' rows are
// backfilled to 'failed' by the same migration; the partial index
// already excludes 'failed', so a fresh re-import succeeds.
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const hash = `hash-${randomUUID()}`
const stuckId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'completed',
fiscalPeriodId,
})
await getPool().query(
`UPDATE public.sie_imports SET status = 'failed' WHERE id = $1`,
[stuckId],
)
const newId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'pending',
fiscalPeriodId,
})
expect(newId).toBeTruthy()
})
})
+52 -1
View File
@@ -1641,7 +1641,7 @@ async function createPendingImportRecord(
/**
* Phase 2: Finalize the import record with results and archive the SIE file.
*/
async function finalizeImportRecord(
export async function finalizeImportRecord(
supabase: SupabaseClient,
importId: string,
companyId: string,
@@ -1649,6 +1649,28 @@ async function finalizeImportRecord(
fileContent: string,
documentation?: MigrationDocumentation
): Promise<void> {
// Safety net: if the import ran without errors but didn't actually create
// any journal entries (no OB entry, no vouchers), refuse to mark it as
// 'completed'. A 'completed' row with transactions_count=0 would claim
// the (company_id, file_hash) slot in the partial unique index and the
// overlapping-period check would block any retry. Flipping to 'failed'
// (which the partial index already excludes) keeps the slot free so the
// caller can re-import the same file once the mapping is fixed.
const noEntriesCreated =
result.success &&
result.journalEntriesCreated === 0 &&
!result.openingBalanceEntryId
if (noEntriesCreated) {
result.success = false
if (result.errors.length === 0) {
result.errors.push(
'Importen skapade 0 verifikationer — markerar som misslyckad så filen ' +
'kan importeras om utan replace/undo. Granska varningarna för att se ' +
'vilka konton som behöver mappas.',
)
}
}
const status = result.success ? 'completed' : 'failed'
await supabase
@@ -1799,6 +1821,35 @@ export async function executeSIEImport(
return result
}
// Defense in depth: refuse to enter executeSIEImport when the mapping
// doesn't cover a single account present in the file. Without this guard
// a stale MCP client (or the HTTP execute route) could still drive
// importVouchers to silently skip every voucher and write a 0-entry
// 'completed' sie_imports row that holds the unique-index slot. Mirrors
// the stage-time check in gnubok_import_sie.
const sourceAccountsInFile = new Set<string>()
for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account)
if (options.importOpeningBalances) {
for (const b of parsed.openingBalances.filter((b) => b.yearIndex === 0)) {
sourceAccountsInFile.add(b.account)
}
}
const mappedSources = new Set(
mappings.filter((m) => m.targetAccount).map((m) => m.sourceAccount),
)
const hasOverlap = [...sourceAccountsInFile].some((a) => mappedSources.has(a))
if (sourceAccountsInFile.size > 0 && !hasOverlap) {
const sample = [...sourceAccountsInFile].slice(0, 8).join(', ')
result.errors.push(
`Kontomappningarna täcker inga konton i SIE-filen. ` +
`Filen innehåller ${sourceAccountsInFile.size} unika källkonton ` +
`(t.ex. ${sample}), men inget av dem finns i mappings.sourceAccount. ` +
`Importen avbryts innan en sie_imports-rad skapas så att du kan ` +
`försöka igen med korrekta mappningar.`,
)
return result
}
// Replace mode: if a prior completed import overlaps the new SIE's fiscal
// year, mark it 'replaced' (and cancel its imported entries) before we
// try to insert. Done before checkDuplicateImport / checkDuplicatePeriodImport
+45 -7
View File
@@ -42,7 +42,7 @@ import {
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
import { AccountsNotInChartError, isBookkeepingError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import { getEmailService } from '@/lib/email/service'
@@ -661,15 +661,21 @@ async function commitMarkInvoicePaid(
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
// Route on invoice state, not the company's current accounting_method —
// an invoice booked at send under accrual must clear 1510 here even if
// the company has since switched to kontantmetoden.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
if (isRealInvoice) {
if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
if (useCashEntry) {
const je = await createInvoiceCashEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoiceCashEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
const je = await createInvoicePaymentJournalEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
)
journalEntryId = je?.id ?? null
}
@@ -910,9 +916,14 @@ async function commitMatchTransactionInvoice(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Route on invoice state, not the company's current setting. Mirror of
// the match-invoice route fix — see that handler for the full rationale.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
if (useCashEntry) {
const je = await createInvoiceCashEntry(
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
)
@@ -2155,6 +2166,30 @@ async function commitImportSie(
}
}
async function commitUndoSieImport(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
const importId = params.import_id as string
if (!importId) {
return { error: 'import_id is required', status: 400 }
}
const result = await undoSIEImport(supabase, companyId, importId)
if (!result.success) {
return { error: result.error ?? 'SIE undo failed', status: 400 }
}
return {
data: {
import_id: importId,
deleted_entries: result.deletedEntries,
},
}
}
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
/**
@@ -2700,6 +2735,9 @@ export async function commitPendingOperation(
case 'import_sie':
result = await commitImportSie(supabase, userId, companyId, pendingOp.params)
break
case 'undo_sie_import':
result = await commitUndoSieImport(supabase, companyId, pendingOp.params)
break
case 'create_voucher':
result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params, opts)
break
+3
View File
@@ -61,6 +61,9 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// but not the irreversible tier that year-end close / period lock occupy.
post_annual_depreciation: 'medium',
import_sie: 'high',
// Hard-deletes the import's journal entries + resets voucher sequences.
// Same destructive reach as replace_sie_import; never auto-commit.
undo_sie_import: 'high',
explain_voucher_gap: 'medium',
uncategorize_transaction: 'medium',
approve_supplier_invoice: 'high',
+7 -3
View File
@@ -180,6 +180,7 @@ export async function runReconciliation(
.select('*')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
.eq('currency', currency)
if (dateFrom) query = query.gte('date', dateFrom)
@@ -262,10 +263,13 @@ export async function getReconciliationStatus(
bankAccount = '1930',
currency: string = 'SEK',
): Promise<ReconciliationStatus> {
// Get all transactions in range
// Get all transactions in range. Ignored rows are pulled too so the totals
// card still reflects what the bank actually moved, but they're excluded
// from the "unmatched" count below — the user has explicitly said they
// don't want them surfacing as something to reconcile.
let txQuery = supabase
.from('transactions')
.select('amount, journal_entry_id, reconciliation_method')
.select('amount, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
.eq('currency', currency)
@@ -328,7 +332,7 @@ export async function getReconciliationStatus(
).length
const unmatchedTransactionCount = (transactions || []).filter(
(tx) => tx.journal_entry_id === null
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
).length
// Unlinked GL lines count (RPC excludes source_type='opening_balance' since
+175 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate, calculateVacationAccrual } from '../calculation-engine'
import {
calculateSalary,
calculateKarensavdrag,
calculateSjuklon,
calculateAvgifterRate,
calculateVacationAccrual,
prorateBaseSalaryForPeriod,
} from '../calculation-engine'
import type { PayrollConfig } from '../payroll-config'
import type { TaxTableRate } from '../tax-tables'
@@ -384,6 +391,173 @@ describe('calculateSalary', () => {
})
})
// ============================================================
// Partial-month employment proration
// ============================================================
//
// May 2026 calendar (Mon-Fri only):
// May 1 (Fri), May 4-8 (5), May 11-15 (5), May 18-22 (5), May 25-29 (5)
// → 21 workdays total in May.
// An employee hired May 15 (Fri) works May 15, 18-22, 25-29 → 11 workdays.
// 11 / 21 ≈ 0.5238 → 40 000 SEK × 0.5238 ≈ 20 952,38 SEK.
describe('partial-month employment proration', () => {
it('prorates base salary for an employee hired mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 11 / 21 = 20 952,38 (rounded via engine's r())
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('prorates base salary for an employee terminated mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2020-01-01',
employmentEnd: '2026-05-15',
}),
config2026,
emptyTaxRates,
)
// May 1, 4-8, 11-15 = 11 workdays → 40 000 × 11 / 21
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('does not prorate when the employee covers the full period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2024-01-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('returns 0 gross when employment does not overlap the period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-06-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(0)
})
it('combines employment proration with employment_degree', () => {
const result = calculateSalary(
makeBasicInput({
employmentDegree: 50,
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 50% × 11 / 21 = 20 000 × 11 / 21 ≈ 10 476,19
expect(result.grossSalary).toBeCloseTo(10476.19, 2)
})
it('skips proration when period bounds are missing (backward compat)', () => {
const result = calculateSalary(
makeBasicInput({
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('subtracts unpaid_leave once on top of proration (no double-counting)', () => {
// 40 000 × 11/21 (mid-month hire) − 2 × 40 000/21 (two unpaid days)
// = 20 952,38 − 3 809,52
// = 17 142,86
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
lineItems: [
{
itemType: 'unpaid_leave',
amount: -3809.52, // = 2 × Math.round((40000/21) * 100) / 100
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
},
],
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBeCloseTo(17142.86, 2)
})
})
describe('prorateBaseSalaryForPeriod', () => {
it('returns 1 when all dates are missing', () => {
expect(prorateBaseSalaryForPeriod(undefined, undefined, undefined, undefined)).toBe(1)
})
it('returns 1 when employment fully covers the period', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', null, '2026-05-01', '2026-05-31'),
).toBe(1)
})
it('returns 11/21 for May 2026 mid-month hire on the 15th', () => {
const ratio = prorateBaseSalaryForPeriod(
'2026-05-15',
null,
'2026-05-01',
'2026-05-31',
)
expect(ratio).toBeCloseTo(11 / 21, 6)
})
it('returns 0 when employment ends before the period starts', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', '2026-04-30', '2026-05-01', '2026-05-31'),
).toBe(0)
})
it('returns 0 when employment starts after the period ends', () => {
expect(
prorateBaseSalaryForPeriod('2026-06-01', null, '2026-05-01', '2026-05-31'),
).toBe(0)
})
})
// ============================================================
// Hardening — realistic API flow & cross-rule invariants
// ============================================================
@@ -237,11 +237,34 @@ describe('deriveAbsenceLineItems — parental', () => {
})
})
describe('deriveAbsenceLineItems — unpaid_leave', () => {
it('emits unpaid_leave line item with a per-day daily-rate deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
monthlySalary: 42000, // dailyRate = 42 000 / 21 = 2 000
periodDays: days([
['2026-04-10', 'unpaid_leave'],
['2026-04-13', 'unpaid_leave'],
]),
}),
)
const unpaid = result.lineItems.find(li => li.item_type === 'unpaid_leave')
expect(unpaid).toBeDefined()
expect(unpaid!.quantity).toBe(2)
expect(unpaid!.amount).toBe(-4000)
// false — engine's Step 3 absence sum already subtracts unpaid_leave;
// setting the flag would double-count in Step 4 totalGrossDeductions.
expect(unpaid!.is_gross_deduction).toBe(false)
expect(unpaid!.is_vacation_basis).toBe(false)
expect(result.aggregated.unpaidLeaveDays).toBe(2)
})
})
describe('deriveAbsenceLineItems — empty', () => {
it('returns empty result for no absence', () => {
const result = deriveAbsenceLineItems(baseInput())
expect(result.lineItems).toEqual([])
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0 })
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0, unpaidLeaveDays: 0 })
expect(result.flagFkReporting).toBe(false)
expect(result.flagLakarintyg).toBe(false)
})
+1
View File
@@ -37,6 +37,7 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
sick_day15_plus: '7281',
vab: '7210',
parental_leave: '7210',
unpaid_leave: '7210',
vacation: '7285',
semesterersattning: '7285',
// Travel
+123 -8
View File
@@ -42,6 +42,17 @@ export interface SalaryCalculationInput {
/** Line items */
lineItems: CalculationLineItem[]
/**
* Pay period bounds (YYYY-MM-DD). Together with employmentStart/employmentEnd
* they drive partial-month proration: an employee hired mid-period or
* terminated mid-period receives only the workday-fraction of base salary.
* When omitted, proration is skipped (ratio = 1).
*/
periodStart?: string
periodEnd?: string
employmentStart?: string
employmentEnd?: string | null
}
export interface CalculationLineItem {
@@ -134,6 +145,80 @@ function fmtKr(amount: number): string {
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
}
// ============================================================
// Partial-month proration
// ============================================================
const DAY_MS = 24 * 60 * 60 * 1000
function parseIsoDateUtc(s: string): Date {
return new Date(`${s}T00:00:00Z`)
}
function maxDate(a: string, b: string): string {
return a >= b ? a : b
}
function minDate(a: string, b: string): string {
return a <= b ? a : b
}
/**
* Count Mon–Fri days inclusive between start and end (YYYY-MM-DD). Returns 0
* when start > end. Swedish bank holidays are NOT excluded — the engine uses
* the same 21-workday convention used elsewhere (monthlySalary / 21), so a
* variable workday count that excluded holidays would diverge from the
* baseline daily rate convention.
*/
function countWorkdaysInclusive(start: string, end: string): number {
if (start > end) return 0
const startMs = parseIsoDateUtc(start).getTime()
const endMs = parseIsoDateUtc(end).getTime()
const totalDays = Math.round((endMs - startMs) / DAY_MS) + 1
let workdays = 0
for (let i = 0; i < totalDays; i++) {
const d = new Date(startMs + i * DAY_MS)
const dow = d.getUTCDay() // 0 = Sun, 6 = Sat
if (dow >= 1 && dow <= 5) workdays += 1
}
return workdays
}
/**
* Fraction of the pay period the employee was actually employed, measured in
* Mon–Fri workdays. Returns 1 when the employee was employed for the full
* period (or when employment dates / period bounds are missing). Returns 0
* when the employee was not employed at all during the period.
*
* This is the standard Swedish payroll convention for partial-month proration:
* an employee hired 2026-05-15 gets workdays-in-(May 15–31) / workdays-in-May.
* Hourly employees are not prorated here — they are paid for actually-worked
* hours, so the calling code passes salaryType='monthly' to gate this.
*/
export function prorateBaseSalaryForPeriod(
employmentStart: string | undefined,
employmentEnd: string | null | undefined,
periodStart: string | undefined,
periodEnd: string | undefined,
): number {
if (!periodStart || !periodEnd) return 1
if (!employmentStart) return 1
const effectiveStart = maxDate(employmentStart, periodStart)
const effectiveEnd = employmentEnd ? minDate(employmentEnd, periodEnd) : periodEnd
if (effectiveStart > effectiveEnd) return 0
// Fast path: employment fully covers the period.
if (employmentStart <= periodStart && (!employmentEnd || employmentEnd >= periodEnd)) {
return 1
}
const overlap = countWorkdaysInclusive(effectiveStart, effectiveEnd)
const total = countWorkdaysInclusive(periodStart, periodEnd)
if (total === 0) return 1
const ratio = overlap / total
if (ratio < 0) return 0
if (ratio > 1) return 1
return ratio
}
// ============================================================
// Main calculation
// ============================================================
@@ -162,13 +247,43 @@ export function calculateSalary(
// ─── Step 1: Base salary ───
let baseSalary: number
if (input.salaryType === 'monthly') {
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
const degreeAdjusted = r(input.monthlySalary * (input.employmentDegree / 100))
const prorationRatio = prorateBaseSalaryForPeriod(
input.employmentStart,
input.employmentEnd,
input.periodStart,
input.periodEnd,
)
if (prorationRatio < 1 && input.periodStart && input.periodEnd) {
baseSalary = r(degreeAdjusted * prorationRatio)
const overlapStart = input.employmentStart && input.employmentStart > input.periodStart
? input.employmentStart
: input.periodStart
const overlapEnd = input.employmentEnd && input.employmentEnd < input.periodEnd
? input.employmentEnd
: input.periodEnd
steps.push({
label: 'Grundlön (proportionerad anställningsperiod)',
formula: 'månadslön × (sysselsättningsgrad / 100) × (arbetsdagar i anställning / arbetsdagar i period)',
input: {
monthly_salary: input.monthlySalary,
employment_degree: input.employmentDegree,
degree_adjusted: degreeAdjusted,
overlap_start: overlapStart,
overlap_end: overlapEnd,
proration_ratio: Math.round(prorationRatio * 10000) / 10000,
},
output: baseSalary,
})
} else {
baseSalary = degreeAdjusted
steps.push({
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
}
} else {
const hours = input.hoursWorked || 0
const rate = input.hourlyRate || 0
@@ -205,7 +320,7 @@ export function calculateSalary(
// ─── Step 3: Subtract absence deductions ───
const absenceItems = input.lineItems.filter(
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'vacation'].includes(li.itemType)
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'unpaid_leave', 'vacation'].includes(li.itemType)
)
const totalAbsence = r(absenceItems.reduce((sum, li) => sum + li.amount, 0))
if (totalAbsence !== 0) {
+16 -2
View File
@@ -49,7 +49,21 @@ export async function createSalaryRunWithEmployees(
.eq('company_id', companyId)
.eq('is_active', true)
for (const emp of employees || []) {
// Pay period bounds (inclusive) — used to skip employees whose employment
// does not overlap the run. employment_start is NOT NULL on employees;
// employment_end is nullable for ongoing employments.
const periodStart = `${params.periodYear}-${String(params.periodMonth).padStart(2, '0')}-01`
const periodEnd = new Date(Date.UTC(params.periodYear, params.periodMonth, 0))
.toISOString()
.slice(0, 10)
const eligibleEmployees = (employees || []).filter((emp) => {
if (emp.employment_start && emp.employment_start > periodEnd) return false
if (emp.employment_end && emp.employment_end < periodStart) return false
return true
})
for (const emp of eligibleEmployees) {
const baseAmount =
emp.salary_type === 'monthly'
? Math.round((emp.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100
@@ -91,7 +105,7 @@ export async function createSalaryRunWithEmployees(
}
}
return { run: run as Record<string, unknown>, employeeCount: (employees || []).length }
return { run: run as Record<string, unknown>, employeeCount: eligibleEmployees.length }
} catch (err) {
// Compensating delete — never leave a half-populated run. Cascade removes
// any salary_run_employees / salary_line_items already inserted.
+29 -1
View File
@@ -35,6 +35,7 @@ export type AbsenceType =
| 'pregnancy'
| 'care_relative'
| 'study'
| 'unpaid_leave'
| 'other_leave'
export interface AbsenceDay {
@@ -44,7 +45,7 @@ export interface AbsenceDay {
}
export interface DerivedLineItem {
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave'
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave' | 'unpaid_leave'
description: string
quantity: number
amount: number
@@ -58,6 +59,7 @@ export interface AggregatedCounts {
sickDays: number
vabDays: number
parentalDays: number
unpaidLeaveDays: number
}
export interface DeriveResult {
@@ -173,6 +175,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
.map(d => d.absence_date)
const vabDays = periodDays.filter(d => d.absence_type === 'vab')
const parentalDays = periodDays.filter(d => d.absence_type === 'parental')
const unpaidLeaveDays = periodDays.filter(d => d.absence_type === 'unpaid_leave')
let flagFkReporting = false
let flagLakarintyg = false
@@ -334,12 +337,37 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
})
}
// ── Unpaid leave (tjänstledighet utan lön) ─────────────────────────────
// Each day reduces gross pay by one daily rate (monthlySalary / 21 — same
// convention used elsewhere in the engine). Not semestergrundande per SemL
// 17 § (only paid leave types accrue vacation).
//
// is_gross_deduction is deliberately false: the engine's Step 3 absence
// sum already subtracts items whose item_type is 'unpaid_leave', so setting
// the flag would double-count the amount in Step 4's gross_deduction sum.
const unpaidLeaveCount = unpaidLeaveDays.length
if (unpaidLeaveCount > 0) {
const dailyRate = r(monthlySalary / 21)
const deduction = r(dailyRate * unpaidLeaveCount)
lineItems.push({
item_type: 'unpaid_leave',
description: `Tjänstledighet utan lön (${unpaidLeaveCount} dagar)`,
quantity: unpaidLeaveCount,
amount: -deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: false,
})
}
return {
lineItems,
aggregated: {
sickDays: periodSickDates.length,
vabDays: vabCount,
parentalDays: parentalCount,
unpaidLeaveDays: unpaidLeaveCount,
},
flagFkReporting,
flagLakarintyg,
+5
View File
@@ -43,6 +43,7 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'sick_day15_plus',
'vab',
'parental_leave',
'unpaid_leave',
]
/**
@@ -598,6 +599,10 @@ export async function runSalaryCalculation(
vaxaStodStart: emp.vaxa_stod_start,
vaxaStodEnd: emp.vaxa_stod_end,
lineItems,
periodStart,
periodEnd,
employmentStart: emp.employment_start,
employmentEnd: emp.employment_end,
},
config,
taxRates.map((r) => ({
+84 -7
View File
@@ -177,6 +177,83 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2b. CSV row dedupes against uncategorized enable_banking row when
// date+amount+description prefix match (Lunar CSV vs Lunar PSD2 case).
// -----------------------------------------------------------------------
it('dedupes CSV row against unbooked enable_banking row with matching description', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'ICA Maxi Solna',
external_id: 'lunar_csvhash123',
import_source: 'csv_lunar',
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query — one PSD2 row with matching content
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, description: 'ICA Maxi Solna' }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — external_id differs, so no match
enqueue({ data: [], error: null })
// No insert expected — row should be deduplicated at content layer
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2c. No false positive: same date+amount but different description does
// NOT trigger content dedup — guards against the historical concern
// about unrelated transfers colliding on (date, amount) alone.
// -----------------------------------------------------------------------
it('does not dedupe when date+amount match but description differs', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'Coop Stockholm',
external_id: 'lunar_csvhash456',
import_source: 'csv_lunar',
})
const inserted = makeTransaction({
id: 'tx-no-collision',
external_id: raw.external_id,
amount: -250.0,
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query — a PSD2 row with same date/amount but DIFFERENT description
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, description: 'ICA Maxi Solna' }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — no match
enqueue({ data: [], error: null })
// Insert succeeds — the new row is not a duplicate
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
expect(result.transaction_ids).toEqual(['tx-no-collision'])
})
// -----------------------------------------------------------------------
// 3. Counts errors when insert fails
// -----------------------------------------------------------------------
@@ -657,7 +734,7 @@ describe('ingestTransactions', () => {
// -----------------------------------------------------------------------
// Content-based dedup: cross-source duplicate detection
// -----------------------------------------------------------------------
it('skips transactions that match already-booked ones by date+amount', async () => {
it('skips transactions that match already-booked ones by date+amount+description', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'psd2_conn123_tx456',
@@ -665,9 +742,9 @@ describe('ingestTransactions', () => {
amount: -250,
})
// Booked transaction map returns a booked tx with same date+amount
// Booked transaction map returns a booked tx with same date+amount+description
enqueue({
data: [{ date: '2024-06-15', amount: -250 }],
data: [{ date: '2024-06-15', amount: -250, description: raw.description }],
error: null,
})
// Unbooked bank-synced transaction map query
@@ -694,7 +771,7 @@ describe('ingestTransactions', () => {
// Booked transaction map: same date but different amount
enqueue({
data: [{ date: '2024-06-15', amount: -250 }],
data: [{ date: '2024-06-15', amount: -250, description: raw.description }],
error: null,
})
// Unbooked bank-synced transaction map query
@@ -724,12 +801,12 @@ describe('ingestTransactions', () => {
const inserted = makeTransaction({ id: 'tx-new', amount: -100 })
// Booked map: 2 existing booked transactions with same date+amount
// Booked map: 2 existing booked transactions with same date+amount+description
// So 2 of the 3 incoming should be skipped, 1 should be imported
enqueue({
data: [
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-15', amount: -100, description: raw1.description },
{ date: '2024-06-15', amount: -100, description: raw1.description },
],
error: null,
})
+44 -27
View File
@@ -16,15 +16,27 @@ interface ExistingTransactionMaps {
/** Booked transactions (any source) — consumed by any incoming raw transaction. */
booked: Map<string, number>
/**
* Unbooked enable_banking transactions — only consumed when the incoming raw
* transaction is also from enable_banking. This catches reconnect duplicates
* (external_id changed but the same tx already exists from a prior sync)
* without producing false positives for unrelated CSV imports that happen to
* share a date/amount with a pending bank-synced row.
* Unbooked enable_banking transactions — consumed by any incoming raw
* transaction regardless of source. Catches two cases: PSD2 reconnect
* duplicates (external_id regenerated, same tx already pending) AND
* CSV imports overlapping an active PSD2 sync (same Lunar/etc tx arriving
* twice, once via PSD2 and once via file upload).
*/
unbookedEnableBanking: Map<string, number>
}
/**
* Stable content-dedup key. Includes a normalized description prefix so the
* two-tuple (date, amount) doesn't false-positive across unrelated transfers
* that happen to share a date and amount. Lunar's CSV "Text" column and
* PSD2's `description || counterparty_name` (see enable-banking/lib/sync.ts)
* agree well enough in practice for the same underlying transaction.
*/
function contentDedupKey(date: string, amount: number | string, description: string | null | undefined): string {
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
return `${date}|${amount}|${descPrefix}`
}
async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
@@ -41,7 +53,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount')
.select('date, amount, description')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -49,7 +61,7 @@ async function buildExistingTransactionMaps(
if (bookedRows) {
for (const tx of bookedRows) {
const key = `${tx.date}|${tx.amount}`
const key = contentDedupKey(tx.date, tx.amount, tx.description)
booked.set(key, (booked.get(key) || 0) + 1)
}
}
@@ -60,7 +72,7 @@ async function buildExistingTransactionMaps(
try {
const { data: unbookedBank } = await supabase
.from('transactions')
.select('date, amount')
.select('date, amount, description')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
@@ -69,7 +81,7 @@ async function buildExistingTransactionMaps(
if (unbookedBank) {
for (const tx of unbookedBank) {
const key = `${tx.date}|${tx.amount}`
const key = contentDedupKey(tx.date, tx.amount, tx.description)
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
@@ -85,8 +97,12 @@ async function buildExistingTransactionMaps(
*
* Handles:
* 1. Deduplication via external_id
* 1b. Content-based dedup via date+amount against already-booked transactions
* (catches cross-source duplicates, e.g. CSV import then PSD2 sync)
* 1b. Content-based dedup (date+amount+description prefix) against already-booked
* transactions — catches cross-source duplicates, e.g. PSD2 row gets booked
* before the user later re-imports the same period via CSV.
* 1c. Content-based dedup against unbooked enable_banking rows — catches PSD2
* reconnect duplicates AND CSV imports overlapping an active PSD2 sync (the
* description-prefix component makes this safe to apply across sources).
* 2. Insert into transactions table
* 3. OCR/reference-based invoice matching (highest confidence)
* 4. Amount+customer fallback invoice matching
@@ -112,10 +128,11 @@ export async function ingestTransactions(
transaction_ids: [],
}
// Pre-fetch existing transactions for content-based dedup (date+amount).
// Booked rows (any source) catch cross-source duplicates; unbooked
// enable_banking rows catch reconnect duplicates but are only consumed
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
// Pre-fetch existing transactions for content-based dedup
// (date+amount+description prefix). Booked rows catch cross-source
// duplicates after they've been booked; unbooked enable_banking rows
// catch the more common case where a PSD2 row is still pending in the
// inbox when the user re-imports the same period via CSV.
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
// When rawInsertOnly is set (viewer imports), skip pre-fetching supplier
@@ -206,8 +223,8 @@ export async function ingestTransactions(
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date and amount (cross-source duplicate).
const contentKey = `${raw.date}|${raw.amount}`
// exists with the same date, amount, and description prefix.
const contentKey = contentDedupKey(raw.date, raw.amount, raw.description)
const bookedCount = existingMaps.booked.get(contentKey) || 0
if (bookedCount > 0) {
existingMaps.booked.set(contentKey, bookedCount - 1)
@@ -215,16 +232,16 @@ export async function ingestTransactions(
continue
}
// 1c. Reconnect dedup: only enable_banking rows consume slots from the
// unbooked-enable_banking map, so a CSV row with the same date/amount as
// a pending bank-synced row is not incorrectly dropped as a duplicate.
if (raw.import_source === 'enable_banking') {
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
if (unbookedEbCount > 0) {
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
result.duplicates++
continue
}
// 1c. Overlap dedup: skip if an unbooked enable_banking row already
// exists with the same (date, amount, description prefix). Applies to
// any incoming source — PSD2 reconnects, CSV imports over an active
// PSD2 sync, etc. Description prefix prevents unrelated transfers from
// colliding on (date, amount) alone.
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
if (unbookedEbCount > 0) {
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
result.duplicates++
continue
}
// 2. Insert new transaction (with SEK conversion for foreign currencies)