Ausgangs- und Eingangsbelege erhalten Skontosatz und Frist, mit Vorgaben bei Kunden und Lieferanten. Das PDF ergänzt automatisch einen Skontohinweis (oder nutzt neue Textvorlagen-Variablen); bei Schlussrechnungen gilt der Skonto für die Restforderung. Skontobedingungen werden aus E-Rechnungen (#SKONTO# und ZUGFeRD) sowie von der KI-Vorbereitung übernommen. Die Bankzuordnung erkennt Zahlungen abzüglich Skonto innerhalb der Frist und bietet sie in der Vorschlagsübersicht direkt an. BWA-Erlöse und -Aufwand berücksichtigen gebuchten Skonto im Zeitraum der Zahlung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
7.6 KiB
TypeScript
142 lines
7.6 KiB
TypeScript
import assert from "node:assert/strict"
|
||
import test from "node:test"
|
||
import {
|
||
getCreatedDocumentGrossByRate,
|
||
getCreatedDocumentGrossTotal,
|
||
getIncomingInvoiceGrossByRate,
|
||
normalizeCashDiscountAccountsInput,
|
||
resolveCashDiscountAccounts,
|
||
splitCashDiscount,
|
||
validateCashDiscount,
|
||
} from "../src/modules/cash-discount"
|
||
import { getCashDiscountBookingLines } from "../src/utils/export/datev"
|
||
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||
|
||
const defaults = resolveCashDiscountAccounts({})
|
||
|
||
test("splits incoming invoice discount by tax rate onto received discount accounts", () => {
|
||
const gross = getIncomingInvoiceGrossByRate({
|
||
expense: true,
|
||
accounts: [
|
||
{ taxType: "19", amountNet: 100, amountTax: 19 },
|
||
{ taxType: "7", amountNet: 100, amountTax: 7 },
|
||
],
|
||
})
|
||
assert.deepEqual(gross, { "19": -119, "7": -107, "0": 0 })
|
||
|
||
const split = splitCashDiscount(-4.52, gross, defaults.received)
|
||
assert.deepEqual(split, [
|
||
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||
])
|
||
})
|
||
|
||
test("assigns rounding differences to the largest share", () => {
|
||
const split = splitCashDiscount(1, { "19": 1, "7": 1, "0": 1 }, defaults.granted)
|
||
assert.equal(split.reduce((sum, item) => sum + item.gross, 0).toFixed(2), "1.00")
|
||
assert.equal(split.length, 3)
|
||
})
|
||
|
||
test("rejects discounts on intra-community acquisitions", () => {
|
||
assert.throws(
|
||
() => getIncomingInvoiceGrossByRate({ accounts: [{ taxType: "19I", amountNet: 100, amountTax: 0 }] }),
|
||
/manuell buchen/,
|
||
)
|
||
})
|
||
|
||
test("final invoices spread discounts over the remaining claim after advance invoices", () => {
|
||
const advance = { id: 7, rows: [{ advanceInvoiceData: true, price: 1000, taxPercent: 19 }] }
|
||
const final = {
|
||
id: 8,
|
||
usedAdvanceInvoices: [7],
|
||
rows: [
|
||
{ mode: "normal", quantity: 1, price: 2000, discountPercent: 0, taxPercent: 19 },
|
||
{ mode: "normal", quantity: 1, price: 1000, discountPercent: 0, taxPercent: 7 },
|
||
],
|
||
}
|
||
assert.deepEqual(getCreatedDocumentGrossByRate(final, [advance]), { "19": 1190, "7": 1070, "0": 0 })
|
||
assert.equal(getCreatedDocumentGrossTotal(final, [advance]), 2260)
|
||
})
|
||
|
||
test("tax exempt small business invoices use the account without tax", () => {
|
||
const gross = getCreatedDocumentGrossByRate({
|
||
taxType: "19 UStG",
|
||
rows: [{ mode: "normal", quantity: 1, price: 100, discountPercent: 0, taxPercent: 19 }],
|
||
})
|
||
assert.deepEqual(gross, { "19": 0, "7": 0, "0": 100 })
|
||
assert.deepEqual(splitCashDiscount(2, gross, defaults.granted), [{ rate: 0, account: "8730", gross: 2, net: 2, tax: 0 }])
|
||
})
|
||
|
||
test("validates payment plus discount against the open amount", () => {
|
||
assert.doesNotThrow(() => validateCashDiscount({ amount: 98, discount: 2, total: 100, settled: 0 }))
|
||
assert.doesNotThrow(() => validateCashDiscount({ amount: -98, discount: -2, total: -100, settled: 0 }))
|
||
assert.throws(() => validateCashDiscount({ amount: 99, discount: 2, total: 100, settled: 0 }), /offenen Betrag/)
|
||
assert.throws(() => validateCashDiscount({ amount: 50, discount: 2, total: 100, settled: 50 }), /offenen Betrag/)
|
||
assert.throws(() => validateCashDiscount({ amount: 80, discount: 20, total: 100, settled: 0 }), /höher als 10 %/)
|
||
assert.throws(() => validateCashDiscount({ amount: -98, discount: -2, total: 100, settled: 0 }), /Richtung/)
|
||
})
|
||
|
||
test("normalizes configured discount accounts and keeps SKR03 defaults", () => {
|
||
const input = normalizeCashDiscountAccountsInput({ received: { "19": " 3737 ", "7": "" } })
|
||
assert.deepEqual(input, { received: { "19": "3737" } })
|
||
assert.equal(resolveCashDiscountAccounts(input).received["19"], "3737")
|
||
assert.equal(resolveCashDiscountAccounts(input).received["7"], "3731")
|
||
assert.throws(() => normalizeCashDiscountAccountsInput({ granted: { "19": "87A6" } }), /Ziffern/)
|
||
})
|
||
|
||
test("exports discount bookings per tax rate against the business partner", () => {
|
||
const lines = getCashDiscountBookingLines({
|
||
bankstatement: { id: 42, date: "2026-10-05" },
|
||
incominginvoice: { reference: "RE-1", vendor: { vendorNumber: "70001", name: "Lieferant" } },
|
||
cashDiscountSplit: [
|
||
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||
],
|
||
}).map(line => line.split(";"))
|
||
|
||
assert.equal(lines.length, 2)
|
||
assert.equal(lines[0].length, 124)
|
||
assert.deepEqual([lines[0][0], lines[0][1], lines[0][6], lines[0][7], lines[0][9], lines[0][10]],
|
||
["2,38", '"H"', "3736", "70001", "0510", '"RE-1"'])
|
||
|
||
const [granted] = getCashDiscountBookingLines({
|
||
bankstatement: { id: 43, date: "2026-10-05" },
|
||
createddocument: { documentNumber: "RE-100", customer: { customerNumber: "10001", name: "Kunde" } },
|
||
cashDiscountSplit: [{ rate: 19, account: "8736", gross: 2.38, net: 2, tax: 0.38 }],
|
||
}).map(line => line.split(";"))
|
||
assert.deepEqual([granted[0], granted[1], granted[6], granted[7]], ["2,38", '"S"', "8736", "10001"])
|
||
})
|
||
|
||
test("MCP allocation input accepts cash discounts only with bank statements", () => {
|
||
const payload = prepareStatementAllocationInput({ bankstatement: 1, createddocument: 2, amount: 98, cashDiscountAmount: 2 })
|
||
assert.equal(payload.cashDiscountAmount, 2)
|
||
assert.throws(() => prepareStatementAllocationInput({
|
||
amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2,
|
||
}), /Bankzahlung/)
|
||
})
|
||
|
||
test("builds cash discount terms and the PDF sentence", async () => {
|
||
const { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext, parseCashDiscountPaymentTerms } = await import("../src/modules/cash-discount")
|
||
const terms = buildCashDiscountTerms({ percent: 2, days: 10, documentDate: "2026-11-04", amount: 1190 })
|
||
assert.deepEqual(terms, { percent: 2, days: 10, dueDate: "2026-11-14", discount: 23.8, payable: 1166.2 })
|
||
assert.equal(formatCashDiscountText(terms).replace(/ /g, " "),
|
||
"Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.")
|
||
assert.equal(getCashDiscountTemplateContext(terms).skonto_datum, "14.11.2026")
|
||
assert.match(appendCashDiscountText("Vielen Dank.", "Vielen Dank.", terms), /^Bei Zahlung .*\n\nVielen Dank\.$/)
|
||
assert.equal(appendCashDiscountText("Eigener Text", "{{skonto_hinweis}}", terms), "Eigener Text")
|
||
assert.equal(buildCashDiscountTerms({ percent: null, days: 10, documentDate: "2026-11-04", amount: 100 }), null)
|
||
|
||
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=7#PROZENT=3.00#\n#SKONTO#TAGE=14#PROZENT=2.00#"), { percent: 3, days: 7 })
|
||
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=14#PROZENT=2,5#BASISBETRAG=100.00#"), { percent: 2.5, days: 14 })
|
||
assert.equal(parseCashDiscountPaymentTerms("Zahlbar ohne Abzug"), null)
|
||
})
|
||
|
||
test("maps explicit AI cash discount data onto incoming invoices", async () => {
|
||
const { mapGptDataToIncomingInvoice } = await import("../src/modules/cron/prepareIncomingInvoices")
|
||
const mapped = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 2, cash_discount_days: 10 }, 1)
|
||
assert.equal(mapped.cashDiscountPercent, 2)
|
||
assert.equal(mapped.cashDiscountDueDate, "2026-11-11")
|
||
const ignored = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 25 }, 1)
|
||
assert.equal(ignored.cashDiscountPercent, undefined)
|
||
})
|