Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -1410,6 +1410,24 @@ describe('UpdateSettingsSchema', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reminder day thresholds', () => {
|
||||
it('accepts integer thresholds from 1 through 365', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
reminder_days_level_1: 7,
|
||||
reminder_days_level_2: 21,
|
||||
reminder_days_level_3: 365,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it.each([0, 366, 1.5])('rejects invalid threshold %s', (days) => {
|
||||
const result = UpdateSettingsSchema.safeParse({ reminder_days_level_1: days })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invoice_email_texts', () => {
|
||||
it('accepts a valid nested partial', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
|
||||
+32
-1
@@ -681,9 +681,34 @@ export const CreateCustomerSchema = z.object({
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
}).superRefine((customer, ctx) => {
|
||||
if (customer.personal_number && customer.customer_type !== 'individual') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['personal_number'],
|
||||
message: 'Personal number is only allowed for individual customers',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateCustomerSchema = CreateCustomerSchema.partial()
|
||||
export const UpdateCustomerSchema = z.object({
|
||||
name: z.string().min(1, 'Customer name is required').optional(),
|
||||
customer_type: CustomerTypeSchema.optional(),
|
||||
customer_number: z.string().trim().max(32).nullable().optional(),
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
personal_number: z.string().regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number').nullable().optional(),
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Supplier schemas
|
||||
@@ -786,6 +811,9 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
|
||||
export const CreateSupplierInvoiceSchema = z.object({
|
||||
supplier_id: uuid,
|
||||
// Optional invoice PDF/image already stored in the WORM document archive.
|
||||
// The route verifies company ownership and that the document is unused.
|
||||
document_id: uuid.optional(),
|
||||
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
|
||||
invoice_date: isoDate,
|
||||
due_date: isoDate,
|
||||
@@ -1493,6 +1521,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_footer_text: z.string().max(500).nullable().optional(),
|
||||
// Automation
|
||||
send_invoice_reminders: z.boolean().optional(),
|
||||
reminder_days_level_1: z.number().int().min(1).max(365).optional(),
|
||||
reminder_days_level_2: z.number().int().min(1).max(365).optional(),
|
||||
reminder_days_level_3: z.number().int().min(1).max(365).optional(),
|
||||
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
|
||||
reminder_fee_enabled: z.boolean().optional(),
|
||||
reminder_fee_amount: z
|
||||
|
||||
@@ -158,6 +158,8 @@ export const SCOPE_GROUPS = [
|
||||
|
||||
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
gnubok_list_companies: 'companies:read',
|
||||
// Transactions
|
||||
gnubok_list_uncategorized_transactions: 'transactions:read',
|
||||
gnubok_list_transactions_without_documents: 'transactions:read',
|
||||
|
||||
@@ -453,6 +453,45 @@ describe('createCreditNoteJournalEntry: per-line VAT', () => {
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rot' as const, 3000, 9500],
|
||||
['rut' as const, 5000, 7500],
|
||||
])('reverses the %s receivable split across 1510 and 1513', async (deductionType, taxCredit, customerCredit) => {
|
||||
const creditNote = makeInvoice({
|
||||
invoice_number: 'KR-1002',
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({
|
||||
quantity: -1,
|
||||
unit_price: 10000,
|
||||
line_total: -10000,
|
||||
vat_rate: 25,
|
||||
vat_amount: -2500,
|
||||
deduction_type: deductionType,
|
||||
dimensions: { '6': 'P001' },
|
||||
}),
|
||||
],
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
|
||||
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
|
||||
expect(input.lines.find((line) => line.account_number === '1510')?.credit_amount)
|
||||
.toBe(customerCredit)
|
||||
const line1513 = input.lines.find((line) => line.account_number === '1513')
|
||||
expect(line1513?.credit_amount).toBe(taxCredit)
|
||||
expect(line1513?.debit_amount).toBe(0)
|
||||
expect(line1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
|
||||
|
||||
const totalDebit = input.lines.reduce((sum, line) => sum + line.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, line) => sum + line.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoiceCashEntry: per-line VAT', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeInvoiceInput(overrides: Partial<{
|
||||
currency: string
|
||||
exchange_rate: number | null
|
||||
vat_treatment: VatTreatment
|
||||
credited_invoice_id: string | null
|
||||
items: InvoiceItem[]
|
||||
default_dimensions: Record<string, string> | null
|
||||
}> = {}) {
|
||||
@@ -77,6 +78,71 @@ describe('proposeSendLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('credit note uses positive amounts on the reversed sides', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
invoice_number: 'KR-2025-001',
|
||||
credited_invoice_id: 'invoice-1',
|
||||
total: -12500,
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
items: [
|
||||
makeItem({ quantity: -1, line_total: -10000, vat_amount: -2500 }),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: '',
|
||||
credit_amount: '12500',
|
||||
line_description: 'Kreditfaktura KR-2025-001',
|
||||
},
|
||||
{
|
||||
account_number: '3001',
|
||||
debit_amount: '10000',
|
||||
credit_amount: '',
|
||||
line_description: 'Kreditfaktura KR-2025-001',
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit_amount: '2500',
|
||||
credit_amount: '',
|
||||
line_description: 'Moms kreditfaktura 25%',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('credit-note preview reverses the ROT receivable split', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
invoice_number: 'KR-2025-002',
|
||||
credited_invoice_id: 'invoice-2',
|
||||
total: -12500,
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
items: [
|
||||
makeItem({
|
||||
quantity: -1,
|
||||
line_total: -10000,
|
||||
vat_amount: -2500,
|
||||
deduction_type: 'rot',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines.find((line) => line.account_number === '1510')?.credit_amount).toBe('9500')
|
||||
expect(lines.find((line) => line.account_number === '1513')?.credit_amount).toBe('3000')
|
||||
expect(lines.reduce((sum, line) => sum + (parseFloat(line.debit_amount) || 0), 0))
|
||||
.toBe(12500)
|
||||
expect(lines.reduce((sum, line) => sum + (parseFloat(line.credit_amount) || 0), 0))
|
||||
.toBe(12500)
|
||||
})
|
||||
|
||||
describe('dimensions propagation (PR7)', () => {
|
||||
const bag = { '1': 'KS01', '6': 'P001' }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { generateSalesVatLines } from './vat-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
@@ -258,6 +259,7 @@ function generateRotRutLines(
|
||||
currency?: string | null,
|
||||
exchangeRate?: number | null,
|
||||
defaultDimensions?: LineDimensions,
|
||||
side: 'debit' | 'credit' = 'debit',
|
||||
): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const isForeign = currency != null && currency !== 'SEK'
|
||||
@@ -276,8 +278,8 @@ function generateRotRutLines(
|
||||
if (!item.deduction_type) continue
|
||||
// Recompute server-side to defend against tampered client values.
|
||||
const amount = computeDeduction({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price,
|
||||
quantity: side === 'credit' ? Math.abs(item.quantity) : item.quantity,
|
||||
deduction_type: item.deduction_type,
|
||||
})
|
||||
if (amount <= 0) continue
|
||||
@@ -287,9 +289,11 @@ function generateRotRutLines(
|
||||
const kind = item.deduction_type === 'rot' ? 'ROT' : 'RUT'
|
||||
lines.push({
|
||||
account_number: '1513',
|
||||
debit_amount: amountSek,
|
||||
credit_amount: 0,
|
||||
line_description: `${kind}-avdrag faktura ${invoiceTagText}`,
|
||||
debit_amount: side === 'debit' ? amountSek : 0,
|
||||
credit_amount: side === 'credit' ? amountSek : 0,
|
||||
line_description: side === 'credit'
|
||||
? `${kind}-avdrag kreditfaktura ${invoiceTagText}`
|
||||
: `${kind}-avdrag faktura ${invoiceTagText}`,
|
||||
// Per-item line: carries the item's merged bag like its revenue line.
|
||||
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
|
||||
})
|
||||
@@ -648,15 +652,30 @@ export async function createCreditNoteJournalEntry(
|
||||
|
||||
lines.push(...debitLines)
|
||||
|
||||
// Credit: Kundfordringar, balance guarantee: credit = sum of all debit lines
|
||||
// ROT/RUT reverses the exact receivable split used by the original invoice:
|
||||
// 1510 for the customer portion and 1513 for the Skatteverket portion.
|
||||
const rotRut = creditNote.items && creditNote.items.length > 0
|
||||
? generateRotRutLines(
|
||||
creditNote.items,
|
||||
tag,
|
||||
creditNote.currency,
|
||||
creditNote.exchange_rate,
|
||||
defaultDimensions,
|
||||
'credit',
|
||||
)
|
||||
: { lines: [], totalSek: 0 }
|
||||
|
||||
// Credit: Kundfordringar, balance guarantee: 1510 + 1513 equals debits.
|
||||
const totalDebits = debitLines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const customerReceivable = roundOre(totalDebits - rotRut.totalSek)
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||||
credit_amount: customerReceivable,
|
||||
line_description: `Kreditfaktura ${tag}`,
|
||||
dimensions: defaultDimensions,
|
||||
})
|
||||
lines.push(...rotRut.lines)
|
||||
|
||||
const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id)
|
||||
const input: CreateJournalEntryInput = {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { resolveSekAmount } from './currency-utils'
|
||||
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
|
||||
|
||||
@@ -22,6 +24,7 @@ export interface ProposeSendLinesInput {
|
||||
currency: string
|
||||
exchange_rate?: number | null
|
||||
vat_treatment: VatTreatment
|
||||
credited_invoice_id?: string | null
|
||||
items?: InvoiceItem[]
|
||||
/**
|
||||
* Dimensions PR7: the invoice's default bag, stamped on every proposed
|
||||
@@ -47,13 +50,51 @@ function toFormAmount(n: number): string {
|
||||
*/
|
||||
export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
|
||||
const { invoice, entityType } = input
|
||||
const proposedLines = invoice.credited_invoice_id
|
||||
? buildCreditNoteLines(invoice, entityType)
|
||||
: buildSendLines(invoice, entityType)
|
||||
const lines: FormLine[] = stampProposalDimensions(
|
||||
buildSendLines(invoice, entityType),
|
||||
proposedLines,
|
||||
invoice.default_dimensions
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
function absoluteOptional(amount: number | null | undefined): number | null | undefined {
|
||||
return amount == null ? amount : Math.abs(amount)
|
||||
}
|
||||
|
||||
function buildCreditNoteLines(
|
||||
invoice: ProposeSendLinesInput['invoice'],
|
||||
entityType: EntityType,
|
||||
): FormLine[] {
|
||||
const absoluteInvoice: ProposeSendLinesInput['invoice'] = {
|
||||
...invoice,
|
||||
total: Math.abs(invoice.total),
|
||||
total_sek: absoluteOptional(invoice.total_sek),
|
||||
subtotal: Math.abs(invoice.subtotal),
|
||||
subtotal_sek: absoluteOptional(invoice.subtotal_sek),
|
||||
vat_amount: Math.abs(invoice.vat_amount),
|
||||
vat_amount_sek: absoluteOptional(invoice.vat_amount_sek),
|
||||
items: invoice.items?.map((item) => ({
|
||||
...item,
|
||||
quantity: Math.abs(item.quantity),
|
||||
line_total: Math.abs(item.line_total),
|
||||
vat_amount: item.vat_amount == null ? item.vat_amount : Math.abs(item.vat_amount),
|
||||
})),
|
||||
}
|
||||
|
||||
return buildSendLines(absoluteInvoice, entityType).map((line) => ({
|
||||
...line,
|
||||
debit_amount: line.credit_amount,
|
||||
credit_amount: line.debit_amount,
|
||||
line_description: line.line_description
|
||||
.replace('Försäljning faktura', 'Kreditfaktura')
|
||||
.replace('Utgående moms faktura', 'Moms kreditfaktura')
|
||||
.replace('Utgående moms', 'Moms kreditfaktura'),
|
||||
}))
|
||||
}
|
||||
|
||||
function stampProposalDimensions(
|
||||
lines: FormLine[],
|
||||
bag?: Record<string, string> | null
|
||||
@@ -164,19 +205,41 @@ function buildSendLines(
|
||||
}
|
||||
}
|
||||
|
||||
// Debit: 1510 Kundfordringar, balance guarantee
|
||||
const deductionLines: FormLine[] = []
|
||||
let deductionTotal = 0
|
||||
for (const item of invoice.items ?? []) {
|
||||
if (!item.deduction_type) continue
|
||||
const deduction = computeDeduction({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
deduction_type: item.deduction_type,
|
||||
})
|
||||
const amountSek = roundOre(toSek(deduction))
|
||||
if (amountSek <= 0) continue
|
||||
deductionTotal = roundOre(deductionTotal + amountSek)
|
||||
deductionLines.push({
|
||||
account_number: '1513',
|
||||
debit_amount: toFormAmount(amountSek),
|
||||
credit_amount: '',
|
||||
line_description: `${item.deduction_type === 'rot' ? 'ROT' : 'RUT'}-avdrag faktura ${invoice.invoice_number ?? ''}`.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
// Debit: 1510 customer portion plus 1513 Skatteverket portion.
|
||||
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const debitAmount = isForeign
|
||||
? Math.round(totalCredits * 100) / 100
|
||||
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
const customerReceivable = roundOre(debitAmount - deductionTotal)
|
||||
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: toFormAmount(debitAmount),
|
||||
debit_amount: toFormAmount(customerReceivable),
|
||||
credit_amount: '',
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
lines.push(...deductionLines)
|
||||
lines.push(...creditLines)
|
||||
|
||||
return lines
|
||||
|
||||
+21
-19
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -154,26 +155,27 @@ export async function getUserCompanies(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
.from('company_members')
|
||||
.select(`
|
||||
company_id,
|
||||
role,
|
||||
joined_at,
|
||||
companies:company_id (
|
||||
return fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
org_number,
|
||||
entity_type,
|
||||
archived_at,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('joined_at', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
company_id,
|
||||
role,
|
||||
joined_at,
|
||||
companies:company_id (
|
||||
id,
|
||||
name,
|
||||
org_number,
|
||||
entity_type,
|
||||
archived_at,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,23 @@ describe('getJournalEntryUnderlagReferences', () => {
|
||||
expect(refs).toEqual([{ type: 'supplier_invoice', id: 'si-1', number: 'LF-001' }])
|
||||
})
|
||||
|
||||
it('surfaces the retained PDF through a supplier payment reference', async () => {
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct
|
||||
{ data: [] }, // 2. invoice_payments
|
||||
{ data: [] }, // 4. supplier registration
|
||||
{ data: [{ id: 'si-1', supplier_invoice_number: 'LF-001', document_id: 'doc-1' }] }, // 5. supplier payment
|
||||
{ data: [{ supplier_invoice_id: 'si-1' }] }, // 6. supplier_invoice_payments
|
||||
])
|
||||
|
||||
expect(refs).toEqual([{
|
||||
type: 'supplier_invoice',
|
||||
id: 'si-1',
|
||||
number: 'LF-001',
|
||||
document_id: 'doc-1',
|
||||
}])
|
||||
})
|
||||
|
||||
it('returns nothing when no invoice is linked (warning legitimately stays)', async () => {
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
/**
|
||||
* A followable reference from a verifikation back to its underlag: the customer
|
||||
@@ -20,6 +21,8 @@ export interface UnderlagReference {
|
||||
id: string
|
||||
/** invoice_number / supplier_invoice_number: the UI builds the label from this. */
|
||||
number: string
|
||||
/** Retained source document owned by a referenced supplier invoice, if any. */
|
||||
document_id?: string
|
||||
}
|
||||
|
||||
interface InvoiceRow {
|
||||
@@ -30,6 +33,7 @@ interface InvoiceRow {
|
||||
interface SupplierInvoiceRow {
|
||||
id: string
|
||||
supplier_invoice_number: string
|
||||
document_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,21 +56,21 @@ export async function getJournalEntryUnderlagReferences(
|
||||
const invoices = new Map<string, string>()
|
||||
|
||||
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
|
||||
const { data: directInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
const directInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||
supabase.from('invoices').select('id, invoice_number')
|
||||
.eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const inv of (directInvoices ?? []) as InvoiceRow[]) {
|
||||
invoices.set(inv.id, inv.invoice_number)
|
||||
}
|
||||
|
||||
// Payment rows (kontantmetod inbetalning, partial payments) → invoice_payments.
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
const paymentRows = await fetchAllRows<{ id: string; invoice_id: string | null }>(({ from, to }) =>
|
||||
supabase.from('invoice_payments').select('id, invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
const paymentInvoiceIds = new Set<string>()
|
||||
for (const row of (paymentRows ?? []) as { invoice_id: string | null }[]) {
|
||||
@@ -74,11 +78,11 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
|
||||
if (paymentInvoiceIds.size > 0) {
|
||||
const { data: paidInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', Array.from(paymentInvoiceIds))
|
||||
const paidInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||
supabase.from('invoices').select('id, invoice_number')
|
||||
.eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const inv of (paidInvoices ?? []) as InvoiceRow[]) {
|
||||
invoices.set(inv.id, inv.invoice_number)
|
||||
@@ -86,35 +90,41 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
|
||||
// --- Supplier invoices ---------------------------------------------------
|
||||
const supplierInvoices = new Map<string, string>()
|
||||
const supplierInvoices = new Map<string, { number: string; documentId?: string }>()
|
||||
|
||||
// Registration booking (accrual) on the invoice itself.
|
||||
const { data: registrationLinks } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('registration_journal_entry_id', journalEntryId)
|
||||
const registrationLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
|
||||
.eq('company_id', companyId).eq('registration_journal_entry_id', journalEntryId)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
supplierInvoices.set(si.id, {
|
||||
number: si.supplier_invoice_number,
|
||||
...(si.document_id ? { documentId: si.document_id } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Payment booking on the invoice itself.
|
||||
const { data: paymentLinks } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('payment_journal_entry_id', journalEntryId)
|
||||
const paymentLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
|
||||
.eq('company_id', companyId).eq('payment_journal_entry_id', journalEntryId)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
supplierInvoices.set(si.id, {
|
||||
number: si.supplier_invoice_number,
|
||||
...(si.document_id ? { documentId: si.document_id } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Partial-payment rows → supplier_invoice_payments.
|
||||
const { data: supplierPaymentRows } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('supplier_invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
const supplierPaymentRows = await fetchAllRows<{ id: string; supplier_invoice_id: string | null }>(
|
||||
({ from, to }) => supabase.from('supplier_invoice_payments').select('id, supplier_invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
const supplierPaymentIds = new Set<string>()
|
||||
for (const row of (supplierPaymentRows ?? []) as { supplier_invoice_id: string | null }[]) {
|
||||
@@ -124,20 +134,30 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
|
||||
if (supplierPaymentIds.size > 0) {
|
||||
const { data: paidSupplierInvoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', Array.from(supplierPaymentIds))
|
||||
const paidSupplierInvoices = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
||||
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
|
||||
.eq('company_id', companyId).in('id', Array.from(supplierPaymentIds))
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
supplierInvoices.set(si.id, {
|
||||
number: si.supplier_invoice_number,
|
||||
...(si.document_id ? { documentId: si.document_id } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Assemble ------------------------------------------------------------
|
||||
const references: UnderlagReference[] = []
|
||||
for (const [id, number] of invoices) references.push({ type: 'invoice', id, number })
|
||||
for (const [id, number] of supplierInvoices) references.push({ type: 'supplier_invoice', id, number })
|
||||
for (const [id, supplierInvoice] of supplierInvoices) {
|
||||
references.push({
|
||||
type: 'supplier_invoice',
|
||||
id,
|
||||
number: supplierInvoice.number,
|
||||
...(supplierInvoice.documentId ? { document_id: supplierInvoice.documentId } : {}),
|
||||
})
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Display a personal identity number without exposing birth date or full ID.
|
||||
*/
|
||||
export function maskCustomerPersonalNumber(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
const last4 = value.replace(/\D/g, '').slice(-4)
|
||||
return last4.length === 4 ? `********-${last4}` : null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
|
||||
|
||||
export function encryptCustomerPersonalNumber(value: string | null | undefined): string | null {
|
||||
return value ? encryptPersonnummer(value) : null
|
||||
}
|
||||
|
||||
export function maskStoredCustomerPersonalNumber(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
if (/^(\d{6}|\d{8})[-+]?\d{4}$/.test(value)) {
|
||||
return maskCustomerPersonalNumber(value)
|
||||
}
|
||||
return maskCustomerPersonalNumber(decryptPersonnummer(value))
|
||||
}
|
||||
|
||||
export function maskCustomerRow<T extends { personal_number?: string | null }>(row: T): T {
|
||||
return {
|
||||
...row,
|
||||
personal_number: maskStoredCustomerPersonalNumber(row.personal_number),
|
||||
}
|
||||
}
|
||||
@@ -355,10 +355,34 @@ export function generateReminderEmailSubject(data: ReminderEmailData): string {
|
||||
/**
|
||||
* Get the number of days after due date for each reminder level
|
||||
*/
|
||||
export function getReminderDaysConfig(): Record<1 | 2 | 3, number> {
|
||||
return {
|
||||
export type ReminderDaysConfig = Record<1 | 2 | 3, number>
|
||||
|
||||
type ReminderDaysSettings = Partial<
|
||||
Pick<
|
||||
CompanySettings,
|
||||
'reminder_days_level_1' | 'reminder_days_level_2' | 'reminder_days_level_3'
|
||||
>
|
||||
>
|
||||
|
||||
export function getReminderDaysConfig(
|
||||
settings?: ReminderDaysSettings | null,
|
||||
): ReminderDaysConfig {
|
||||
const defaults: ReminderDaysConfig = {
|
||||
1: REMINDER_CONFIG[1].daysAfterDue,
|
||||
2: REMINDER_CONFIG[2].daysAfterDue,
|
||||
3: REMINDER_CONFIG[3].daysAfterDue
|
||||
3: REMINDER_CONFIG[3].daysAfterDue,
|
||||
}
|
||||
|
||||
const configured: ReminderDaysConfig = {
|
||||
1: settings?.reminder_days_level_1 ?? defaults[1],
|
||||
2: settings?.reminder_days_level_2 ?? defaults[2],
|
||||
3: settings?.reminder_days_level_3 ?? defaults[3],
|
||||
}
|
||||
|
||||
const valid =
|
||||
Object.values(configured).every((days) => Number.isInteger(days) && days >= 1 && days <= 365)
|
||||
&& configured[1] < configured[2]
|
||||
&& configured[2] < configured[3]
|
||||
|
||||
return valid ? configured : defaults
|
||||
}
|
||||
|
||||
@@ -428,6 +428,11 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.',
|
||||
message_en: 'Invoice is not in an unpaid state.',
|
||||
},
|
||||
MATCH_INVOICE_CREDIT_NOTE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Kreditfakturor kan inte registreras som betalda.',
|
||||
message_en: 'Credit notes cannot be recorded as paid.',
|
||||
},
|
||||
MATCH_INVOICE_NOT_INVOICE_TYPE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
|
||||
@@ -523,6 +528,11 @@ const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte kopplas.',
|
||||
message_en: 'Invoice is not in an unpaid state.',
|
||||
},
|
||||
LINK_TX_INVOICE_CREDIT_NOTE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Kreditfakturor kan inte registreras som betalda.',
|
||||
message_en: 'Credit notes cannot be recorded as paid.',
|
||||
},
|
||||
LINK_TX_INVOICE_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Fakturan ändrades samtidigt. Försök igen.',
|
||||
@@ -754,6 +764,48 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.',
|
||||
message_en: 'Only sent, paid, or overdue invoices can be credited.',
|
||||
},
|
||||
INVOICE_CREDIT_ISSUE_INCOMPLETE: {
|
||||
httpStatus: 500,
|
||||
message_sv:
|
||||
'Kreditfakturan kunde inte utfärdas färdigt. Ingen e-post skickades. Försök igen.',
|
||||
message_en:
|
||||
'The credit note could not be issued completely. No email was sent. Please try again.',
|
||||
},
|
||||
INVOICE_CREDIT_REPAIR_REQUIRED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Kreditfakturans verifikat skapades, men utfärdandet måste slutföras. Försök igen eller kontakta support.',
|
||||
message_en: 'The credit-note voucher was created, but issuance must be completed. Retry or contact support.',
|
||||
},
|
||||
INVOICE_CREDIT_ALREADY_ISSUED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Kreditfakturan har redan utfärdats.',
|
||||
message_en: 'The credit note has already been issued.',
|
||||
},
|
||||
INVOICE_MARK_SENT_INVALID_STATUS: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Fakturan kan inte markeras som skickad i nuvarande status.',
|
||||
message_en: 'The invoice cannot be marked as sent in its current status.',
|
||||
},
|
||||
INVOICE_MARK_SENT_STATUS_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturans status kunde inte uppdateras.',
|
||||
message_en: 'The invoice status could not be updated.',
|
||||
},
|
||||
INVOICE_MARK_SENT_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Fakturan ändrades av en annan begäran. Ladda om och försök igen.',
|
||||
message_en: 'The invoice was changed by another request. Reload and retry.',
|
||||
},
|
||||
INVOICE_MARK_SENT_BOOK_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte bokföras och ligger kvar som utkast.',
|
||||
message_en: 'The invoice could not be posted and remains a draft.',
|
||||
},
|
||||
INVOICE_MARK_SENT_REPAIR_REQUIRED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Verifikatet skapades, men kopplingen till fakturan måste återställas. Kontakta support.',
|
||||
message_en: 'The voucher was created, but its invoice link must be repaired. Contact support.',
|
||||
},
|
||||
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
|
||||
httpStatus: 503,
|
||||
message_sv:
|
||||
@@ -1639,6 +1691,26 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Artikeln kunde inte uppdateras.',
|
||||
message_en: 'Failed to update article.',
|
||||
},
|
||||
INVOICE_DELETE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte tas bort eller makuleras.',
|
||||
message_en: 'The invoice could not be deleted or cancelled.',
|
||||
},
|
||||
CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Personnummer kan endast sparas för privatkunder.',
|
||||
message_en: 'Personal numbers can only be stored for individual customers.',
|
||||
},
|
||||
ARTICLE_DELETE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Artikeln kunde inte tas bort.',
|
||||
message_en: 'Failed to delete article.',
|
||||
},
|
||||
ARTICLE_IN_USE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Artikeln har använts på en faktura och kan därför inte tas bort.',
|
||||
message_en: 'The article has been used on an invoice and cannot be deleted.',
|
||||
},
|
||||
ARTICLE_REVENUE_ACCOUNT_INVALID: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).',
|
||||
|
||||
@@ -26,6 +26,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
|
||||
'period.locked',
|
||||
'period.year_closed',
|
||||
'customer.created',
|
||||
'article.deleted',
|
||||
'supplier.created',
|
||||
'receipt.matched',
|
||||
'receipt.confirmed',
|
||||
@@ -98,6 +99,9 @@ function extractEntityId(payload: Record<string, unknown>): string | null {
|
||||
if (typeof payload.invoiceId === 'string') {
|
||||
return payload.invoiceId
|
||||
}
|
||||
if (typeof payload.articleId === 'string') {
|
||||
return payload.articleId
|
||||
}
|
||||
|
||||
// For journal_entry.corrected: use the corrected entry's ID
|
||||
if ('corrected' in payload) {
|
||||
|
||||
+2
-1
@@ -87,6 +87,7 @@ export type CoreEvent =
|
||||
// Articles (artikelregister)
|
||||
| { type: 'article.created'; payload: { article: Article; userId: string; companyId: string } }
|
||||
| { type: 'article.updated'; payload: { article: Article; userId: string; companyId: string } }
|
||||
| { type: 'article.deleted'; payload: { articleId: string; userId: string; companyId: string } }
|
||||
// Suppliers
|
||||
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
|
||||
// Receipts
|
||||
@@ -174,7 +175,7 @@ export type CoreEvent =
|
||||
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
|
||||
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
|
||||
errorCode: string | null // structured error code from tool-result.toToolError when applicable
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
|
||||
// Raw material for clustering real agent failures into curated gotchas:
|
||||
// errorCode alone can't distinguish "period locked" from "unbalanced".
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"icon": "Mail",
|
||||
"dataPattern": "core",
|
||||
"description": "Skicka fakturor och påminnelser via e-post",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"readsCoreTables": [
|
||||
"invoices",
|
||||
"customers",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
function item(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
description: 'Arbete',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 500,
|
||||
created_at: '2026-07-14T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildCreditNoteItem', () => {
|
||||
it('negates amounts and preserves ROT/RUT, account, accrual, and dimension metadata', () => {
|
||||
const result = buildCreditNoteItem('credit-1', item({
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 600,
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Test 1:2',
|
||||
revenue_account: '3041',
|
||||
accrual_period_start: '2026-07-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
}))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
invoice_id: 'credit-1',
|
||||
quantity: -2,
|
||||
line_total: -2000,
|
||||
vat_amount: -500,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: -600,
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Test 1:2',
|
||||
revenue_account: '3041',
|
||||
accrual_period_start: '2026-07-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import { buildInvoiceCopyInitial, canCopyInvoice } from '@/lib/invoices/copy-invoice'
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
line_type: 'product',
|
||||
description: 'Consulting',
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 2000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 500,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('canCopyInvoice', () => {
|
||||
it.each(['sent', 'paid', 'partially_paid', 'overdue', 'credited'] as const)(
|
||||
'allows an issued ordinary invoice with status %s',
|
||||
(status) => {
|
||||
expect(canCopyInvoice(makeInvoice({ status }))).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects drafts, credit notes, other document types, and self-billing invoices', () => {
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'draft' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', credited_invoice_id: 'invoice-original' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', document_type: 'proforma' }))).toBe(false)
|
||||
expect(canCopyInvoice(makeInvoice({ status: 'sent', is_self_billed: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInvoiceCopyInitial', () => {
|
||||
it('copies reusable content and clears lifecycle-specific fields', () => {
|
||||
const source = makeInvoice({
|
||||
id: 'invoice-original',
|
||||
invoice_number: 'F-2026007',
|
||||
status: 'paid',
|
||||
invoice_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
delivery_date: '2025-12-20',
|
||||
your_reference: 'Old customer contact',
|
||||
our_reference: 'Seller contact',
|
||||
notes: 'Reusable terms',
|
||||
payment_link_url: 'https://example.com/old-payment',
|
||||
journal_entry_id: 'journal-1',
|
||||
ore_rounding: true,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
const first = makeItem({
|
||||
sort_order: 1,
|
||||
article_id: 'article-1',
|
||||
revenue_account: '3041',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Old property',
|
||||
apartment_number: '1201',
|
||||
brf_org_number: '5560000000',
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-06-30',
|
||||
accrual_balance_account: '2970',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
const second = makeItem({ id: 'item-2', sort_order: 0, description: 'First row' })
|
||||
|
||||
const copy = buildInvoiceCopyInitial({ ...source, items: [first, second] })
|
||||
|
||||
expect(copy).toMatchObject({
|
||||
source_invoice_number: 'F-2026007',
|
||||
customer_id: source.customer_id,
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice',
|
||||
our_reference: 'Seller contact',
|
||||
notes: 'Reusable terms',
|
||||
ore_rounding: true,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
expect(copy.items.map((item) => item.description)).toEqual(['First row', 'Consulting'])
|
||||
expect(copy.items[1]).toMatchObject({
|
||||
article_id: null,
|
||||
revenue_account: '3041',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 2,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
expect(copy).not.toHaveProperty('invoice_date')
|
||||
expect(copy).not.toHaveProperty('due_date')
|
||||
expect(copy).not.toHaveProperty('your_reference')
|
||||
expect(copy).not.toHaveProperty('payment_link_url')
|
||||
expect(copy).not.toHaveProperty('journal_entry_id')
|
||||
expect(copy).not.toHaveProperty('status')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
|
||||
|
||||
async function seedCustomerInvoicePair(): Promise<{
|
||||
originalInvoiceId: string
|
||||
creditNoteId: string
|
||||
}> {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId })
|
||||
|
||||
const customerId = randomUUID()
|
||||
const originalInvoiceId = randomUUID()
|
||||
const creditNoteId = randomUUID()
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
|
||||
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
|
||||
[customerId, userId, companyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
|
||||
1000, 0, 1000, 'standard_25', 25, 'sent', 0, 1000)`,
|
||||
[originalInvoiceId, userId, companyId, customerId, `F-${originalInvoiceId.slice(0, 8)}`],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount, credited_invoice_id)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-07-01', '2026-07-31', 'SEK',
|
||||
-1000, 0, -1000, 'standard_25', 25, 'sent', 0, -1000, $6)`,
|
||||
[creditNoteId, userId, companyId, customerId, `KR-${creditNoteId.slice(0, 8)}`, originalInvoiceId],
|
||||
)
|
||||
|
||||
return { originalInvoiceId, creditNoteId }
|
||||
}
|
||||
|
||||
describe('invoices_credit_note_not_paid constraint', () => {
|
||||
it.each(['paid', 'partially_paid'])('rejects credit-note status %s', async (status) => {
|
||||
const { creditNoteId } = await seedCustomerInvoicePair()
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.invoices SET status = $1 WHERE id = $2`, [status, creditNoteId]),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
|
||||
it('allows an ordinary customer invoice to be marked paid', async () => {
|
||||
const { originalInvoiceId } = await seedCustomerInvoicePair()
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.invoices SET status = 'paid' WHERE id = $1`, [originalInvoiceId]),
|
||||
).resolves.toMatchObject({ rowCount: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
|
||||
|
||||
describe('getCreditNoteSendMode', () => {
|
||||
it('prefers email when delivery is available and the customer has an address', () => {
|
||||
expect(getCreditNoteSendMode({
|
||||
customerHasEmail: true,
|
||||
isSandbox: false,
|
||||
canEmail: true,
|
||||
})).toBe('email')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ customerHasEmail: false, isSandbox: false, canEmail: true },
|
||||
{ customerHasEmail: true, isSandbox: true, canEmail: true },
|
||||
{ customerHasEmail: true, isSandbox: false, canEmail: false },
|
||||
])('falls back to manual issuance for $customerHasEmail/$isSandbox/$canEmail', (input) => {
|
||||
expect(getCreditNoteSendMode(input)).toBe('manual')
|
||||
})
|
||||
})
|
||||
@@ -184,6 +184,25 @@ describe('findMatchingInvoices', () => {
|
||||
expect(result[0].matchReason).toContain('OCR-referens')
|
||||
})
|
||||
|
||||
it('never suggests a credit note, even when its OCR reference matches', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, reference: 'KR-F-2024001' })
|
||||
mockResult({
|
||||
data: [
|
||||
makeInvoice({
|
||||
invoice_number: 'KR-F-2024001',
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns immediately on OCR match without further scoring', async () => {
|
||||
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
|
||||
mockResult({
|
||||
|
||||
@@ -29,4 +29,14 @@ describe('isEditableInvoiceDraft', () => {
|
||||
isEditableInvoiceDraft({ status: 'draft', journal_entry_id: null, is_self_billed: true }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks a credit-note draft because it must continue mirroring the original', () => {
|
||||
expect(
|
||||
isEditableInvoiceDraft({
|
||||
status: 'draft',
|
||||
journal_entry_id: null,
|
||||
credited_invoice_id: 'invoice-1',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { CreditNote } from '@/types'
|
||||
|
||||
const mockCreateCreditNoteJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createCreditNoteJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateCreditNoteJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockCancelSchedulesForSource = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
|
||||
cancelSchedulesForSource: (...args: unknown[]) =>
|
||||
mockCancelSchedulesForSource(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
creditNoteNeedsJournalEntry,
|
||||
issueCreditNote,
|
||||
} from '@/lib/invoices/issue-credit-note'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const log: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn(),
|
||||
}
|
||||
|
||||
function makeCreditNote(overrides: Partial<CreditNote> = {}) {
|
||||
return {
|
||||
...makeInvoice({
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-100',
|
||||
invoice_date: '2026-07-14',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'invoice-1',
|
||||
subtotal: -1000,
|
||||
vat_amount: -250,
|
||||
total: -1250,
|
||||
...overrides,
|
||||
}),
|
||||
customer: { name: 'Testkund' },
|
||||
} as CreditNote & { customer: { name: string } }
|
||||
}
|
||||
|
||||
describe('issueCreditNote', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockCancelSchedulesForSource.mockResolvedValue({
|
||||
cancelledSchedules: 1,
|
||||
reversedEntries: 0,
|
||||
failedReversals: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('books an accrual credit note, links it, cancels accruals, and marks the original', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { voucher_series: 'A', voucher_number: 42 }, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-1' })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: {
|
||||
id: 'invoice-1',
|
||||
invoice_number: 'F-100',
|
||||
status: 'sent',
|
||||
journal_entry_id: 'original-journal-1',
|
||||
},
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'accrual',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
complete: true,
|
||||
journalEntryId: 'journal-1',
|
||||
journalEntryRequired: true,
|
||||
repairRequired: false,
|
||||
failures: [],
|
||||
})
|
||||
expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'credit-1', status: 'sent' }),
|
||||
'enskild_firma',
|
||||
'Testkund',
|
||||
'A-42',
|
||||
)
|
||||
expect(mockCancelSchedulesForSource).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ invoiceId: 'invoice-1' },
|
||||
{ reversalDate: '2026-07-14' },
|
||||
)
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'credit_note.created' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('marks and emits a cash-method credit note without creating a journal entry', async () => {
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'cash',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
complete: true,
|
||||
journalEntryId: null,
|
||||
journalEntryRequired: false,
|
||||
repairRequired: false,
|
||||
failures: [],
|
||||
})
|
||||
expect(mockCreateCreditNoteJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not cancel accrual schedules when the credit journal entry fails', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('Perioden är låst'))
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'accrual',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.journalEntryId).toBeNull()
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.failures).toEqual([
|
||||
{ step: 'journal_entry', reason: 'Perioden är låst' },
|
||||
])
|
||||
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books a paid cash-method original before marking it credited', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'journal-cash' })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: {
|
||||
id: 'invoice-1',
|
||||
invoice_number: 'F-100',
|
||||
status: 'paid',
|
||||
paid_at: '2026-07-01T00:00:00Z',
|
||||
},
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'cash',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.journalEntryId).toBe('journal-cash')
|
||||
expect(mockCreateCreditNoteJournalEntry).toHaveBeenCalledOnce()
|
||||
expect(mockCancelSchedulesForSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects when cash-method credit notes need a reversal voucher', () => {
|
||||
const original = { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' }
|
||||
expect(creditNoteNeedsJournalEntry('cash', original)).toBe(false)
|
||||
expect(creditNoteNeedsJournalEntry('cash', { ...original, status: 'paid' })).toBe(true)
|
||||
expect(creditNoteNeedsJournalEntry('cash', { ...original, paid_amount: 100 })).toBe(true)
|
||||
expect(creditNoteNeedsJournalEntry('accrual', original)).toBe(true)
|
||||
})
|
||||
|
||||
it('reuses the posted voucher that wins a concurrent create race', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
mockCreateCreditNoteJournalEntry.mockRejectedValue(new Error('duplicate source'))
|
||||
enqueue({ data: { id: 'journal-winner' }, error: null })
|
||||
enqueue({ data: [{ id: 'credit-1' }], error: null })
|
||||
enqueue({ data: [{ id: 'invoice-1' }], error: null })
|
||||
|
||||
const result = await issueCreditNote({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
creditNote: makeCreditNote(),
|
||||
originalInvoice: { id: 'invoice-1', invoice_number: 'F-100', status: 'sent' },
|
||||
entityType: 'enskild_firma',
|
||||
accountingMethod: 'accrual',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.journalEntryId).toBe('journal-winner')
|
||||
expect(result.failures).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
|
||||
|
||||
async function seedCompanySettings(): Promise<string> {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId })
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id)
|
||||
VALUES ($1, $2)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
return companyId
|
||||
}
|
||||
|
||||
describe('company_settings reminder day constraints', () => {
|
||||
it('uses the legacy 15, 30, and 45 day defaults', async () => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
const result = await getPool().query(
|
||||
`SELECT reminder_days_level_1, reminder_days_level_2, reminder_days_level_3
|
||||
FROM public.company_settings
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
reminder_days_level_1: 15,
|
||||
reminder_days_level_2: 30,
|
||||
reminder_days_level_3: 45,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a strictly increasing custom schedule', async () => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings
|
||||
SET reminder_days_level_1 = 7,
|
||||
reminder_days_level_2 = 21,
|
||||
reminder_days_level_3 = 35
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
),
|
||||
).resolves.toMatchObject({ rowCount: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[30, 20, 45],
|
||||
[15, 15, 45],
|
||||
[0, 30, 45],
|
||||
[15, 30, 366],
|
||||
])('rejects invalid schedule %s, %s, %s', async (level1, level2, level3) => {
|
||||
const companyId = await seedCompanySettings()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings
|
||||
SET reminder_days_level_1 = $1,
|
||||
reminder_days_level_2 = $2,
|
||||
reminder_days_level_3 = $3
|
||||
WHERE company_id = $4`,
|
||||
[level1, level2, level3, companyId],
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
determineReminderLevel,
|
||||
calculateDaysOverdue,
|
||||
} from '../reminder-processor'
|
||||
import { getReminderDaysConfig } from '@/lib/email/reminder-templates'
|
||||
|
||||
describe('determineReminderLevel', () => {
|
||||
it('returns null below the level-1 threshold', () => {
|
||||
@@ -60,6 +61,37 @@ describe('determineReminderLevel', () => {
|
||||
it('returns null when all levels have been sent', () => {
|
||||
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a company-specific schedule', () => {
|
||||
const config = { 1: 7, 2: 21, 3: 35 } as const
|
||||
|
||||
expect(determineReminderLevel(6, [], config)).toBeNull()
|
||||
expect(determineReminderLevel(7, [], config)).toBe(1)
|
||||
expect(determineReminderLevel(21, [1], config)).toBe(2)
|
||||
expect(determineReminderLevel(35, [1, 2], config)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReminderDaysConfig', () => {
|
||||
it('returns the existing defaults when no company settings are supplied', () => {
|
||||
expect(getReminderDaysConfig()).toEqual({ 1: 15, 2: 30, 3: 45 })
|
||||
})
|
||||
|
||||
it('returns configured company thresholds', () => {
|
||||
expect(getReminderDaysConfig({
|
||||
reminder_days_level_1: 5,
|
||||
reminder_days_level_2: 10,
|
||||
reminder_days_level_3: 20,
|
||||
})).toEqual({ 1: 5, 2: 10, 3: 20 })
|
||||
})
|
||||
|
||||
it('falls back to defaults for an invalid stored schedule', () => {
|
||||
expect(getReminderDaysConfig({
|
||||
reminder_days_level_1: 30,
|
||||
reminder_days_level_2: 20,
|
||||
reminder_days_level_3: 45,
|
||||
})).toEqual({ 1: 15, 2: 30, 3: 45 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDaysOverdue', () => {
|
||||
|
||||
@@ -49,6 +49,28 @@ describe('settleInvoicePayment', () => {
|
||||
vi.mocked(createInvoiceCashEntry).mockResolvedValue({ id: 'je-2' } as never)
|
||||
})
|
||||
|
||||
it('rejects credit notes before creating a journal entry or updating state', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice: payableInvoice({ credited_invoice_id: 'original-invoice-1' }),
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_NOT_PAYABLE',
|
||||
details: { reason: 'credit_note' },
|
||||
})
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(createInvoiceCashEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books via the payment entry and forwards the settlement account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InvoiceItem } from '@/types'
|
||||
|
||||
export function buildCreditNoteItem(invoiceId: string, item: InvoiceItem) {
|
||||
return {
|
||||
invoice_id: invoiceId,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
article_id: item.article_id ?? null,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
deduction_amount: item.deduction_amount
|
||||
? -Math.abs(item.deduction_amount)
|
||||
: 0,
|
||||
labor_hours: item.labor_hours ?? null,
|
||||
work_type: item.work_type ?? null,
|
||||
housing_designation: item.housing_designation ?? null,
|
||||
apartment_number: item.apartment_number ?? null,
|
||||
brf_org_number: item.brf_org_number ?? null,
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
dimensions: item.dimensions ?? {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
Currency,
|
||||
Invoice,
|
||||
InvoiceDocumentType,
|
||||
InvoiceItem,
|
||||
InvoiceStatus,
|
||||
} from '@/types'
|
||||
|
||||
const COPYABLE_STATUSES: ReadonlySet<InvoiceStatus> = new Set([
|
||||
'sent',
|
||||
'paid',
|
||||
'partially_paid',
|
||||
'overdue',
|
||||
'credited',
|
||||
])
|
||||
|
||||
export type InvoiceCopySource = Invoice & { items: InvoiceItem[] }
|
||||
|
||||
export interface InvoiceCopyItem {
|
||||
line_type: 'product' | 'text'
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate: number
|
||||
article_id: null
|
||||
revenue_account: string | null
|
||||
deduction_type: 'rot' | 'rut' | null
|
||||
labor_hours: number | null
|
||||
work_type: string | null
|
||||
housing_designation: null
|
||||
apartment_number: null
|
||||
brf_org_number: null
|
||||
accrual_period_start: null
|
||||
accrual_period_end: null
|
||||
accrual_balance_account: null
|
||||
dimensions: Record<string, string> | null
|
||||
}
|
||||
|
||||
export interface InvoiceCopyInitial {
|
||||
source_invoice_number: string
|
||||
customer_id: string
|
||||
currency: Currency
|
||||
document_type: InvoiceDocumentType
|
||||
our_reference: string
|
||||
notes: string
|
||||
ore_rounding: boolean | null
|
||||
default_dimensions: Record<string, string>
|
||||
items: InvoiceCopyItem[]
|
||||
}
|
||||
|
||||
export function canCopyInvoice(
|
||||
invoice: Pick<Invoice, 'status' | 'document_type' | 'credited_invoice_id' | 'is_self_billed'>,
|
||||
): boolean {
|
||||
return (
|
||||
invoice.document_type === 'invoice' &&
|
||||
!invoice.credited_invoice_id &&
|
||||
!invoice.is_self_billed &&
|
||||
COPYABLE_STATUSES.has(invoice.status)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a safe starting point for a new invoice draft.
|
||||
*
|
||||
* The copied data is limited to reusable commercial content. Identity,
|
||||
* lifecycle, payment, bookkeeping, date, accrual, and recipient-specific
|
||||
* ROT/RUT fields are deliberately absent or cleared.
|
||||
*/
|
||||
export function buildInvoiceCopyInitial(source: InvoiceCopySource): InvoiceCopyInitial {
|
||||
return {
|
||||
source_invoice_number: source.invoice_number ?? '',
|
||||
customer_id: source.customer_id,
|
||||
currency: source.currency,
|
||||
document_type: 'invoice',
|
||||
our_reference: source.our_reference ?? '',
|
||||
notes: source.notes ?? '',
|
||||
ore_rounding: source.ore_rounding,
|
||||
default_dimensions: source.default_dimensions ?? {},
|
||||
items: [...source.items]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((item) => ({
|
||||
line_type: item.line_type ?? 'product',
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
vat_rate: item.vat_rate ?? 25,
|
||||
// A copied line keeps the frozen description and price, but is not
|
||||
// linked to a possibly changed or archived article preset.
|
||||
article_id: null,
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
labor_hours: item.labor_hours ?? null,
|
||||
work_type: item.work_type ?? null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
// Accrual dates belong to the original accounting period.
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: item.dimensions && Object.keys(item.dimensions).length > 0
|
||||
? item.dimensions
|
||||
: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getCreditNoteSendMode(input: {
|
||||
customerHasEmail: boolean
|
||||
isSandbox: boolean
|
||||
canEmail: boolean
|
||||
}): 'email' | 'manual' {
|
||||
return input.customerHasEmail && !input.isSandbox && input.canEmail
|
||||
? 'email'
|
||||
: 'manual'
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { Invoice, Transaction, Customer } from '@/types'
|
||||
|
||||
export interface InvoiceMatch {
|
||||
@@ -130,17 +131,19 @@ export async function findMatchingInvoices(
|
||||
}
|
||||
|
||||
// Query unpaid invoices (sent or overdue) with customer info
|
||||
const { data: invoices, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*)
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (error || !invoices) {
|
||||
let invoices: Array<Invoice & { customer?: { name?: string | null } | null }>
|
||||
try {
|
||||
invoices = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('company_id', companyId)
|
||||
.is('credited_invoice_id', null)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
} catch {
|
||||
// Failed to fetch invoices: return empty matches
|
||||
return []
|
||||
}
|
||||
@@ -149,22 +152,36 @@ export async function findMatchingInvoices(
|
||||
// attached but whose status leaked (still 'sent'/'overdue'). Partially-paid
|
||||
// invoices can legitimately take more payments, so they pass through.
|
||||
// Without this, a status leak would double-book the receipt.
|
||||
const fullCandidateIds = invoices
|
||||
const payableInvoices = invoices.filter(
|
||||
(invoice) => {
|
||||
const creditNotes = (invoice as Invoice & {
|
||||
credit_notes?: Array<{ status: string; creation_complete?: boolean }>
|
||||
}).credit_notes ?? []
|
||||
return !invoice.credited_invoice_id && !creditNotes.some(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
},
|
||||
)
|
||||
const fullCandidateIds = payableInvoices
|
||||
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
|
||||
.map((inv) => inv.id as string)
|
||||
const paidIds = new Set<string>()
|
||||
if (fullCandidateIds.length > 0) {
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullCandidateIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
for (const row of paymentRows ?? []) {
|
||||
const paymentRows = await fetchAllRows<{ id: string; invoice_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('id, invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullCandidateIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
for (const row of paymentRows) {
|
||||
paidIds.add((row as { invoice_id: string }).invoice_id)
|
||||
}
|
||||
}
|
||||
const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string))
|
||||
const filteredInvoices = payableInvoices.filter((inv) => !paidIds.has(inv.id as string))
|
||||
if (filteredInvoices.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* entry is created when the invoice is sent (mark-sent / send) or, for
|
||||
* kontantmetoden, at payment; once one exists, BFL immutability applies and the
|
||||
* invoice must be corrected with a credit note instead. A self-billed invoice we
|
||||
* received is the counterparty's document: never editable here.
|
||||
* received is the counterparty's document: never editable here. Credit-note
|
||||
* drafts mirror an issued invoice and must not be changed into a different
|
||||
* correction after creation.
|
||||
*
|
||||
* This is the single source of truth for that predicate. The PATCH route
|
||||
* (app/api/invoices/[id]/route.ts) enforces it server-side; the detail and edit
|
||||
@@ -15,6 +17,12 @@ export function isEditableInvoiceDraft(invoice: {
|
||||
status: string
|
||||
journal_entry_id?: string | null
|
||||
is_self_billed?: boolean | null
|
||||
credited_invoice_id?: string | null
|
||||
}): boolean {
|
||||
return invoice.status === 'draft' && !invoice.journal_entry_id && !invoice.is_self_billed
|
||||
return (
|
||||
invoice.status === 'draft' &&
|
||||
!invoice.journal_entry_id &&
|
||||
!invoice.is_self_billed &&
|
||||
!invoice.credited_invoice_id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { AccountingMethod, CreditNote, EntityType } from '@/types'
|
||||
|
||||
export interface CreditNoteOriginalInvoice {
|
||||
id: string
|
||||
invoice_number: string | null
|
||||
status: string
|
||||
journal_entry_id?: string | null
|
||||
paid_at?: string | null
|
||||
paid_amount?: number | null
|
||||
total?: number | null
|
||||
}
|
||||
|
||||
export interface CreditNoteIssueFailure {
|
||||
step: 'journal_entry' | 'journal_link' | 'accrual_schedules' | 'original_status'
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface IssueCreditNoteInput {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
creditNote: CreditNote & { customer?: { name?: string | null } | null }
|
||||
originalInvoice: CreditNoteOriginalInvoice
|
||||
entityType: EntityType
|
||||
accountingMethod: AccountingMethod
|
||||
log: Logger
|
||||
}
|
||||
|
||||
export interface IssueCreditNoteResult {
|
||||
complete: boolean
|
||||
journalEntryId: string | null
|
||||
journalEntryRequired: boolean
|
||||
repairRequired: boolean
|
||||
failures: CreditNoteIssueFailure[]
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Okänt fel'
|
||||
}
|
||||
|
||||
/**
|
||||
* Faktureringsmetoden always books the credit on issue. Kontantmetoden only
|
||||
* books it when the original sale has already reached the ledger, for example
|
||||
* through a completed payment or a year-end receivable entry.
|
||||
*/
|
||||
export function creditNoteNeedsJournalEntry(
|
||||
accountingMethod: AccountingMethod,
|
||||
originalInvoice: CreditNoteOriginalInvoice,
|
||||
): boolean {
|
||||
return (
|
||||
accountingMethod === 'accrual' ||
|
||||
!!originalInvoice.journal_entry_id ||
|
||||
originalInvoice.status === 'paid' ||
|
||||
!!originalInvoice.paid_at ||
|
||||
Math.abs(originalInvoice.paid_amount ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
async function getOriginalVoucherRef(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
journalEntryId: string | null | undefined,
|
||||
log: Logger,
|
||||
): Promise<string | undefined> {
|
||||
if (!journalEntryId) return undefined
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
log.warn('failed to load original voucher reference for credit note', error)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!data?.voucher_series || data.voucher_number == null) return undefined
|
||||
return `${data.voucher_series}-${data.voucher_number}`
|
||||
}
|
||||
|
||||
async function findExistingCreditJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
creditNote: CreditNote,
|
||||
): Promise<string | null> {
|
||||
if (creditNote.journal_entry_id) return creditNote.journal_entry_id
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'credit_note')
|
||||
.eq('source_id', creditNote.id)
|
||||
.eq('status', 'posted')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
return data?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the accounting side of credit-note issuance after the caller has
|
||||
* won the draft-to-sent compare-and-set. Every step is idempotent so a sent
|
||||
* credit note with incomplete bookkeeping can be repaired without creating a
|
||||
* second immutable voucher.
|
||||
*/
|
||||
export async function issueCreditNote(input: IssueCreditNoteInput): Promise<IssueCreditNoteResult> {
|
||||
const {
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
creditNote,
|
||||
originalInvoice,
|
||||
entityType,
|
||||
accountingMethod,
|
||||
log,
|
||||
} = input
|
||||
const failures: CreditNoteIssueFailure[] = []
|
||||
const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
let journalEntryId: string | null = null
|
||||
const issuedCreditNote = { ...creditNote, status: 'sent' as const }
|
||||
|
||||
if (journalEntryRequired) {
|
||||
try {
|
||||
journalEntryId = await findExistingCreditJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
issuedCreditNote,
|
||||
)
|
||||
|
||||
if (!journalEntryId) {
|
||||
const originalVoucherRef = await getOriginalVoucherRef(
|
||||
supabase,
|
||||
companyId,
|
||||
originalInvoice.journal_entry_id,
|
||||
log,
|
||||
)
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
issuedCreditNote,
|
||||
entityType,
|
||||
creditNote.customer?.name ?? undefined,
|
||||
originalVoucherRef,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
// A concurrent issuer may have won the unique posted-source guard after
|
||||
// our initial lookup. Re-read and reuse that immutable voucher.
|
||||
try {
|
||||
journalEntryId = await findExistingCreditJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
issuedCreditNote,
|
||||
)
|
||||
} catch (recoveryError) {
|
||||
log.error('failed to recover credit note journal entry after create conflict', recoveryError, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
}
|
||||
if (!journalEntryId) {
|
||||
log.error('failed to create or recover credit note journal entry on issue', error, {
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
failures.push({ step: 'journal_entry', reason: errorMessage(error) })
|
||||
}
|
||||
}
|
||||
|
||||
if (!journalEntryId) {
|
||||
if (failures.length === 0) {
|
||||
failures.push({
|
||||
step: 'journal_entry',
|
||||
reason: 'Ingen öppen bokföringsperiod hittades för kreditfakturans datum.',
|
||||
})
|
||||
}
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: false, failures }
|
||||
}
|
||||
|
||||
if (creditNote.journal_entry_id !== journalEntryId) {
|
||||
const { data: linkedRows, error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', creditNote.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'sent')
|
||||
.select('id')
|
||||
|
||||
if (linkError || !linkedRows || linkedRows.length === 0) {
|
||||
log.error('failed to link credit note to journal entry', linkError ?? undefined, {
|
||||
creditNoteId: creditNote.id,
|
||||
journalEntryId,
|
||||
})
|
||||
failures.push({
|
||||
step: 'journal_link',
|
||||
reason: linkError?.message ?? 'Kreditfakturan kunde inte kopplas till verifikatet.',
|
||||
})
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
|
||||
}
|
||||
}
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const cancelResult = await cancelSchedulesForSource(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{ invoiceId: originalInvoice.id },
|
||||
{ reversalDate: creditNote.invoice_date },
|
||||
)
|
||||
if (cancelResult.failedReversals > 0) {
|
||||
failures.push({
|
||||
step: 'accrual_schedules',
|
||||
reason:
|
||||
'En eller flera periodiseringsverifikat kunde inte vändas. ' +
|
||||
'Kontrollera Bokföring > Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('failed to cancel accrual schedules for credited invoice', error)
|
||||
failures.push({ step: 'accrual_schedules', reason: errorMessage(error) })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let originalStatusChanged = false
|
||||
if (originalInvoice.status !== 'credited') {
|
||||
const { data: updatedOriginal, error: originalStatusError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'credited' })
|
||||
.eq('id', originalInvoice.id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.select('id')
|
||||
|
||||
if (originalStatusError || !updatedOriginal || updatedOriginal.length === 0) {
|
||||
log.error('failed to mark original invoice as credited', originalStatusError ?? undefined, {
|
||||
originalInvoiceId: originalInvoice.id,
|
||||
creditNoteId: creditNote.id,
|
||||
})
|
||||
failures.push({
|
||||
step: 'original_status',
|
||||
reason: originalStatusError?.message ?? 'Originalfakturan kunde inte markeras som krediterad.',
|
||||
})
|
||||
return {
|
||||
complete: false,
|
||||
journalEntryId,
|
||||
journalEntryRequired,
|
||||
repairRequired: journalEntryRequired && !!journalEntryId,
|
||||
failures,
|
||||
}
|
||||
}
|
||||
originalStatusChanged = true
|
||||
}
|
||||
|
||||
if (originalStatusChanged) {
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'credit_note.created',
|
||||
payload: { creditNote: issuedCreditNote, companyId, userId },
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('credit_note.created emit failed after completed issuance', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { complete: true, journalEntryId, journalEntryRequired, repairRequired: false, failures }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
generateReminderEmailHtml,
|
||||
generateReminderEmailText,
|
||||
generateReminderEmailSubject,
|
||||
getReminderDaysConfig
|
||||
getReminderDaysConfig,
|
||||
type ReminderDaysConfig,
|
||||
} from '@/lib/email/reminder-templates'
|
||||
import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest'
|
||||
import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries'
|
||||
@@ -49,21 +50,19 @@ export interface ProcessRemindersResult {
|
||||
*/
|
||||
export function determineReminderLevel(
|
||||
daysOverdue: number,
|
||||
existingLevels: number[]
|
||||
existingLevels: number[],
|
||||
config: ReminderDaysConfig = getReminderDaysConfig(),
|
||||
): 1 | 2 | 3 | null {
|
||||
const config = getReminderDaysConfig()
|
||||
|
||||
// Check level 3 (45 days)
|
||||
// Check the highest eligible level first, preserving the existing behavior
|
||||
// when a previous cron run was missed.
|
||||
if (daysOverdue >= config[3] && !existingLevels.includes(3)) {
|
||||
return 3
|
||||
}
|
||||
|
||||
// Check level 2 (30 days)
|
||||
if (daysOverdue >= config[2] && !existingLevels.includes(2)) {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Check level 1 (15 days)
|
||||
if (daysOverdue >= config[1] && !existingLevels.includes(1)) {
|
||||
return 1
|
||||
}
|
||||
@@ -146,12 +145,11 @@ export async function sendReminder(
|
||||
export async function processOverdueReminders(): Promise<ProcessRemindersResult> {
|
||||
const supabase = createServiceClient()
|
||||
const results: ReminderResult[] = []
|
||||
const config = getReminderDaysConfig()
|
||||
|
||||
// Find all sent invoices that are past due date (at least 15 days overdue)
|
||||
const minOverdueDays = config[1]
|
||||
// Company schedules can start as early as one day overdue. Fetch that
|
||||
// bounded candidate set, then apply each company's thresholds below.
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - minOverdueDays)
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 1)
|
||||
|
||||
// Positive allowlist: inherently excludes 'paid', 'partially_paid', 'cancelled', 'credited'.
|
||||
// Including 'overdue' ensures level-2 / level-3 reminders re-fire after the first reminder
|
||||
@@ -160,7 +158,8 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*)
|
||||
customer:customers(*),
|
||||
credit_notes:invoices!credited_invoice_id(id, status, creation_complete)
|
||||
`)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.is('credited_invoice_id', null)
|
||||
@@ -181,6 +180,17 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
// Process each invoice
|
||||
for (const invoice of overdueInvoices) {
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: active credit note exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
|
||||
// Skip if customer has no email
|
||||
@@ -209,13 +219,6 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
const existingLevels = existingReminders?.map(r => r.reminder_level) || []
|
||||
const daysOverdue = calculateDaysOverdue(invoice.due_date)
|
||||
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels)
|
||||
|
||||
// Skip if no reminder needed
|
||||
if (!reminderLevel) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get company settings for this user
|
||||
const { data: company, error: companyError } = await supabase
|
||||
@@ -226,14 +229,17 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
if (companyError || !company) {
|
||||
log.error(`Skipping invoice ${invoice.invoice_number}: company settings not found`)
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerEmail: customer.email,
|
||||
reminderLevel,
|
||||
success: false,
|
||||
error: 'Company settings not found'
|
||||
})
|
||||
const fallbackLevel = determineReminderLevel(daysOverdue, existingLevels)
|
||||
if (fallbackLevel) {
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerEmail: customer.email,
|
||||
reminderLevel: fallbackLevel,
|
||||
success: false,
|
||||
error: 'Company settings not found',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -243,16 +249,35 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
continue
|
||||
}
|
||||
|
||||
const reminderConfig = getReminderDaysConfig(company as CompanySettings)
|
||||
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels, reminderConfig)
|
||||
|
||||
if (!reminderLevel) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Race-window guard: re-check invoice status immediately before sending.
|
||||
// The cron runs at 08:00; a payment match arriving during the run shouldn't
|
||||
// produce a reminder for an already-paid invoice.
|
||||
const { data: currentInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('status')
|
||||
.select('status, credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', invoice.company_id)
|
||||
.single()
|
||||
|
||||
if (!currentInvoice || !['sent', 'overdue'].includes(currentInvoice.status as string)) {
|
||||
const currentCreditNotes = ((currentInvoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> } | null)?.credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (
|
||||
!currentInvoice ||
|
||||
!['sent', 'overdue'].includes(currentInvoice.status as string) ||
|
||||
currentCreditNotes.length > 0
|
||||
) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: status changed to ${currentInvoice?.status ?? 'unknown'} mid-run`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export type SettleInvoicePaymentResult =
|
||||
| { ok: false; code: 'INVOICE_PAID_LINES_UNBALANCED'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_NO_FISCAL_PERIOD'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_BOOK_FAILED'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_NOT_PAYABLE'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_RACE' }
|
||||
| { ok: false; code: 'BOOKKEEPING_ERROR'; error: unknown }
|
||||
| { ok: false; code: 'UPDATE_FAILED'; error: unknown }
|
||||
@@ -102,6 +103,14 @@ export async function settleInvoicePayment(
|
||||
settlementAccountNumber,
|
||||
} = params
|
||||
|
||||
if (invoice.credited_invoice_id) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_NOT_PAYABLE',
|
||||
details: { reason: 'credit_note' },
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Drive the JE shape from the invoice's actual booking state, not from
|
||||
|
||||
@@ -143,6 +143,38 @@ describe('commitPendingOperation: unlock_period', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: credit-note issuance guard', () => {
|
||||
it('rejects mark_invoice_sent before the ordinary invoice executor can book it', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: 'credit-1',
|
||||
status: 'draft',
|
||||
credited_invoice_id: 'invoice-1',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // dispatcher's rejected update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'mark_invoice_sent',
|
||||
params: { invoice_id: 'credit-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
op,
|
||||
)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(result.error).toContain('Credit notes must be issued')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── post_annual_depreciation ───────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: post_annual_depreciation', () => {
|
||||
|
||||
@@ -253,6 +253,49 @@ describe('commitPendingOperation: link_transaction_journal_entry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a credit note before linking the transaction', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: INV_UUID,
|
||||
status: 'sent',
|
||||
total: -1000,
|
||||
remaining_amount: -1000,
|
||||
credited_invoice_id: 'original-invoice-1',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
params: {
|
||||
transaction_id: TX_UUID,
|
||||
journal_entry_id: JE_UUID,
|
||||
invoice_id: INV_UUID,
|
||||
},
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toBe('Credit notes cannot be recorded as paid.')
|
||||
})
|
||||
|
||||
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -67,6 +67,35 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () => {
|
||||
it('rejects credit notes before creating a payment journal entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026001',
|
||||
status: 'sent',
|
||||
total: -525,
|
||||
remaining_amount: -525,
|
||||
paid_amount: null,
|
||||
credited_invoice_id: 'inv-1',
|
||||
document_type: 'invoice',
|
||||
journal_entry_id: 'je-credit',
|
||||
customer: { name: 'Test AB' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { invoice_id: 'credit-1', payment_date: '2026-03-30' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('zeroes remaining_amount and emits invoice.paid on full payment (issue #825)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -59,6 +59,42 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: match_transaction_invoice settlement account resolution', () => {
|
||||
it('rejects a credit note before creating a payment journal entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'credit-1',
|
||||
invoice_number: 'KR-F-2026001',
|
||||
status: 'sent',
|
||||
total: -12500,
|
||||
credited_invoice_id: 'inv-1',
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'credit-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('credits the payment JE to the transaction\'s own linked cash account, not a hardcoded 1930', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
|
||||
@@ -905,6 +905,9 @@ async function commitMarkInvoicePaid(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return { error: 'Kreditfakturor kan inte markeras som betalda.', status: 409 }
|
||||
}
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
|
||||
}
|
||||
@@ -1143,6 +1146,12 @@ async function commitSendInvoice(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return {
|
||||
error: 'Credit notes must be issued through the invoice send flow',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
// partially_paid/credited imply the invoice was already issued too: the
|
||||
// status flip below would regress them to 'sent' (PR #666 review, ASVS V2.3).
|
||||
if (['sent', 'paid', 'overdue', 'partially_paid', 'credited'].includes(invoice.status)) {
|
||||
@@ -1312,6 +1321,12 @@ async function commitMarkInvoiceSent(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return {
|
||||
error: 'Credit notes must be issued through the invoice send flow',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
|
||||
|
||||
try {
|
||||
@@ -1374,6 +1389,9 @@ async function commitMatchTransactionInvoice(
|
||||
.single()
|
||||
|
||||
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.credited_invoice_id) {
|
||||
return { error: 'Kreditfakturor kan inte registreras som betalda.', status: 409 }
|
||||
}
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return { error: 'Invoice is not in a matchable state', status: 409 }
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export type LinkTransactionJournalEntryErrorCode =
|
||||
| 'LINK_TX_JE_NOT_POSTED'
|
||||
| 'LINK_TX_INVOICE_NOT_FOUND'
|
||||
| 'LINK_TX_INVOICE_NOT_OPEN'
|
||||
| 'LINK_TX_INVOICE_CREDIT_NOTE'
|
||||
| 'LINK_TX_INVOICE_CURRENCY_MISMATCH'
|
||||
| 'LINK_TX_INVOICE_RACE'
|
||||
| 'MATCH_INVOICE_RECORD_PAYMENT_FAILED'
|
||||
@@ -186,6 +187,7 @@ export async function linkTransactionToJournalEntry(
|
||||
| 'exchange_rate'
|
||||
| 'paid_at'
|
||||
| 'invoice_number'
|
||||
| 'credited_invoice_id'
|
||||
> & { customer?: { name?: string } | null }
|
||||
let invoice: FetchedInvoice | null = null
|
||||
let newPaidAmount = 0
|
||||
@@ -200,7 +202,7 @@ export async function linkTransactionToJournalEntry(
|
||||
const { data: invoiceRow, error: fetchInvError } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, customer:customers(name)'
|
||||
'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, credited_invoice_id, customer:customers(name)'
|
||||
)
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
@@ -210,6 +212,10 @@ export async function linkTransactionToJournalEntry(
|
||||
return { ok: false, code: 'LINK_TX_INVOICE_NOT_FOUND' }
|
||||
}
|
||||
|
||||
if (invoiceRow.credited_invoice_id) {
|
||||
return { ok: false, code: 'LINK_TX_INVOICE_CREDIT_NOTE' }
|
||||
}
|
||||
|
||||
if (
|
||||
invoiceRow.status !== 'sent' &&
|
||||
invoiceRow.status !== 'overdue' &&
|
||||
|
||||
Reference in New Issue
Block a user