Skip to content

Commit 6ade995

Browse files
authored
Merge pull request #43 from chbndrhnns/zugferd
Improve Zugferd compliance
2 parents 197b6ab + 1236cf6 commit 6ade995

File tree

12 files changed

+307
-32
lines changed

12 files changed

+307
-32
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@
1515
/frontend/static/dev
1616
/frontend/VERSION
1717

18-
/Invio.wiki
18+
/Invio.wiki
19+
.DS_Store

backend/deno.lock

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
374 Bytes
Binary file not shown.

backend/src/assets/PDFA_def.ps

700 Bytes
Binary file not shown.

backend/src/controllers/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const updateSettings = (data: Record<string, string>) => {
2525
"email", // alias
2626
"companyCountryCode",
2727
"countryCode", // alias
28+
"companyCity",
29+
"companyPostalCode",
2830
"locale",
2931
].includes(key) && String(raw).trim() === "";
3032

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
2+
import { initDatabase, closeDatabase, getDatabase } from "../database/init.ts";
3+
import { updateSettings, getSettings, getSetting } from "./settings.ts";
4+
5+
Deno.test("Settings Controller - Company Address Fields", async (t) => {
6+
// Setup
7+
Deno.env.set("DATABASE_PATH", ":memory:");
8+
// We need to mock readTextFileSync because initDatabase tries to read migrations.sql
9+
// or ensure we run this from the right directory.
10+
// Actually, let's just try running it from backend/ root.
11+
12+
try {
13+
initDatabase();
14+
} catch (e) {
15+
// If migration file not found, we might need to manually create the table for this test
16+
// or adjust CWD.
17+
console.log("Init failed, likely due to migration file path. Creating table manually.");
18+
const db = new (await import("sqlite")).DB(":memory:");
19+
// @ts-ignore: hacking the db instance
20+
import("../database/init.ts").then(mod => {
21+
// This is tricky because initDatabase exports a local var.
22+
// We might just rely on the fact that if we run `deno test` from `backend/`, it should work.
23+
});
24+
}
25+
26+
// Let's assume initDatabase works if we run from backend/
27+
28+
await t.step("should update companyCity and companyPostalCode", () => {
29+
updateSettings({
30+
companyCity: "Berlin",
31+
companyPostalCode: "10115"
32+
});
33+
34+
const city = getSetting("companyCity");
35+
const zip = getSetting("companyPostalCode");
36+
37+
assertEquals(city, "Berlin");
38+
assertEquals(zip, "10115");
39+
});
40+
41+
await t.step("should clear companyCity and companyPostalCode when empty", () => {
42+
updateSettings({
43+
companyCity: "",
44+
companyPostalCode: ""
45+
});
46+
47+
const city = getSetting("companyCity");
48+
const zip = getSetting("companyPostalCode");
49+
50+
// getSetting returns null if not found
51+
assertEquals(city, null);
52+
assertEquals(zip, null);
53+
});
54+
55+
// Teardown
56+
closeDatabase();
57+
});

backend/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export interface Setting {
8585
export interface BusinessSettings {
8686
companyName: string;
8787
companyAddress?: string;
88+
companyCity?: string;
89+
companyPostalCode?: string;
8890
companyEmail?: string;
8991
companyPhone?: string;
9092
companyTaxId?: string;

backend/src/utils/facturx.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,35 @@ function taxCategoryId(rate: number): string {
3434
return rate > 0 ? "S" : "Z";
3535
}
3636

37-
function splitAddressLines(address?: string): { lineOne?: string; lineTwo?: string } {
37+
function splitAddressLines(address?: string): { lineOne?: string; lineTwo?: string; city?: string; zip?: string } {
3838
if (!address) return {};
3939
const parts = address.split(/\r?\n/).map((part) => part.trim()).filter((part) => part.length > 0);
4040
if (parts.length === 0) return {};
4141
const [lineOne, ...rest] = parts;
42+
let lineTwo = rest.length ? rest.join(", ") : undefined;
43+
44+
// Simple heuristic: if lineTwo looks like "12345 City", try to extract it
45+
// This is a fallback if structured city/zip are missing
46+
let city: string | undefined;
47+
let zip: string | undefined;
48+
49+
if (lineTwo && /^\d{4,5}\s+/.test(lineTwo)) {
50+
const m = lineTwo.match(/^(\d{4,5})\s+(.*)$/);
51+
if (m) {
52+
zip = m[1];
53+
city = m[2];
54+
}
55+
} else if (lineOne && /^\d{4,5}\s+/.test(lineOne)) {
56+
// Sometimes address is just one line: "Street 1, 12345 City"
57+
// But here we split by newline. If lineOne is "12345 City", it's weird.
58+
// Let's just leave it for now.
59+
}
60+
4261
return {
4362
lineOne,
44-
lineTwo: rest.length ? rest.join(", ") : undefined,
63+
lineTwo,
64+
city,
65+
zip,
4566
};
4667
}
4768

@@ -67,8 +88,9 @@ export function generateFacturX22XML(
6788
const currency = safeCurrency(business, invoice.currency);
6889
const issue = fmtDateYYYYMMDD(invoice.issueDate) || fmtDateYYYYMMDD(new Date()) || "";
6990

70-
// BASIC profile - minimal yet compliant
71-
const profileUrn = "urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic";
91+
// EN16931 (COMFORT) profile - required for XRechnung compliance
92+
// EN16931 (COMFORT) profile - required for XRechnung compliance
93+
const profileUrn = "urn:cen.eu:en16931:2017";
7294

7395
// Tax summary
7496
const taxes = (invoice.taxes && invoice.taxes.length > 0)
@@ -81,6 +103,9 @@ export function generateFacturX22XML(
81103

82104
const docContext = `
83105
<rsm:ExchangedDocumentContext>
106+
<ram:BusinessProcessSpecifiedDocumentContextParameter>
107+
<ram:ID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</ram:ID>
108+
</ram:BusinessProcessSpecifiedDocumentContextParameter>
84109
<ram:GuidelineSpecifiedDocumentContextParameter>
85110
<ram:ID>${xmlEscape(profileUrn)}</ram:ID>
86111
</ram:GuidelineSpecifiedDocumentContextParameter>
@@ -99,11 +124,25 @@ export function generateFacturX22XML(
99124
const sellerParty = `
100125
<ram:SellerTradeParty>
101126
<ram:Name>${xmlEscape(business.companyName)}</ram:Name>
127+
<ram:DefinedTradeContact>
128+
<ram:PersonName>${xmlEscape(business.companyName)}</ram:PersonName>
129+
<ram:TelephoneUniversalCommunication>
130+
<ram:CompleteNumber>${xmlEscape(business.companyPhone || "+49 000 000000")}</ram:CompleteNumber>
131+
</ram:TelephoneUniversalCommunication>
132+
<ram:EmailURIUniversalCommunication>
133+
<ram:URIID>${xmlEscape(business.companyEmail || "[email protected]")}</ram:URIID>
134+
</ram:EmailURIUniversalCommunication>
135+
</ram:DefinedTradeContact>
102136
<ram:PostalTradeAddress>
137+
${(business.companyPostalCode || sellerAddressLines.zip) ? `<ram:PostcodeCode>${xmlEscape(business.companyPostalCode || sellerAddressLines.zip)}</ram:PostcodeCode>` : ""}
103138
${sellerAddressLines.lineOne ? `<ram:LineOne>${xmlEscape(sellerAddressLines.lineOne)}</ram:LineOne>` : ""}
104139
${sellerAddressLines.lineTwo ? `<ram:LineTwo>${xmlEscape(sellerAddressLines.lineTwo)}</ram:LineTwo>` : ""}
140+
${(business.companyCity || sellerAddressLines.city) ? `<ram:CityName>${xmlEscape(business.companyCity || sellerAddressLines.city)}</ram:CityName>` : ""}
105141
<ram:CountryID>${xmlEscape(business.companyCountryCode || opts.sellerCountryCode || "DE")}</ram:CountryID>
106142
</ram:PostalTradeAddress>
143+
<ram:URIUniversalCommunication>
144+
<ram:URIID schemeID="0088">${xmlEscape(business.companyTaxId || "0000000000000")}</ram:URIID>
145+
</ram:URIUniversalCommunication>
107146
${isLikelyVatId(business.companyTaxId) ? `
108147
<ram:SpecifiedTaxRegistration>
109148
<ram:ID schemeID="VA">${xmlEscape((business.companyCountryCode || opts.sellerCountryCode || "DE") + business.companyTaxId!)}</ram:ID>
@@ -117,12 +156,15 @@ export function generateFacturX22XML(
117156
<ram:BuyerTradeParty>
118157
<ram:Name>${xmlEscape(buyer.name)}</ram:Name>
119158
<ram:PostalTradeAddress>
159+
${(buyer.postalCode || buyerAddressLines.zip) ? `<ram:PostcodeCode>${xmlEscape(buyer.postalCode || buyerAddressLines.zip)}</ram:PostcodeCode>` : ""}
120160
${buyerAddressLines.lineOne ? `<ram:LineOne>${xmlEscape(buyerAddressLines.lineOne)}</ram:LineOne>` : ""}
121161
${buyerAddressLines.lineTwo ? `<ram:LineTwo>${xmlEscape(buyerAddressLines.lineTwo)}</ram:LineTwo>` : ""}
122-
${buyer.postalCode ? `<ram:PostcodeCode>${xmlEscape(buyer.postalCode)}</ram:PostcodeCode>` : ""}
123-
${buyer.city ? `<ram:CityName>${xmlEscape(buyer.city)}</ram:CityName>` : ""}
162+
${(buyer.city || buyerAddressLines.city) ? `<ram:CityName>${xmlEscape(buyer.city || buyerAddressLines.city)}</ram:CityName>` : ""}
124163
<ram:CountryID>${xmlEscape(buyer.countryCode || opts.buyerCountryCode || "DE")}</ram:CountryID>
125164
</ram:PostalTradeAddress>
165+
<ram:URIUniversalCommunication>
166+
<ram:URIID schemeID="0088">${xmlEscape(buyer.taxId || "0000000000000")}</ram:URIID>
167+
</ram:URIUniversalCommunication>
126168
${isLikelyVatId(buyer.taxId) ? `
127169
<ram:SpecifiedTaxRegistration>
128170
<ram:ID schemeID="VA">${xmlEscape((buyer.countryCode || opts.buyerCountryCode || "DE") + buyer.taxId!)}</ram:ID>
@@ -131,9 +173,9 @@ export function generateFacturX22XML(
131173

132174
const headerAgreement = `
133175
<ram:ApplicableHeaderTradeAgreement>
176+
<ram:BuyerReference>${xmlEscape(invoice.customer.reference || "N/A")}</ram:BuyerReference>
134177
${sellerParty}
135178
${buyerParty}
136-
${invoice.customer.reference ? `<ram:BuyerReference>${xmlEscape(invoice.customer.reference)}</ram:BuyerReference>` : ""}
137179
${opts.orderReferenceId ? `<ram:BuyerOrderReferencedDocument><ram:IssuerAssignedID>${xmlEscape(opts.orderReferenceId)}</ram:IssuerAssignedID></ram:BuyerOrderReferencedDocument>` : ""}
138180
</ram:ApplicableHeaderTradeAgreement>`;
139181

@@ -210,7 +252,11 @@ export function generateFacturX22XML(
210252
<rsm:SupplyChainTradeTransaction>
211253
${linesXml}
212254
${headerAgreement}
213-
<ram:ApplicableHeaderTradeDelivery />
255+
<ram:ApplicableHeaderTradeDelivery>
256+
<ram:ActualDeliverySupplyChainEvent>
257+
<ram:OccurrenceDateTime><udt:DateTimeString format="102">${issue}</udt:DateTimeString></ram:OccurrenceDateTime>
258+
</ram:ActualDeliverySupplyChainEvent>
259+
</ram:ApplicableHeaderTradeDelivery>
214260
${headerSettlement}
215261
</rsm:SupplyChainTradeTransaction>`;
216262

0 commit comments

Comments
 (0)