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:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
@@ -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' },
})
})
})
+108
View File
@@ -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
+31
View File
@@ -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 ?? {},
}
}
+108
View File
@@ -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,
})),
}
}
+9
View File
@@ -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'
}
+37 -20
View File
@@ -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 []
}
+10 -2
View File
@@ -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
)
}
+280
View File
@@ -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 }
}
+54 -29
View File
@@ -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
}
+9
View File
@@ -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