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:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user