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