Fix/usr fdbck ch (#1105)
* fix(privacy): mask voucher amounts in session replays * fix: persist transaction source filter * fix: clarify invoice filenames and booking previews * fix: truncate long uploaded filenames * feat: add invoice delivery history * fix: harden invoice delivery history * fix: include invoice deliveries in full archive
This commit is contained in:
@@ -78,6 +78,98 @@ describe('proposeSendLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create zero-value revenue rows for informational invoice items', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
items: [
|
||||
makeItem(),
|
||||
makeItem({
|
||||
id: 'text-1',
|
||||
line_type: 'text',
|
||||
description: 'Information shown on the invoice',
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines.map((line) => line.account_number)).toEqual(['1510', '3001', '2611'])
|
||||
expect(lines.some((line) =>
|
||||
(parseFloat(line.debit_amount) || 0) === 0
|
||||
&& (parseFloat(line.credit_amount) || 0) === 0
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores informational rows when selecting the legacy invoice VAT treatment', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
items: [
|
||||
makeItem({ vat_rate: undefined }),
|
||||
makeItem({
|
||||
id: 'text-1',
|
||||
line_type: 'text',
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines.map((line) => line.account_number)).toEqual(['1510', '3001', '2611'])
|
||||
})
|
||||
|
||||
it('returns no booking proposal for an invoice containing only informational rows', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
total: 0,
|
||||
subtotal: 0,
|
||||
vat_amount: 0,
|
||||
items: [
|
||||
makeItem({
|
||||
line_type: 'text',
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-zero invoice containing only informational rows', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
items: [
|
||||
makeItem({
|
||||
line_type: 'text',
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
|
||||
it('credit note uses positive amounts on the reversed sides', () => {
|
||||
const lines = proposeSendLines({
|
||||
invoice: makeInvoiceInput({
|
||||
|
||||
@@ -121,14 +121,22 @@ function buildSendLines(
|
||||
|
||||
// Build credit lines per VAT rate group
|
||||
const creditLines: FormLine[] = []
|
||||
const accountingItems = (invoice.items ?? []).filter((item) => item.line_type !== 'text')
|
||||
|
||||
if (invoice.items && invoice.items.length > 0) {
|
||||
const hasPerLineVat = invoice.items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
|
||||
// Existing informational rows are never a valid source for an invoice-level
|
||||
// amount. Returning no proposal keeps an inconsistent text-only invoice from
|
||||
// producing a debit-only entry; the user must correct its economic rows.
|
||||
if (accountingItems.length === 0 && (invoice.items?.length ?? 0) > 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (accountingItems.length > 0) {
|
||||
const hasPerLineVat = accountingItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
|
||||
|
||||
if (!hasPerLineVat) {
|
||||
// Legacy: single rate from invoice level
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.line_total, 0)
|
||||
const subtotal = accountingItems.reduce((sum, item) => sum + item.line_total, 0)
|
||||
creditLines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: '',
|
||||
@@ -136,7 +144,7 @@ function buildSendLines(
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
|
||||
const totalVat = accountingItems.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
|
||||
if (totalVat > 0) {
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
||||
creditLines.push({
|
||||
@@ -149,7 +157,7 @@ function buildSendLines(
|
||||
} else {
|
||||
// Group items by vat_rate
|
||||
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
|
||||
for (const item of invoice.items) {
|
||||
for (const item of accountingItems) {
|
||||
const rate = item.vat_rate ?? 0
|
||||
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
|
||||
group.subtotal += item.line_total
|
||||
@@ -182,7 +190,7 @@ function buildSendLines(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (!invoice.items || invoice.items.length === 0) {
|
||||
// Fallback: invoice-level amounts
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
||||
@@ -207,7 +215,7 @@ function buildSendLines(
|
||||
|
||||
const deductionLines: FormLine[] = []
|
||||
let deductionTotal = 0
|
||||
for (const item of invoice.items ?? []) {
|
||||
for (const item of accountingItems) {
|
||||
if (!item.deduction_type) continue
|
||||
const deduction = computeDeduction({
|
||||
unit_price: item.unit_price,
|
||||
@@ -225,13 +233,26 @@ function buildSendLines(
|
||||
})
|
||||
}
|
||||
|
||||
// Text-only and other informational invoice rows carry zero totals. They
|
||||
// must not become misleading 30xx rows in the booking preview.
|
||||
const nonZeroCreditLines = creditLines.filter(
|
||||
(line) => roundOre(parseFloat(line.credit_amount) || 0) !== 0,
|
||||
)
|
||||
|
||||
// Debit: 1510 customer portion plus 1513 Skatteverket portion.
|
||||
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const totalCredits = nonZeroCreditLines.reduce(
|
||||
(sum, line) => sum + (parseFloat(line.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)
|
||||
|
||||
if (customerReceivable === 0 && deductionLines.length === 0 && nonZeroCreditLines.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: toFormAmount(customerReceivable),
|
||||
@@ -240,7 +261,7 @@ function buildSendLines(
|
||||
})
|
||||
|
||||
lines.push(...deductionLines)
|
||||
lines.push(...creditLines)
|
||||
lines.push(...nonZeroCreditLines)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user