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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user