Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const ALLOWED_SETTINGS = new Set([
"company_name",
"company_email",
"company_phone",
"company_contact_name",
"company_address",
"company_street",
"company_city",
Expand Down
61 changes: 56 additions & 5 deletions packages/backend/src/services/einvoice-validator.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { payeeAccount, vatGroups } from "../xml/en16931";
import type { XmlInvoiceData } from "../xml/types";

export type EinvoiceSeverity = "error" | "warning";
Expand All @@ -14,7 +15,7 @@ export interface EinvoiceIssue {
* monetary sums, VAT categories); German specifics (USt-IdNr format,
* Steuernummer, reverse charge) surface as errors/warnings.
*/
export function validateEinvoice(data: XmlInvoiceData): EinvoiceIssue[] {
export function validateEinvoice(data: XmlInvoiceData, format?: string): EinvoiceIssue[] {
const issues: EinvoiceIssue[] = [];
const cur = data.currency || "";

Expand Down Expand Up @@ -42,15 +43,47 @@ export function validateEinvoice(data: XmlInvoiceData): EinvoiceIssue[] {
issues.push(error("buyer_address", "Buyer address is required."));
}

// German specifics
if ((data.supplier.country || "").toUpperCase() === "DE" && !data.supplier.tax_id) {
if (!data.supplier.country) {
issues.push(error("seller_country", "Seller country is required."));
}
// BR-CO-26: the seller must be identifiable. Inkvoice emits the VAT ID for
// this; a Steuernummer (BT-32) does not satisfy the rule.
if (!data.supplier.tax_id) {
issues.push(
warning(
error(
"seller_vat_id",
"USt-IdNr of the seller is missing (recommended for German invoices).",
"The seller VAT ID (USt-IdNr) is required for e-invoices; a tax number alone is rejected by EN 16931 validators (BR-CO-26).",
),
);
}
if (!payeeAccount(data.supplier)) {
issues.push(
warning(
"seller_iban",
"No valid IBAN configured (Settings → IBAN); the e-invoice will not contain bank transfer instructions.",
),
);
}

// XRechnung (CIUS) requirements on top of EN 16931.
if (format === "xrechnung-ubl") {
if (!data.supplier.phone) {
issues.push(error("seller_phone", "XRechnung requires the seller phone number (BR-DE-6)."));
}
if (!data.supplier.email) {
issues.push(error("seller_email", "XRechnung requires the seller email address (BR-DE-7)."));
}
if (!data.customer.leitweg_id && !data.customer.einvoice_receiver_id && !data.customer.email) {
issues.push(
error(
"buyer_electronic_address",
"XRechnung requires an electronic address for the buyer: an email, Leitweg-ID or receiver ID.",
),
);
}
}

// German specifics
if (
(data.supplier.country || "").toUpperCase() === "DE" &&
data.supplier.tax_id &&
Expand Down Expand Up @@ -149,6 +182,24 @@ export function validateEinvoice(data: XmlInvoiceData): EinvoiceIssue[] {
}
}

// BR-CO-17: VAT per category must equal taxable amount × rate.
for (const g of vatGroups(data)) {
if (g.category !== "S") continue;
const expected = Math.round(g.taxable * g.rate) / 100;
if (Math.abs(expected - g.tax) > 0.01) {
const discountHint =
data.discount_amount > 0
? " The document discount must also reduce the VAT base (§ 10 UStG)."
: "";
issues.push(
error(
"vat_amount",
`VAT at ${g.rate}% is ${g.tax.toFixed(2)}, but ${g.taxable.toFixed(2)} × ${g.rate}% = ${expected.toFixed(2)}.${discountHint}`,
),
);
}
}

// Monetary consistency (EN 16931: Tolerance)
const expectedTaxable = data.subtotal - data.discount_amount;
if (
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/services/einvoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export async function emitEinvoice(
opts: { forceFormat?: EinvoiceFormat } = {},
): Promise<EinvoiceEmitResult> {
const data = buildXmlInvoiceData(invoiceId);
const issues = validateEinvoice(data);
const settings = getAllSettings();

const format =
Expand All @@ -67,6 +66,7 @@ export async function emitEinvoice(
leitwegId: data.customer.leitweg_id,
});

const issues = validateEinvoice(data, format);
const profile = getProfile(formatToProfileId(format));
if (!profile) {
throw new Error(
Expand Down
Loading