Add Kleinunternehmerregelung (§19 UStG) support
Validate e-invoices / mustang (push) Successful in 43s
Validate e-invoices / mustang (push) Successful in 43s
InvoiceOrder/CorrectionInvoiceOrder gain an optional order-level kleinunternehmer flag, snapshotted per order (same pattern as vatExempt) so a later toggle of the tenant's setting never rewrites an already-issued invoice. Takes precedence over vatExempt. - Visual PDF: "enthält X% MwSt." becomes the §19 UStG notice. - E-invoice XML: new UNTDID 5305 category "E" with a free-text exemption reason (no VATEX code — §19 UStG has none, BR-E-10 allows text alone). - New Mustang CI fixture + buildEInvoiceData unit test for the "E" path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ One canonical implementation, consumed by both repos, makes this class of drift
|
||||
|
||||
**2026-07-23, Netto row.** A dedicated "Netto" row (Gesamt minus every rate's own tax, summed) now sits between Gesamt and the "enthält X% MwSt." annotation, on every invoice type this package renders — original, Storno, and Gutschrift alike. First version skipped it on `vatExempt` orders (net and Gesamt are the same figure there, so it looked redundant) — corrected same day: it's shown unconditionally, for layout consistency across every invoice, not gated on the tax rate.
|
||||
|
||||
**2026-07-24, Kleinunternehmerregelung (§19 UStG).** `InvoiceOrder`/`CorrectionInvoiceOrder` gained optional `kleinunternehmer`, a third order-level VAT treatment alongside `vatExempt` — see "Kleinunternehmerregelung" below. Takes precedence over `vatExempt` wherever both would otherwise apply (a Kleinunternehmer never charges VAT to begin with, so there's nothing left to "exempt" via the separate intra-community rule).
|
||||
|
||||
## How this is consumed
|
||||
|
||||
Not published to npm — installed as a git dependency:
|
||||
@@ -55,10 +57,10 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra
|
||||
- **`einvoice/buildEInvoiceData.ts`** — maps `InvoiceOrder`/`CorrectionInvoiceOrder` + `InvoiceSeller` into the raw UBL-shaped `Invoice` object `@e-invoice-eu/core` expects (the library converts UBL → CII internally for Factur-X output — this package only ever builds the UBL shape, regardless of target format). Reuses `computeTaxBreakdown()` for the per-rate VAT grouping, same as the visual PDFs — one tax-math implementation feeding both the human-readable and machine-readable side of the same document.
|
||||
- Every EN16931 amount field turned out, at runtime (via the library's own ajv JSON-schema validation — **not visible in its TypeScript types at all**), to require a sibling `*@currencyID` key the moment the amount itself is present, and every quantity a `*@unitCode`. `amt()`/`qty()` return both keys at once via object spread so a call site can't add one without the other — found by actually running a sample invoice through `generate()` and reading the ajv errors, not from the library's own docs.
|
||||
- Original invoice: `InvoiceTypeCode` `380` ("Commercial invoice"). Correction invoice: `381` ("Credit note") — this library has no separate credit-note type, same `Invoice` shape either way, just the type code — with a `cac:BillingReference` pointing back at the original invoice number. Amounts stay positive either way (the credited amount, not a negative number) — EN16931/UBL convention puts the polarity in the type code, not the sign; the PDF's own visual "-{amount}" is a *display* convention layered on top (`correctionInvoicePdf.tsx`'s own `groupByTaxRate()`), not something this XML mapper re-derives independently.
|
||||
- VAT category is `S` ("Standard rated") for any positive-rate order — 19% and 7% both use `S`, with the actual percentage in `cbc:Percent`. When `order.vatExempt` is set (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG), category `K` ("VAT exempt for EEA intra-community supply of goods and services") is used instead, with a `VATEX-EU-IC` exemption reason (BT-120/BT-121) — but **only** on `cac:TaxTotal`'s own `TaxSubtotal.TaxCategory`, never on an `InvoiceLine`'s `ClassifiedTaxCategory` or an `AllowanceCharge`'s own `TaxCategory` — `@e-invoice-eu/core`'s generated ajv schema rejects `TaxExemptionReasonCode`/`TaxExemptionReason` as "additional properties" on those two despite them being conceptually the same UBL `TaxCategory` complex type. Caught locally (a full PDF/A-3 render + hand-inflated CII XML inspection) before ever reaching Mustang. This shop has no domestic reverse-charge (`AE`) sales — only the one cross-border-EU exemption case exists.
|
||||
- VAT category is `S` ("Standard rated") for any positive-rate order — 19% and 7% both use `S`, with the actual percentage in `cbc:Percent`. When `order.vatExempt` is set (innergemeinschaftliche Lieferung, §4 Nr. 1b UStG), category `K` ("VAT exempt for EEA intra-community supply of goods and services") is used instead, with a `VATEX-EU-IC` exemption reason (BT-120/BT-121). When `order.kleinunternehmer` is set (§19 UStG, takes precedence over `vatExempt`), category `E` ("Exempt from tax") is used with a free-text exemption reason only — §19 UStG is a national provision with no EU-wide VATEX code, and BR-E-10 accepts the reason text alone without a code. Both `K`/`E` fields live — but **only** on `cac:TaxTotal`'s own `TaxSubtotal.TaxCategory`, never on an `InvoiceLine`'s `ClassifiedTaxCategory` or an `AllowanceCharge`'s own `TaxCategory` — `@e-invoice-eu/core`'s generated ajv schema rejects `TaxExemptionReasonCode`/`TaxExemptionReason` as "additional properties" on those two despite them being conceptually the same UBL `TaxCategory` complex type. Caught locally (a full PDF/A-3 render + hand-inflated CII XML inspection) before ever reaching Mustang. This shop has no domestic reverse-charge (`AE`) sales — only these two exemption cases exist, and they're mutually exclusive (see `VatMode` in `buildEInvoiceData.ts`).
|
||||
- Payment means: included whenever `seller.iban` is set (matching the visual PDF footer's own "always show it" behavior since Phase 2), with a `PaymentMeansCode` mapped from the order's actual `paymentMethodTitle` (`Überweisung` → `30` credit transfer, `Kreditkarte` → `48`, `PayPal` → `68`, anything unrecognized → `1` "Instrument not defined" — a payment method added in Payload doesn't need a matching code deploy here to keep e-invoice generation working). **Known gotcha, not yet fixed**: when `seller.iban` is unset, `cac:PaymentMeans` is simply omitted (`undefined`) — but `@e-invoice-eu/core`'s UBL→CII conversion then throws (`Cannot read properties of undefined (reading 'length')` inside its own `format-cii.service.ts`), unrelated to anything in this package's own code. Doesn't affect this shop's production data (this tenant's `company-settings.iban` is always set), but would break e-invoice generation entirely for any future tenant/order whose seller has no IBAN configured — worth fixing (always pass an empty array rather than `undefined`?) before that ever happens.
|
||||
- **`einvoice/countryCode.ts`** — `sellerCountry`/`order.country` are free text ("Deutschland"), not an ISO-3166 select field, but EN16931 wants a fixed two-letter code. Small closed mapping (DACH region only, this shop's actual shipping footprint), falling back to `DE`.
|
||||
- **Library: `@e-invoice-eu/core`**, format `'Factur-X-EN16931'` (the ZUGFeRD "Comfort" profile, the minimum EN16931-compliant level). Verified by actually generating a sample invoice from `SAMPLE_INVOICE_ORDER` and inflating the embedded XML stream out of the resulting PDF/A-3 by hand (the library ships no attachment-reading API of its own to check this against) — confirmed correct `CrossIndustryInvoice` XML, EN16931 guideline reference, per-rate tax breakdown, and payment means, not just "it didn't throw." Same manual-inflation verification repeated for the `vatExempt` path (2026-07-23) — confirmed `CategoryCode>K`, `ExemptionReasonCode>VATEX-EU-IC`, `ExemptionReason>Innergemeinschaftliche Lieferung` all present in the actual embedded XML, not just the pre-conversion UBL JSON.
|
||||
- **Library: `@e-invoice-eu/core`**, format `'Factur-X-EN16931'` (the ZUGFeRD "Comfort" profile, the minimum EN16931-compliant level). Verified by actually generating a sample invoice from `SAMPLE_INVOICE_ORDER` and inflating the embedded XML stream out of the resulting PDF/A-3 by hand (the library ships no attachment-reading API of its own to check this against) — confirmed correct `CrossIndustryInvoice` XML, EN16931 guideline reference, per-rate tax breakdown, and payment means, not just "it didn't throw." Same manual-inflation verification repeated for the `vatExempt` path (2026-07-23) — confirmed `CategoryCode>K`, `ExemptionReasonCode>VATEX-EU-IC`, `ExemptionReason>Innergemeinschaftliche Lieferung` all present in the actual embedded XML, not just the pre-conversion UBL JSON. The `kleinunternehmer` path (2026-07-24) is covered by `__tests__/buildEInvoiceData.test.ts` (asserts the pre-conversion UBL `TaxCategory` shape: `E` + the §19 reason text, no code) and by its own Mustang CI fixture (`kleinunternehmer-invoice.pdf`, see "CI validation" below) — not yet manually re-verified against the hand-inflated CII XML the way `K` was; worth doing once this ships to a real Kleinunternehmer tenant.
|
||||
|
||||
## VAT exemption
|
||||
|
||||
@@ -69,11 +71,20 @@ Ships raw TypeScript/TSX source (no build step) via `main`/`types` pointing stra
|
||||
|
||||
See the frontend repo's own README ("VAT exemption" section) for the actual VIES lookup, de-grossing math, and checkout UI this feeds from, and the Payload backend's README ("B2B checkout & VAT exemption") for the persisted `Orders.vatExempt`/`vatIdValidatedAt` fields and audit-trail reasoning.
|
||||
|
||||
## Kleinunternehmerregelung (§19 UStG)
|
||||
|
||||
`InvoiceOrder.kleinunternehmer`/`CorrectionInvoiceOrder.kleinunternehmer` (optional, default falsy) — a **seller-level**, not order-level, business fact (whether this tenant is a small business under §19 UStG), but still snapshotted onto each order at checkout time (mirroring how `vatExempt` is already frozen there) rather than read live off the seller when an invoice renders. This matters for a reason `vatExempt` doesn't have to worry about: `vatExempt` is inherently decided per order (a live VIES lookup against that specific sale), so there's no "past" value to protect. `kleinunternehmer` is a standing tenant setting that can be toggled on/off at any time — without the snapshot, a tenant switching it later would rewrite the tax treatment of every already-issued invoice the next time it's re-rendered (e.g. a customer's "Rechnung erneut herunterladen"), which is both legally wrong (the treatment at the moment of sale is what counts) and silent. The consuming frontend's checkout is the only place this ever gets read live, from its own `company-settings.kleinunternehmer` — see that repo's README.
|
||||
|
||||
Unlike `vatExempt`, this package does **not** expect `unitPrice` to be de-grossed for a `kleinunternehmer` order — a Kleinunternehmer never charged VAT in the first place, so the catalog gross price and the actual net charge are the same figure; only `taxRatePercent` becomes `0` on every item. Display:
|
||||
|
||||
- Visual PDF (`invoicePdf.tsx`/`correctionInvoicePdf.tsx`): the "enthält X% MwSt." annotation under Gesamt becomes "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." instead — takes precedence over the `vatExempt` note if (implausibly) both were ever set.
|
||||
- E-invoice XML (`buildEInvoiceData.ts`): VAT category `E` + a free-text exemption reason, no VATEX code — see "E-invoicing" above.
|
||||
|
||||
## CI validation (Mustang)
|
||||
|
||||
Every push/PR runs `.gitea/workflows/validate-einvoice.yml` against git.mk360.de's own self-hosted Gitea Actions runner (`vps-runner`, registered on the same VPS as Gitea/Payload — see `/home/marco/dev/docker/docker-compose.yml`'s `act_runner` service):
|
||||
|
||||
1. `npm run fixtures:einvoice` (`scripts/generate-einvoice-fixtures.mts`) renders 3 PDFs into `.mustang-fixtures/` (gitignored, regenerated every run) — an original invoice with **two simultaneous VAT rates (19%+7%) plus a discount and shipping cost together** (the combination `SAMPLE_INVOICE_ORDER` doesn't cover), plus a Storno and a Gutschrift against the same order.
|
||||
2. [Mustang-CLI](https://www.mustangproject.org/commandline/) (the reference ZUGFeRD/Factur-X validator, `--action validateExpectValid -d .mustang-fixtures`) checks all 3 for EN16931 + PDF/A-3 conformance in one call. Non-zero exit fails the job. The jar is downloaded pinned to a specific release + sha256 (`core-2.24.0`) rather than a floating `latest` tag, right in the workflow — no Docker image for Mustang is actively maintained by the upstream project itself, so downloading the jar directly into a `setup-java` step was simpler and more trustworthy than depending on a third-party wrapper image.
|
||||
1. `npm run fixtures:einvoice` (`scripts/generate-einvoice-fixtures.mts`) renders 4 PDFs into `.mustang-fixtures/` (gitignored, regenerated every run) — an original invoice with **two simultaneous VAT rates (19%+7%) plus a discount and shipping cost together** (the combination `SAMPLE_INVOICE_ORDER` doesn't cover), a Storno and a Gutschrift against that same order, and a separate single-rate **Kleinunternehmer** original invoice (`kleinunternehmer-invoice.pdf`, every item at 0%, category `E`) — its own fixture rather than a variant of the first, since a real Kleinunternehmer order never has a positive catalog rate to begin with.
|
||||
2. [Mustang-CLI](https://www.mustangproject.org/commandline/) (the reference ZUGFeRD/Factur-X validator, `--action validateExpectValid -d .mustang-fixtures`) checks all 4 for EN16931 + PDF/A-3 conformance in one call. Non-zero exit fails the job. The jar is downloaded pinned to a specific release + sha256 (`core-2.24.0`) rather than a floating `latest` tag, right in the workflow — no Docker image for Mustang is actively maintained by the upstream project itself, so downloading the jar directly into a `setup-java` step was simpler and more trustworthy than depending on a third-party wrapper image.
|
||||
|
||||
Run the same check locally with `npm run fixtures:einvoice`, then point a locally-downloaded `Mustang-CLI-*.jar` at `.mustang-fixtures/` yourself — useful for iterating on `buildEInvoiceData.ts` without waiting on a CI round-trip.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@einfach-produktiv/invoicing",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Shared invoice / correction-invoice (Stornorechnung, Gutschrift) PDF generation and VAT-breakdown math, consumed as a git dependency by both the einfach-produktiv frontend and the payload backend — not published to npm.",
|
||||
"type": "module",
|
||||
|
||||
@@ -85,6 +85,35 @@ const CORRECTION_ORDER: CorrectionInvoiceOrder = {
|
||||
total: ORDER.total,
|
||||
};
|
||||
|
||||
// §19 UStG Kleinunternehmer — every item snapshotted at 0% (exactly what
|
||||
// api/checkout/route.ts forces when the tenant has company-settings.
|
||||
// kleinunternehmer set), UNTDID 5305 category "E" with a free-text
|
||||
// exemption reason instead of "S"/percent-rated lines. Own fixture rather
|
||||
// than a variant of ORDER above, since a real Kleinunternehmer order never
|
||||
// has a positive rate to begin with — mixing that into the 19%/7% fixture
|
||||
// would test a combination (kleinunternehmer + a nonzero catalog rate)
|
||||
// that can't actually happen in production.
|
||||
const KLEINUNTERNEHMER_ORDER: InvoiceOrder = {
|
||||
orderNumber: "#EP-MUSTANG-0002",
|
||||
invoiceNumber: "RE-MUSTANG-0002",
|
||||
invoiceIssuedAt: new Date().toISOString(),
|
||||
customerFirstName: "Erika",
|
||||
customerLastName: "Musterfrau",
|
||||
deliveryMethod: "address",
|
||||
street: "Kundenweg 2",
|
||||
zip: "80331",
|
||||
city: "München",
|
||||
country: "Deutschland",
|
||||
paymentMethodTitle: "Kreditkarte",
|
||||
kleinunternehmer: true,
|
||||
items: [{ productName: "ToDo-Karten – Set", quantity: 2, unitPrice: 12.9, taxRatePercent: 0, bundleContents: null }],
|
||||
subtotal: 25.8,
|
||||
shippingCost: 4.95,
|
||||
discountAmount: 0,
|
||||
discountCode: null,
|
||||
total: 30.75,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
@@ -100,7 +129,10 @@ async function main() {
|
||||
const gutschrift = await renderCorrectionInvoiceEInvoice("gutschrift", CORRECTION_ORDER, SELLER);
|
||||
await writeFile(new URL("gutschrift.pdf", OUT_DIR), gutschrift);
|
||||
|
||||
console.log(`Wrote 3 e-invoice fixtures to ${OUT_DIR.pathname}`);
|
||||
const kleinunternehmerInvoice = await renderInvoiceEInvoice(KLEINUNTERNEHMER_ORDER, SELLER);
|
||||
await writeFile(new URL("kleinunternehmer-invoice.pdf", OUT_DIR), kleinunternehmerInvoice);
|
||||
|
||||
console.log(`Wrote 4 e-invoice fixtures to ${OUT_DIR.pathname}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildEInvoiceData } from "../einvoice/buildEInvoiceData";
|
||||
import type { InvoiceOrder } from "../invoicePdf";
|
||||
import type { InvoiceSeller } from "../seller";
|
||||
|
||||
const seller: InvoiceSeller = {
|
||||
sellerName: "Test GmbH",
|
||||
sellerStreet: "Teststraße 1",
|
||||
sellerZip: "10115",
|
||||
sellerCity: "Berlin",
|
||||
sellerCountry: "Deutschland",
|
||||
sellerEmail: "rechnung@example.com",
|
||||
vatId: "DE123456789",
|
||||
taxRatePercent: 19,
|
||||
};
|
||||
|
||||
const order = (overrides: Partial<InvoiceOrder> = {}): InvoiceOrder => ({
|
||||
orderNumber: "#EP-0001",
|
||||
invoiceNumber: "RE-0001",
|
||||
invoiceIssuedAt: new Date().toISOString(),
|
||||
customerFirstName: "Max",
|
||||
customerLastName: "Mustermann",
|
||||
deliveryMethod: "address",
|
||||
street: "Musterweg 1",
|
||||
zip: "10115",
|
||||
city: "Berlin",
|
||||
country: "Deutschland",
|
||||
paymentMethodTitle: "Kreditkarte",
|
||||
items: [{ productName: "ToDo-Karten", quantity: 1, unitPrice: 12.9, taxRatePercent: 19, bundleContents: null }],
|
||||
subtotal: 12.9,
|
||||
shippingCost: 0,
|
||||
discountAmount: 0,
|
||||
discountCode: null,
|
||||
total: 12.9,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Reaches into the generated UBL shape rather than asserting against
|
||||
// @e-invoice-eu/core's own types (loosely typed as Record<string, unknown>
|
||||
// internally, see buildEInvoiceData.ts's own comments on why) — mirrors
|
||||
// how that file already treats this data, just from the test side.
|
||||
function taxCategory(invoice: ReturnType<typeof buildEInvoiceData>): Record<string, unknown> {
|
||||
const ubl = invoice["ubl:Invoice"] as unknown as Record<string, unknown>;
|
||||
const taxTotal = (ubl["cac:TaxTotal"] as unknown as Record<string, unknown>[])[0];
|
||||
const subtotal = (taxTotal["cac:TaxSubtotal"] as unknown as Record<string, unknown>[])[0];
|
||||
return subtotal["cac:TaxCategory"] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("buildEInvoiceData VAT category", () => {
|
||||
it("uses category S (standard rated) for a normal order", () => {
|
||||
const category = taxCategory(buildEInvoiceData(order(), seller));
|
||||
expect(category["cbc:ID"]).toBe("S");
|
||||
expect(category["cbc:TaxExemptionReason"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses category K with the intra-community reason when vatExempt", () => {
|
||||
const category = taxCategory(
|
||||
buildEInvoiceData(
|
||||
order({ vatExempt: true, items: [{ productName: "ToDo-Karten", quantity: 1, unitPrice: 12.9, taxRatePercent: 0, bundleContents: null }] }),
|
||||
seller,
|
||||
),
|
||||
);
|
||||
expect(category["cbc:ID"]).toBe("K");
|
||||
expect(category["cbc:TaxExemptionReasonCode"]).toBe("VATEX-EU-IC");
|
||||
});
|
||||
|
||||
it("uses category E with the §19 UStG reason when kleinunternehmer, taking precedence over vatExempt", () => {
|
||||
const category = taxCategory(
|
||||
buildEInvoiceData(
|
||||
order({
|
||||
kleinunternehmer: true,
|
||||
vatExempt: true,
|
||||
items: [{ productName: "ToDo-Karten", quantity: 1, unitPrice: 12.9, taxRatePercent: 0, bundleContents: null }],
|
||||
}),
|
||||
seller,
|
||||
),
|
||||
);
|
||||
expect(category["cbc:ID"]).toBe("E");
|
||||
expect(category["cbc:TaxExemptionReason"]).toBe("Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.");
|
||||
expect(category["cbc:TaxExemptionReasonCode"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,11 @@ export type CorrectionInvoiceOrder = {
|
||||
// de-grossed figures, and shows the same exemption note instead of a
|
||||
// meaningless "enthält 0% MwSt." line.
|
||||
vatExempt?: boolean;
|
||||
// Same as invoicePdf.tsx's own InvoiceOrder.kleinunternehmer — a
|
||||
// Storno/Gutschrift for a Kleinunternehmer original invoice reverses the
|
||||
// same 0%-rated figures and shows the same §19 UStG note instead of a
|
||||
// meaningless "enthält 0% MwSt." line. Takes precedence over vatExempt.
|
||||
kleinunternehmer?: boolean;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string | null;
|
||||
packstationNumber?: string | null;
|
||||
@@ -342,7 +347,9 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
describes Gesamt, not Netto (which has the tax already
|
||||
taken out), so it must not read as attached to Netto. */}
|
||||
<View style={styles.vatNotes}>
|
||||
{order.vatExempt ? (
|
||||
{order.kleinunternehmer ? (
|
||||
<Text style={styles.vatNoteLabel}>Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</Text>
|
||||
) : order.vatExempt ? (
|
||||
<Text style={styles.vatNoteLabel}>Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</Text>
|
||||
) : (
|
||||
rateGroups.map((g) => (
|
||||
|
||||
@@ -23,17 +23,33 @@ type BuyerCountryCode = UblInvoice["cac:AccountingCustomerParty"]["cac:Party"]["
|
||||
// summed group uses this same code.
|
||||
const VAT_CATEGORY = "S";
|
||||
|
||||
// UNTDID 5305 code 'K' — "VAT exempt for EEA intra-community supply of
|
||||
// goods and services" — the EN16931 category for an innergemeinschaftliche
|
||||
// Lieferung (§4 Nr. 1b UStG / Art. 138 VAT Directive), distinct from 'S'
|
||||
// (positive standard rate), 'Z' (zero-rated but still taxable), and 'AE'
|
||||
// (domestic reverse charge under §13b UStG — doesn't apply here at all,
|
||||
// see the frontend repo's lib/vatExemption.ts for why this shop's checkout
|
||||
// only ever produces this 'K' exemption for a cross-border validated B2B
|
||||
// sale, never 'AE').
|
||||
function vatTaxCategory(rate: number, vatExempt: boolean): Record<string, unknown> {
|
||||
// Which of this shop's three mutually-exclusive VAT treatments an
|
||||
// invoice/correction-invoice was issued under — 'standard' (normal
|
||||
// Regelbesteuerung), 'intra-community' (validated cross-border B2B
|
||||
// exemption, §4 Nr. 1b UStG), or 'kleinunternehmer' (§19 UStG small
|
||||
// business — the seller never charges VAT at all, on any sale). Derived
|
||||
// once per document from the order's own snapshotted
|
||||
// vatExempt/kleinunternehmer flags (see buildEInvoiceData/
|
||||
// buildCorrectionEInvoiceData below) and threaded through every function
|
||||
// in this file that used to take a plain `vatExempt: boolean` — a
|
||||
// Kleinunternehmer sale is never also an intra-community exemption
|
||||
// (there's no VAT to exempt in the first place), so this is a 3-way
|
||||
// choice, not two independent booleans.
|
||||
type VatMode = "standard" | "intra-community" | "kleinunternehmer";
|
||||
|
||||
// UNTDID 5305 codes: 'K' — "VAT exempt for EEA intra-community supply of
|
||||
// goods and services" (§4 Nr. 1b UStG / Art. 138 VAT Directive). 'E' —
|
||||
// "Exempt from tax", used here for §19 UStG Kleinunternehmer — Germany's
|
||||
// small-business exemption is a national provision, not one of the
|
||||
// EU-wide reasons that has its own dedicated UNTDID/VATEX category, so
|
||||
// 'E' (the general exemption code) plus a free-text reason (BT-120, see
|
||||
// vatTaxSubtotalCategory below) is the correct EN16931 shape rather than
|
||||
// forcing it into 'K'. Distinct from 'S' (positive standard rate) and 'Z'
|
||||
// (zero-rated but still taxable) — neither applies to either exemption.
|
||||
function vatTaxCategory(rate: number, vatMode: VatMode): Record<string, unknown> {
|
||||
const id = vatMode === "kleinunternehmer" ? "E" : vatMode === "intra-community" ? "K" : VAT_CATEGORY;
|
||||
return {
|
||||
"cbc:ID": vatExempt ? "K" : VAT_CATEGORY,
|
||||
"cbc:ID": id,
|
||||
"cbc:Percent": String(rate),
|
||||
"cac:TaxScheme": { "cbc:ID": "VAT" },
|
||||
};
|
||||
@@ -48,13 +64,17 @@ function vatTaxCategory(rate: number, vatExempt: boolean): Record<string, unknow
|
||||
// generated type despite conceptually being "the same" UBL TaxCategory
|
||||
// complex type) — caught locally before ever reaching Mustang/CI, exactly
|
||||
// the kind of runtime-only-visible constraint this pipeline has hit before
|
||||
// (see amtCents()'s own comment). BR-K-10 requires either field whenever
|
||||
// category is 'K', satisfied here since this is the one place they're
|
||||
// actually allowed to live.
|
||||
function vatTaxSubtotalCategory(rate: number, vatExempt: boolean): Record<string, unknown> {
|
||||
// (see amtCents()'s own comment). BR-K-10/BR-E-10 require either field
|
||||
// whenever category is 'K'/'E', satisfied here since this is the one place
|
||||
// they're actually allowed to live. §19 UStG has no EU-wide VATEX code (a
|
||||
// purely national exemption reason) — BR-E-10 accepts the free-text
|
||||
// TaxExemptionReason alone without a TaxExemptionReasonCode, so
|
||||
// kleinunternehmer only sets the text, unlike intra-community's VATEX-EU-IC.
|
||||
function vatTaxSubtotalCategory(rate: number, vatMode: VatMode): Record<string, unknown> {
|
||||
return {
|
||||
...vatTaxCategory(rate, vatExempt),
|
||||
...(vatExempt ? { "cbc:TaxExemptionReasonCode": "VATEX-EU-IC", "cbc:TaxExemptionReason": "Innergemeinschaftliche Lieferung" } : {}),
|
||||
...vatTaxCategory(rate, vatMode),
|
||||
...(vatMode === "intra-community" ? { "cbc:TaxExemptionReasonCode": "VATEX-EU-IC", "cbc:TaxExemptionReason": "Innergemeinschaftliche Lieferung" } : {}),
|
||||
...(vatMode === "kleinunternehmer" ? { "cbc:TaxExemptionReason": "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,7 +274,7 @@ function computeRateGroups(lines: ComputedLine[], discountAmount: number, shippi
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}
|
||||
|
||||
function taxTotal(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:TaxTotal"] {
|
||||
function taxTotal(rateGroups: RateGroup[], vatMode: VatMode): UblInvoice["cac:TaxTotal"] {
|
||||
const totalTaxCents = rateGroups.reduce((sum, g) => sum + g.taxCents, 0);
|
||||
return [
|
||||
{
|
||||
@@ -262,13 +282,13 @@ function taxTotal(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:
|
||||
"cac:TaxSubtotal": rateGroups.map((g) => ({
|
||||
...amtCents("cbc:TaxableAmount", g.taxableCents),
|
||||
...amtCents("cbc:TaxAmount", g.taxCents),
|
||||
"cac:TaxCategory": vatTaxSubtotalCategory(g.rate, vatExempt),
|
||||
"cac:TaxCategory": vatTaxSubtotalCategory(g.rate, vatMode),
|
||||
})),
|
||||
},
|
||||
] as unknown as UblInvoice["cac:TaxTotal"];
|
||||
}
|
||||
|
||||
function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoice["cac:AllowanceCharge"] {
|
||||
function allowanceCharges(rateGroups: RateGroup[], vatMode: VatMode): UblInvoice["cac:AllowanceCharge"] {
|
||||
// Loosely typed here (build as Record<string, unknown>, cast once at the
|
||||
// return boundary) — VAT_CATEGORY's literal type widens to plain
|
||||
// `string` the moment it's read through an intermediate variable, which
|
||||
@@ -277,7 +297,7 @@ function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoi
|
||||
// exact same widening issue.
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
for (const g of rateGroups) {
|
||||
const taxCategory = vatTaxCategory(g.rate, vatExempt);
|
||||
const taxCategory = vatTaxCategory(g.rate, vatMode);
|
||||
if (g.allowanceCents > 0) {
|
||||
entries.push({
|
||||
"cbc:ChargeIndicator": "false",
|
||||
@@ -298,7 +318,7 @@ function allowanceCharges(rateGroups: RateGroup[], vatExempt: boolean): UblInvoi
|
||||
return entries.length ? (entries as unknown as UblInvoice["cac:AllowanceCharge"]) : undefined;
|
||||
}
|
||||
|
||||
function invoiceLines(lines: ComputedLine[], vatExempt: boolean): UblInvoice["cac:InvoiceLine"] {
|
||||
function invoiceLines(lines: ComputedLine[], vatMode: VatMode): UblInvoice["cac:InvoiceLine"] {
|
||||
return lines.map(({ item, netCents }, i) => {
|
||||
const netUnitPrice = item.unitPrice / (1 + item.taxRatePercent / 100);
|
||||
return {
|
||||
@@ -307,7 +327,7 @@ function invoiceLines(lines: ComputedLine[], vatExempt: boolean): UblInvoice["ca
|
||||
...amtCents("cbc:LineExtensionAmount", netCents),
|
||||
"cac:Item": {
|
||||
"cbc:Name": item.variantName ? `${item.productName} (${item.variantName})` : item.productName,
|
||||
"cac:ClassifiedTaxCategory": vatTaxCategory(item.taxRatePercent, vatExempt),
|
||||
"cac:ClassifiedTaxCategory": vatTaxCategory(item.taxRatePercent, vatMode),
|
||||
},
|
||||
// Unit price, not a summed/reconciled total — kept as the plain
|
||||
// (unrounded-to-cent) net unit price for reference; BR-CO-10 only
|
||||
@@ -372,10 +392,10 @@ function paymentTerms(note: string): UblInvoice["cac:PaymentTerms"] {
|
||||
// Full original invoice — one line per order item, positive amounts,
|
||||
// InvoiceTypeCode 380 ("Commercial invoice").
|
||||
export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): Invoice {
|
||||
const vatExempt = Boolean(order.vatExempt);
|
||||
const vatMode: VatMode = order.kleinunternehmer ? "kleinunternehmer" : order.vatExempt ? "intra-community" : "standard";
|
||||
const lines = computeLines(order.items);
|
||||
const rateGroups = computeRateGroups(lines, order.discountAmount, order.shippingCost);
|
||||
const allowanceChargeEntries = allowanceCharges(rateGroups, vatExempt);
|
||||
const allowanceChargeEntries = allowanceCharges(rateGroups, vatMode);
|
||||
const paid = isPaidImmediately(order.paymentMethodTitle);
|
||||
|
||||
return {
|
||||
@@ -399,13 +419,13 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
|
||||
// invoice's BT-115 is 0 (see legalMonetaryTotal()'s `prepaid` param).
|
||||
...(paid ? {} : { "cac:PaymentTerms": paymentTerms("Zahlbar sofort ohne Abzug.") }),
|
||||
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
|
||||
"cac:TaxTotal": taxTotal(rateGroups, vatExempt),
|
||||
"cac:TaxTotal": taxTotal(rateGroups, vatMode),
|
||||
// `order.total` is what the customer was actually charged at
|
||||
// checkout — any cent of drift against this breakdown's own
|
||||
// TaxInclusiveAmount is declared via PayableRoundingAmount inside
|
||||
// legalMonetaryTotal(), not silently absorbed into either figure.
|
||||
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, Math.round(order.total * 100), paid),
|
||||
"cac:InvoiceLine": invoiceLines(lines, vatExempt),
|
||||
"cac:InvoiceLine": invoiceLines(lines, vatMode),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -422,7 +442,7 @@ export function buildEInvoiceData(order: InvoiceOrder, seller: InvoiceSeller): I
|
||||
// Stornorechnung-vs-Gutschrift policy (full reversal incl. shipping vs.
|
||||
// only returned quantities, no shipping, no discount reproration).
|
||||
export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order: CorrectionInvoiceOrder, seller: InvoiceSeller): Invoice {
|
||||
const vatExempt = Boolean(order.vatExempt);
|
||||
const vatMode: VatMode = order.kleinunternehmer ? "kleinunternehmer" : order.vatExempt ? "intra-community" : "standard";
|
||||
const effectiveItems =
|
||||
kind === "storno"
|
||||
? order.items
|
||||
@@ -430,7 +450,7 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order:
|
||||
|
||||
const lines = computeLines(effectiveItems);
|
||||
const rateGroups = computeRateGroups(lines, kind === "storno" ? order.discountAmount : 0, kind === "storno" ? order.shippingCost : 0);
|
||||
const allowanceChargeEntries = allowanceCharges(rateGroups, vatExempt);
|
||||
const allowanceChargeEntries = allowanceCharges(rateGroups, vatMode);
|
||||
// No separately-stored "charged total" exists for a correction event the
|
||||
// way `order.total` does for the original invoice — the reversal amount
|
||||
// *is* this breakdown's own TaxInclusiveAmount, so PayableAmount is
|
||||
@@ -469,9 +489,9 @@ export function buildCorrectionEInvoiceData(kind: CorrectionInvoiceKind, order:
|
||||
// branch above) — payment terms text satisfies the rule instead.
|
||||
"cac:PaymentTerms": paymentTerms("Der Rechnungsbetrag wird erstattet."),
|
||||
...(allowanceChargeEntries ? { "cac:AllowanceCharge": allowanceChargeEntries } : {}),
|
||||
"cac:TaxTotal": taxTotal(rateGroups, vatExempt),
|
||||
"cac:TaxTotal": taxTotal(rateGroups, vatMode),
|
||||
"cac:LegalMonetaryTotal": legalMonetaryTotal(rateGroups, taxInclusiveCents, false),
|
||||
"cac:InvoiceLine": invoiceLines(lines, vatExempt),
|
||||
"cac:InvoiceLine": invoiceLines(lines, vatMode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+13
-1
@@ -156,6 +156,16 @@ export type InvoiceOrder = {
|
||||
// figures) — this flag only controls the exemption note shown here,
|
||||
// it doesn't itself change any arithmetic in this file.
|
||||
vatExempt?: boolean;
|
||||
// Kleinunternehmerregelung (§19 UStG) — snapshotted per order at checkout
|
||||
// time (frontend's api/checkout/route.ts, from the seller's own
|
||||
// company-settings.kleinunternehmer), never read live off the seller at
|
||||
// render time: an invoice already issued must keep showing the tax
|
||||
// treatment that actually applied at the moment of sale, even if the
|
||||
// tenant later toggles this setting on/off. Takes precedence over
|
||||
// vatExempt below when both would otherwise apply — a Kleinunternehmer
|
||||
// never charges VAT in the first place, so there is nothing left to
|
||||
// "exempt" via the separate intra-community rule.
|
||||
kleinunternehmer?: boolean;
|
||||
deliveryMethod: "address" | "packstation";
|
||||
street?: string | null;
|
||||
packstationNumber?: string | null;
|
||||
@@ -394,7 +404,9 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
made it read as if Netto were what contained the tax,
|
||||
which is backwards (Netto is the figure with tax
|
||||
already taken out). */}
|
||||
{order.vatExempt ? (
|
||||
{order.kleinunternehmer ? (
|
||||
<Text style={styles.vatNoteLabel}>Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</Text>
|
||||
) : order.vatExempt ? (
|
||||
<Text style={styles.vatNoteLabel}>Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</Text>
|
||||
) : (
|
||||
rateGroups.map((g) => (
|
||||
|
||||
Reference in New Issue
Block a user