feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt: 1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer Ausnahmen, die sich nicht aus der Kategorie ergeben. 2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS. 3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert. Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt. Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0 bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem leeren Feld eine 0 gemacht. Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen exakt den Rechnungsbetrag ergibt. Der Satz wandert von der Rechnung an die BESTELLPOSITION (OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis: wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte entstand in der vorigen Migration und wurde nie in Betrieb beschrieben. VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der sicherere ist — als Lebensmittel waeren 7 richtig, als Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen vermerkt. Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand" gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die Saetze ohne globalen Zustand testen lassen, und greift auf die Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7c59283ede
commit
625b73a8c8
20 files changed
+518
-70
No files matched your search
+25
-20
@@ -2,7 +2,7 @@ import PDFDocument from "pdfkit";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { splitGross } from "@/lib/vat";
|
||||
import { invoiceVatBreakdown } from "@/lib/vat";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
interface InvoicePdfInput {
|
||||
@@ -10,13 +10,6 @@ interface InvoicePdfInput {
|
||||
invoiceNumber: string;
|
||||
issuedAt: Date;
|
||||
totalCents: number;
|
||||
/**
|
||||
* Umsatzsteuersatz in ganzen Prozent, wie er an der Rechnung
|
||||
* festgeschrieben wurde. 0 = Kleinunternehmerregelung (§ 19 UStG), null =
|
||||
* Rechnung aus der Zeit vor der Steuerausweisung — dann bleibt der Block
|
||||
* weg, weil rueckwirkend nicht feststellbar ist, welcher Satz gegolten hat.
|
||||
*/
|
||||
vatRatePercent?: number | null;
|
||||
order: {
|
||||
orderNumber: string;
|
||||
firstName: string;
|
||||
@@ -29,7 +22,15 @@ interface InvoicePdfInput {
|
||||
shippingCents: number;
|
||||
discountCents: number;
|
||||
discountCode?: { code: string } | null;
|
||||
items: { productName: string; weightGrams: number; quantity: number; unitPriceCents: number; totalCents: number }[];
|
||||
items: {
|
||||
productName: string;
|
||||
weightGrams: number;
|
||||
quantity: number;
|
||||
unitPriceCents: number;
|
||||
totalCents: number;
|
||||
/** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */
|
||||
vatRatePercent: number | null;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,18 +125,21 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
|
||||
.fillColor("#242424")
|
||||
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
|
||||
}
|
||||
// Steuerausweis. Der Satz steht an der Rechnung selbst (nicht in der
|
||||
// Umgebung), damit ein alter Beleg auch nach einem Wechsel des
|
||||
// Steuerstatus unveraendert bleibt. Null = Rechnung aus der Zeit vor der
|
||||
// Steuerausweisung: dann bleibt der Block weg wie bisher.
|
||||
if (input.vatRatePercent != null && input.vatRatePercent > 0) {
|
||||
const { netCents, vatCents } = splitGross(input.totalCents, input.vatRatePercent);
|
||||
// Steuerausweis JE SATZ: eine Bestellung kann Honig (ermaessigt) und eine
|
||||
// Bienenwachskerze (voll) zugleich enthalten. Die Saetze stehen an den
|
||||
// Positionen, nicht in der Umgebung — ein alter Beleg bleibt damit auch
|
||||
// nach einer Umkategorisierung unveraendert. Eine leere Aufstellung heisst:
|
||||
// Bestellung aus der Zeit vor der Steuerausweisung, dann bleibt der Block
|
||||
// weg wie bisher.
|
||||
const breakdown = invoiceVatBreakdown(input.order);
|
||||
for (const line of breakdown) {
|
||||
if (line.ratePercent <= 0) continue;
|
||||
y += 16;
|
||||
doc.fillColor("#666666").text(t.netLabel, 350, y);
|
||||
doc.fillColor("#242424").text(formatPrice(netCents, input.order.currency), columns.total, y);
|
||||
doc.fillColor("#242424").text(formatPrice(line.netCents, input.order.currency), columns.total, y);
|
||||
y += 16;
|
||||
doc.fillColor("#666666").text(t.vatLabel(input.vatRatePercent), 350, y);
|
||||
doc.fillColor("#242424").text(formatPrice(vatCents, input.order.currency), columns.total, y);
|
||||
doc.fillColor("#666666").text(t.vatLabel(line.ratePercent), 350, y);
|
||||
doc.fillColor("#242424").text(formatPrice(line.vatCents, input.order.currency), columns.total, y);
|
||||
}
|
||||
|
||||
y += 20;
|
||||
@@ -143,8 +147,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
|
||||
doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y);
|
||||
|
||||
// Der Hinweis nach § 19 UStG ist bei Kleinunternehmerregelung eine
|
||||
// Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich.
|
||||
if (input.vatRatePercent === 0) {
|
||||
// Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich,
|
||||
// und nur, wenn die GESAMTE Rechnung steuerfrei ist.
|
||||
if (breakdown.length > 0 && breakdown.every((line) => line.ratePercent === 0)) {
|
||||
y += 22;
|
||||
doc.fontSize(9).fillColor("#666666").text(t.smallBusinessNote, 350, y, { width: 195 });
|
||||
}
|
||||
|
||||
@@ -55,6 +55,22 @@ function envNamesUsedInCode(dir: string): Set<string> {
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel, die die App zwar liest, aber nicht ueber ein woertliches
|
||||
* `process.env.NAME` — deshalb findet sie die Suche unten nicht.
|
||||
*
|
||||
* lib/vat.ts bekommt die Umgebung als Parameter uebergeben, damit sich die
|
||||
* Steuersaetze ohne globalen Zustand testen lassen, und greift auf die
|
||||
* Kategorie-Saetze ueber einen berechneten Schluessel zu. Beides ist Absicht;
|
||||
* eine Regex kann es nicht sehen.
|
||||
*/
|
||||
const READ_INDIRECTLY: Record<string, string> = {
|
||||
VAT_RATE_PERCENT: "lib/vat.ts — als Parameter uebergebene Umgebung (vatRateForCategory).",
|
||||
VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
||||
VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
||||
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie
|
||||
* liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst
|
||||
@@ -98,7 +114,7 @@ describe("Kubernetes-Manifeste und Code", () => {
|
||||
|
||||
// Act
|
||||
const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]
|
||||
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP))
|
||||
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP) && !(name in READ_INDIRECTLY))
|
||||
.sort();
|
||||
|
||||
// Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus,
|
||||
|
||||
@@ -109,6 +109,23 @@ export const adminProductNumbersSchema = z.object({
|
||||
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
|
||||
pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."),
|
||||
quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."),
|
||||
/**
|
||||
* Umsatzsteuersatz als Ausnahme zur Kategorie. Leeres Feld = null = "Satz
|
||||
* der Kategorie" (der Normalfall, siehe lib/vat.ts) — deshalb ein eigener
|
||||
* Zweig fuer den Leerstring: z.coerce.number() machte daraus sonst eine 0,
|
||||
* und 0 bedeutet hier "steuerfrei nach § 19 UStG", also etwas voellig
|
||||
* anderes als "nicht gesetzt".
|
||||
*/
|
||||
vatRatePercent: z
|
||||
.union([
|
||||
z.literal("").transform(() => null),
|
||||
z.coerce
|
||||
.number()
|
||||
.int("Der Steuersatz muss eine ganze Zahl sein.")
|
||||
.min(0, "Der Steuersatz darf nicht negativ sein.")
|
||||
.max(100, "Der Steuersatz darf höchstens 100 betragen."),
|
||||
])
|
||||
.default(null),
|
||||
});
|
||||
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
|
||||
|
||||
|
||||
+135
-1
@@ -1,5 +1,11 @@
|
||||
import { describe, test, expect, vi, afterEach } from "vitest";
|
||||
import { splitGross, vatRatePercent, DEFAULT_VAT_RATE_PERCENT } from "./vat";
|
||||
import {
|
||||
splitGross,
|
||||
vatRatePercent,
|
||||
vatRateForCategory,
|
||||
vatBreakdown,
|
||||
DEFAULT_VAT_RATE_PERCENT,
|
||||
} from "./vat";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -91,3 +97,131 @@ describe("splitGross", () => {
|
||||
expect(splitGross(0, 19)).toEqual({ netCents: 0, vatCents: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("vatRateForCategory", () => {
|
||||
test("nimmt den Satz der jeweiligen Kategorie", () => {
|
||||
// Arrange
|
||||
const env = {
|
||||
VAT_RATE_PERCENT_HONIG: "7",
|
||||
VAT_RATE_PERCENT_KERZEN: "19",
|
||||
VAT_RATE_PERCENT_PROPOLIS: "19",
|
||||
};
|
||||
|
||||
// Act & Assert: Honig ist ein Lebensmittel und ermaessigt besteuert, eine
|
||||
// Bienenwachskerze nicht.
|
||||
expect(vatRateForCategory("HONIG", env)).toBe(7);
|
||||
expect(vatRateForCategory("KERZEN", env)).toBe(19);
|
||||
expect(vatRateForCategory("PROPOLIS", env)).toBe(19);
|
||||
});
|
||||
|
||||
test("faellt auf den gemeinsamen Satz zurueck, wenn die Kategorie keinen eigenen hat", () => {
|
||||
// Arrange & Act & Assert: eine bestehende Konfiguration mit nur
|
||||
// VAT_RATE_PERCENT bleibt dadurch gueltig.
|
||||
expect(vatRateForCategory("KERZEN", { VAT_RATE_PERCENT: "19" })).toBe(19);
|
||||
expect(vatRateForCategory("HONIG", { VAT_RATE_PERCENT: "7" })).toBe(7);
|
||||
});
|
||||
|
||||
test("behandelt eine unbekannte Kategorie wie eine ohne eigenen Satz", () => {
|
||||
// Arrange & Act & Assert: eine spaeter ergaenzte Kategorie soll den
|
||||
// gemeinsamen Wert erben, statt geraten zu werden.
|
||||
expect(vatRateForCategory("SEIFE", { VAT_RATE_PERCENT: "19" })).toBe(19);
|
||||
});
|
||||
|
||||
test("greift ohne jede Angabe auf den ermaessigten Satz zurueck", () => {
|
||||
expect(vatRateForCategory("HONIG", {})).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vatBreakdown", () => {
|
||||
test("weist bei nur einem Satz eine einzige Zeile aus", () => {
|
||||
// Arrange & Act
|
||||
const lines = vatBreakdown({
|
||||
items: [{ totalCents: 1000, vatRatePercent: 7 }],
|
||||
shippingCents: 70,
|
||||
discountCents: 0,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(lines).toEqual([{ ratePercent: 7, grossCents: 1070, netCents: 1000, vatCents: 70 }]);
|
||||
});
|
||||
|
||||
test("trennt Honig und Kerze nach Saetzen", () => {
|
||||
// Arrange: ein Glas Honig und eine Kerze im selben Paket.
|
||||
const lines = vatBreakdown({
|
||||
items: [
|
||||
{ totalCents: 1070, vatRatePercent: 7 },
|
||||
{ totalCents: 1190, vatRatePercent: 19 },
|
||||
],
|
||||
shippingCents: 0,
|
||||
discountCents: 0,
|
||||
});
|
||||
|
||||
// Act & Assert: je Satz eine eigene Zeile, kein Mischsatz.
|
||||
expect(lines).toEqual([
|
||||
{ ratePercent: 7, grossCents: 1070, netCents: 1000, vatCents: 70 },
|
||||
{ ratePercent: 19, grossCents: 1190, netCents: 1000, vatCents: 190 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("verteilt Versand und Rabatt im Verhaeltnis der Warenwerte", () => {
|
||||
// Arrange: 3/4 Honig, 1/4 Kerze — der Versand teilt sich genauso auf,
|
||||
// denn als Nebenleistung folgt er der jeweiligen Hauptleistung.
|
||||
const lines = vatBreakdown({
|
||||
items: [
|
||||
{ totalCents: 3000, vatRatePercent: 7 },
|
||||
{ totalCents: 1000, vatRatePercent: 19 },
|
||||
],
|
||||
shippingCents: 400,
|
||||
discountCents: 0,
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
expect(lines[0].grossCents).toBe(3300);
|
||||
expect(lines[1].grossCents).toBe(1100);
|
||||
});
|
||||
|
||||
test("die Summe der Zeilen ergibt exakt den Rechnungsbetrag", () => {
|
||||
// Arrange: krumme Betraege, bei denen eine naive Rundung Cents
|
||||
// verschwinden oder entstehen liesse.
|
||||
const items = [
|
||||
{ totalCents: 1333, vatRatePercent: 7 },
|
||||
{ totalCents: 777, vatRatePercent: 19 },
|
||||
{ totalCents: 99, vatRatePercent: 0 },
|
||||
];
|
||||
const shippingCents = 495;
|
||||
const discountCents = 317;
|
||||
|
||||
// Act
|
||||
const lines = vatBreakdown({ items, shippingCents, discountCents });
|
||||
|
||||
// Assert
|
||||
const expectedTotal =
|
||||
items.reduce((sum, item) => sum + item.totalCents, 0) + shippingCents - discountCents;
|
||||
expect(lines.reduce((sum, line) => sum + line.grossCents, 0)).toBe(expectedTotal);
|
||||
for (const line of lines) {
|
||||
expect(line.netCents + line.vatCents).toBe(line.grossCents);
|
||||
}
|
||||
});
|
||||
|
||||
test("fasst mehrere Positionen desselben Satzes zusammen", () => {
|
||||
// Arrange & Act
|
||||
const lines = vatBreakdown({
|
||||
items: [
|
||||
{ totalCents: 500, vatRatePercent: 7 },
|
||||
{ totalCents: 570, vatRatePercent: 7 },
|
||||
],
|
||||
shippingCents: 0,
|
||||
discountCents: 0,
|
||||
});
|
||||
|
||||
// Assert: eine Rechnung listet je Satz eine Zeile, nicht je Position.
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].grossCents).toBe(1070);
|
||||
});
|
||||
|
||||
test("liefert ohne Positionen keine Aufstellung", () => {
|
||||
// Arrange & Act & Assert: betrifft Rechnungen aus der Zeit vor der
|
||||
// Steuerausweisung — dort bleibt der Block weg statt falsch zu rechnen.
|
||||
expect(vatBreakdown({ items: [], shippingCents: 500, discountCents: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
+170
@@ -81,3 +81,173 @@ export function splitGross(grossCents: number, ratePercent: number): VatSplit {
|
||||
const netCents = Math.round(grossCents / (1 + ratePercent / 100));
|
||||
return { netCents, vatCents: grossCents - netCents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Satz je Produktkategorie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Umgebungsvariable je Kategorie. Der Steuersatz haengt in Deutschland an der
|
||||
* Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermaessigt
|
||||
* besteuert (Anlage 2 zum UStG), eine Bienenwachskerze ist es nicht.
|
||||
*
|
||||
* Fehlt der kategoriespezifische Wert, gilt VAT_RATE_PERCENT als gemeinsamer
|
||||
* Rueckfallwert — so bleibt eine bestehende Konfiguration mit nur diesem
|
||||
* einen Wert weiterhin gueltig.
|
||||
*/
|
||||
const CATEGORY_ENV_NAMES = {
|
||||
HONIG: "VAT_RATE_PERCENT_HONIG",
|
||||
KERZEN: "VAT_RATE_PERCENT_KERZEN",
|
||||
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
|
||||
} as const;
|
||||
|
||||
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;
|
||||
|
||||
export function isVatCategory(value: string): value is VatCategory {
|
||||
return value in CATEGORY_ENV_NAMES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steuersatz fuer eine Produktkategorie.
|
||||
*
|
||||
* Reihenfolge: kategoriespezifischer Wert, sonst der gemeinsame
|
||||
* VAT_RATE_PERCENT, sonst der ermaessigte Satz. Eine unbekannte Kategorie
|
||||
* (etwa eine spaeter ergaenzte) faellt ebenfalls auf den gemeinsamen Wert
|
||||
* zurueck, statt zu raten.
|
||||
*/
|
||||
export function vatRateForCategory(
|
||||
category: string,
|
||||
env: Record<string, string | undefined>
|
||||
): number {
|
||||
const specific = isVatCategory(category) ? env[CATEGORY_ENV_NAMES[category]] : undefined;
|
||||
return vatRatePercent(specific?.trim() ? specific : env.VAT_RATE_PERCENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steuersatz eines konkreten Produkts.
|
||||
*
|
||||
* Ein am Produkt gesetzter Wert gewinnt immer — er ist die Ausnahme fuer
|
||||
* Artikel, die sich nicht aus ihrer Kategorie ergeben. Ist dort nichts
|
||||
* hinterlegt (der Normalfall), gilt der Satz der Kategorie; eine Aenderung
|
||||
* der gesetzlichen Saetze wirkt dann ueber die Konfiguration auf alle
|
||||
* betroffenen Produkte zugleich, statt in jedem Datensatz nachgepflegt
|
||||
* werden zu muessen.
|
||||
*
|
||||
* Ein unsinniger Wert am Produkt (ausserhalb 0 bis 100) wird ignoriert und
|
||||
* faellt auf die Kategorie zurueck, statt eine Bestellung scheitern zu
|
||||
* lassen — dieselbe Haltung wie bei der Umgebungsvariablen.
|
||||
*/
|
||||
export function vatRateForProduct(
|
||||
product: { category: string; vatRatePercent?: number | null },
|
||||
env: Record<string, string | undefined>
|
||||
): number {
|
||||
const own = product.vatRatePercent;
|
||||
if (own != null && Number.isInteger(own) && own >= 0 && own <= 100) return own;
|
||||
return vatRateForCategory(product.category, env);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steueraufstellung einer Rechnung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VatBreakdownLine extends VatSplit {
|
||||
ratePercent: number;
|
||||
grossCents: number;
|
||||
}
|
||||
|
||||
export interface VatBreakdownInput {
|
||||
/** Bestellpositionen mit ihrem bei der Bestellung festgehaltenen Satz. */
|
||||
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number }>;
|
||||
shippingCents: number;
|
||||
discountCents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verteilt einen Betrag im Verhaeltnis der Gewichte, ohne dass dabei Cents
|
||||
* verschwinden oder entstehen.
|
||||
*
|
||||
* Nach dem Abrunden jedes Anteils bleibt ein Rest von wenigen Cent uebrig;
|
||||
* der geht an die Anteile mit dem groessten abgeschnittenen Nachkommaanteil
|
||||
* (Hare-Niemeyer). Eine blosse Rundung je Anteil koennte in der Summe daneben
|
||||
* liegen — bei Geldbetraegen auf einer Rechnung ist das nicht hinnehmbar.
|
||||
*/
|
||||
function distributeProportionally(amountCents: number, weights: readonly number[]): number[] {
|
||||
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
if (totalWeight <= 0 || amountCents === 0) return weights.map(() => 0);
|
||||
|
||||
const exact = weights.map((weight) => (amountCents * weight) / totalWeight);
|
||||
const shares = exact.map((value) => Math.floor(value));
|
||||
let remainder = amountCents - shares.reduce((sum, share) => sum + share, 0);
|
||||
|
||||
const byLargestFraction = exact
|
||||
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
||||
.sort((a, b) => b.fraction - a.fraction);
|
||||
|
||||
for (const { index } of byLargestFraction) {
|
||||
if (remainder <= 0) break;
|
||||
shares[index] += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt die Umsatzsteuer einer Rechnung je Satz auf.
|
||||
*
|
||||
* Eine Bestellung kann Waren mit verschiedenen Saetzen enthalten — ein Glas
|
||||
* Honig (7%) und eine Bienenwachskerze (19%) im selben Paket. Die Rechnung
|
||||
* muss dann JE SATZ Netto und Steuer ausweisen, nicht einen Mischsatz.
|
||||
*
|
||||
* Versand und Rabatt werden im Verhaeltnis der Warenwerte auf die Saetze
|
||||
* verteilt: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das
|
||||
* Schicksal der Hauptleistung — gibt es mehrere Hauptleistungen, teilt er
|
||||
* sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze
|
||||
* im selben Verhaeltnis.
|
||||
*
|
||||
* Zugesichert: die Summe aller grossCents ergibt exakt den Rechnungsbetrag,
|
||||
* und je Zeile gilt netCents + vatCents === grossCents.
|
||||
*/
|
||||
export function vatBreakdown(input: VatBreakdownInput): VatBreakdownLine[] {
|
||||
const grossByRate = new Map<number, number>();
|
||||
for (const item of input.items) {
|
||||
grossByRate.set(item.vatRatePercent, (grossByRate.get(item.vatRatePercent) ?? 0) + item.totalCents);
|
||||
}
|
||||
if (grossByRate.size === 0) return [];
|
||||
|
||||
const rates = [...grossByRate.keys()].sort((a, b) => a - b);
|
||||
const itemGross = rates.map((rate) => grossByRate.get(rate) ?? 0);
|
||||
const shipping = distributeProportionally(input.shippingCents, itemGross);
|
||||
const discount = distributeProportionally(input.discountCents, itemGross);
|
||||
|
||||
return rates.map((ratePercent, index) => {
|
||||
const grossCents = itemGross[index] + shipping[index] - discount[index];
|
||||
return { ratePercent, grossCents, ...splitGross(grossCents, ratePercent) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Steueraufstellung einer Bestellung, wie sie Rechnungsansicht und PDF
|
||||
* anzeigen. Gemeinsame Stelle fuer beide, damit dieselbe Rechnung nicht in
|
||||
* zwei Darstellungen unterschiedlich gerechnet wird.
|
||||
*
|
||||
* Fehlt auch nur an EINER Position der Satz, bleibt die Aufstellung leer.
|
||||
* Ein teilweiser Ausweis waere schlimmer als gar keiner: die Summe der
|
||||
* Steuerzeilen ergaebe dann nicht den Rechnungsbetrag. Der Fall betrifft
|
||||
* Bestellungen aus der Zeit vor der Steuerausweisung.
|
||||
*/
|
||||
export function invoiceVatBreakdown(order: {
|
||||
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
|
||||
shippingCents: number;
|
||||
discountCents: number;
|
||||
}): VatBreakdownLine[] {
|
||||
const items: { totalCents: number; vatRatePercent: number }[] = [];
|
||||
for (const item of order.items) {
|
||||
if (item.vatRatePercent == null) return [];
|
||||
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
|
||||
}
|
||||
return vatBreakdown({
|
||||
items,
|
||||
shippingCents: order.shippingCents,
|
||||
discountCents: order.discountCents,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user