diff --git a/packages/data/config/static-allowance-reason.php b/packages/data/config/static-allowance-reason.php new file mode 100644 index 0000000000..0e281479ec --- /dev/null +++ b/packages/data/config/static-allowance-reason.php @@ -0,0 +1,15 @@ + 'trans//data::static-allowance-reason.static_allowance_reason', + 'plural' => 'trans//data::static-allowance-reason.static_allowance_reasons', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-charge-reason.php b/packages/data/config/static-charge-reason.php new file mode 100644 index 0000000000..6b391002f1 --- /dev/null +++ b/packages/data/config/static-charge-reason.php @@ -0,0 +1,15 @@ + 'trans//data::static-charge-reason.static_charge_reason', + 'plural' => 'trans//data::static-charge-reason.static_charge_reasons', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-document-type.php b/packages/data/config/static-document-type.php new file mode 100644 index 0000000000..5eedccd557 --- /dev/null +++ b/packages/data/config/static-document-type.php @@ -0,0 +1,15 @@ + 'trans//data::static-document-type.static_document_type', + 'plural' => 'trans//data::static-document-type.static_document_types', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-eas-scheme.php b/packages/data/config/static-eas-scheme.php new file mode 100644 index 0000000000..c396c3b014 --- /dev/null +++ b/packages/data/config/static-eas-scheme.php @@ -0,0 +1,15 @@ + 'trans//data::static-eas-scheme.static_eas_scheme', + 'plural' => 'trans//data::static-eas-scheme.static_eas_schemes', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-icd-scheme.php b/packages/data/config/static-icd-scheme.php new file mode 100644 index 0000000000..7b1d87db09 --- /dev/null +++ b/packages/data/config/static-icd-scheme.php @@ -0,0 +1,15 @@ + 'trans//data::static-icd-scheme.static_icd_scheme', + 'plural' => 'trans//data::static-icd-scheme.static_icd_schemes', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-incoterm.php b/packages/data/config/static-incoterm.php new file mode 100644 index 0000000000..51e0404bec --- /dev/null +++ b/packages/data/config/static-incoterm.php @@ -0,0 +1,15 @@ + 'trans//data::static-incoterm.static_incoterm', + 'plural' => 'trans//data::static-incoterm.static_incoterms', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-payment-mean.php b/packages/data/config/static-payment-mean.php new file mode 100644 index 0000000000..889a53d796 --- /dev/null +++ b/packages/data/config/static-payment-mean.php @@ -0,0 +1,15 @@ + 'trans//data::static-payment-mean.static_payment_mean', + 'plural' => 'trans//data::static-payment-mean.static_payment_means', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-unit.php b/packages/data/config/static-unit.php new file mode 100644 index 0000000000..360056878e --- /dev/null +++ b/packages/data/config/static-unit.php @@ -0,0 +1,15 @@ + 'trans//data::static-unit.static_unit', + 'plural' => 'trans//data::static-unit.static_units', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-vat-category.php b/packages/data/config/static-vat-category.php new file mode 100644 index 0000000000..faa2b087bd --- /dev/null +++ b/packages/data/config/static-vat-category.php @@ -0,0 +1,15 @@ + 'trans//data::static-vat-category.static_vat_category', + 'plural' => 'trans//data::static-vat-category.static_vat_categories', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/config/static-vat-exemption-reason.php b/packages/data/config/static-vat-exemption-reason.php new file mode 100644 index 0000000000..6f2fb7d5ab --- /dev/null +++ b/packages/data/config/static-vat-exemption-reason.php @@ -0,0 +1,15 @@ + 'trans//data::static-vat-exemption-reason.static_vat_exemption_reason', + 'plural' => 'trans//data::static-vat-exemption-reason.static_vat_exemption_reasons', + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + ], + 'relations' => [], + 'taxonomies' => [], +]; diff --git a/packages/data/database/data/codelists/allowance-reasons.json b/packages/data/database/data/codelists/allowance-reasons.json new file mode 100644 index 0000000000..336d21d962 --- /dev/null +++ b/packages/data/database/data/codelists/allowance-reasons.json @@ -0,0 +1,25 @@ +{ + "_source": "UNCL 5189 (UN/CEFACT) — allowance reason codes", + "_todo": "verify/complete against official UNCL 5189", + "codes": [ + {"code": "41", "common_name": "Bonus for works ahead of schedule"}, + {"code": "42", "common_name": "Other bonus"}, + {"code": "60", "common_name": "Manufacturer's consumer discount"}, + {"code": "62", "common_name": "Due to military status"}, + {"code": "63", "common_name": "Due to work accident"}, + {"code": "64", "common_name": "Special agreement"}, + {"code": "65", "common_name": "Production error discount"}, + {"code": "66", "common_name": "New outlet discount"}, + {"code": "67", "common_name": "Sample discount"}, + {"code": "68", "common_name": "End-of-range discount"}, + {"code": "70", "common_name": "Incoterm discount"}, + {"code": "71", "common_name": "Point of sales threshold allowance"}, + {"code": "88", "common_name": "Material surcharge/deduction"}, + {"code": "95", "common_name": "Discount"}, + {"code": "100", "common_name": "Special rebate"}, + {"code": "102", "common_name": "Fixed long term"}, + {"code": "103", "common_name": "Temporary"}, + {"code": "104", "common_name": "Standard"}, + {"code": "105", "common_name": "Yearly turnover"} + ] +} diff --git a/packages/data/database/data/codelists/document-types.json b/packages/data/database/data/codelists/document-types.json new file mode 100644 index 0000000000..daae398569 --- /dev/null +++ b/packages/data/database/data/codelists/document-types.json @@ -0,0 +1,276 @@ +{ + "_source": "UNTDID 1001 (easyfirma.net subset)", + "_todo": "verify\/complete against official UNTDID 1001", + "codes": [ + { + "code": "71", + "common_name": "Request for payment", + "en16931_interpretation": "invoice" + }, + { + "code": "80", + "common_name": "Debit note related to goods\/services", + "en16931_interpretation": "invoice" + }, + { + "code": "81", + "common_name": "Credit note related to goods\/services", + "en16931_interpretation": "credit_note" + }, + { + "code": "82", + "common_name": "Metered services invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "83", + "common_name": "Credit note related to financial adjustments", + "en16931_interpretation": "credit_note" + }, + { + "code": "84", + "common_name": "Debit note related to financial adjustments", + "en16931_interpretation": "invoice" + }, + { + "code": "102", + "common_name": "Tax notification", + "en16931_interpretation": "invoice" + }, + { + "code": "130", + "common_name": "Invoicing data sheet", + "en16931_interpretation": "invoice" + }, + { + "code": "202", + "common_name": "Direct payment valuation", + "en16931_interpretation": "invoice" + }, + { + "code": "203", + "common_name": "Provisional payment valuation", + "en16931_interpretation": "invoice" + }, + { + "code": "204", + "common_name": "Payment valuation", + "en16931_interpretation": "invoice" + }, + { + "code": "211", + "common_name": "Interim application for payment", + "en16931_interpretation": "invoice" + }, + { + "code": "218", + "common_name": "Final payment request", + "en16931_interpretation": "invoice" + }, + { + "code": "219", + "common_name": "Payment request for completed units", + "en16931_interpretation": "invoice" + }, + { + "code": "261", + "common_name": "Self billed credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "262", + "common_name": "Consolidated credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "295", + "common_name": "Price variation invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "296", + "common_name": "Credit note for price variation", + "en16931_interpretation": "credit_note" + }, + { + "code": "308", + "common_name": "Delcredere credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "325", + "common_name": "Proforma invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "326", + "common_name": "Partial invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "331", + "common_name": "Commercial invoice with packing list", + "en16931_interpretation": "invoice" + }, + { + "code": "380", + "common_name": "Commercial invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "381", + "common_name": "Credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "382", + "common_name": "Commission note", + "en16931_interpretation": "invoice" + }, + { + "code": "383", + "common_name": "Debit note", + "en16931_interpretation": "invoice" + }, + { + "code": "384", + "common_name": "Corrected invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "385", + "common_name": "Consolidated invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "386", + "common_name": "Prepayment invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "387", + "common_name": "Hire invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "388", + "common_name": "Tax invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "389", + "common_name": "Self-billed invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "390", + "common_name": "Delcredere invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "393", + "common_name": "Factored invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "394", + "common_name": "Lease invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "395", + "common_name": "Consignment invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "396", + "common_name": "Factored credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "420", + "common_name": "OCR payment credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "456", + "common_name": "Debit advice", + "en16931_interpretation": "invoice" + }, + { + "code": "457", + "common_name": "Reversal of debit", + "en16931_interpretation": "invoice" + }, + { + "code": "458", + "common_name": "Reversal of credit", + "en16931_interpretation": "credit_note" + }, + { + "code": "527", + "common_name": "Self billed debit note", + "en16931_interpretation": "invoice" + }, + { + "code": "532", + "common_name": "Forwarder's credit note", + "en16931_interpretation": "credit_note" + }, + { + "code": "575", + "common_name": "Insurer's invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "623", + "common_name": "Forwarder's invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "633", + "common_name": "Port charges documents", + "en16931_interpretation": "invoice" + }, + { + "code": "751", + "common_name": "Invoice information for accounting purposes", + "en16931_interpretation": "invoice" + }, + { + "code": "780", + "common_name": "Freight invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "817", + "common_name": "Claim notification", + "en16931_interpretation": "invoice" + }, + { + "code": "870", + "common_name": "Consular invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "875", + "common_name": "Partial construction invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "876", + "common_name": "Partial final construction invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "877", + "common_name": "Final construction invoice", + "en16931_interpretation": "invoice" + }, + { + "code": "935", + "common_name": "Customs invoice", + "en16931_interpretation": "invoice" + } + ] +} diff --git a/packages/data/database/data/codelists/eas-schemes.json b/packages/data/database/data/codelists/eas-schemes.json new file mode 100644 index 0000000000..9b3faf10af --- /dev/null +++ b/packages/data/database/data/codelists/eas-schemes.json @@ -0,0 +1,16 @@ +{ + "_source": "CEF EAS — Electronic Address Scheme (EN 16931 / Peppol)", + "_todo": "complete from official CEF EAS code list — partial seed", + "codes": [ + {"code": "0002", "common_name": "System Information et Repertoire des Entreprise et des Etablissements: SIRENE"}, + {"code": "0060", "common_name": "Data Universal Numbering System (D-U-N-S Number)"}, + {"code": "0088", "common_name": "EAN Location Code"}, + {"code": "0096", "common_name": "The Danish Business Authority - P-number (DK:P)"}, + {"code": "0184", "common_name": "DIGSTORG"}, + {"code": "0192", "common_name": "Enhetsregisteret ved Bronnoysundregisterne"}, + {"code": "0204", "common_name": "Leitweg-ID"}, + {"code": "0208", "common_name": "Numero d'entreprise / ondernemingsnummer / Unternehmensnummer"}, + {"code": "9918", "common_name": "SOCIETY FOR WORLDWIDE INTERBANK FINANCIAL, TELECOMMUNICATION S.W.I.F.T"}, + {"code": "9930", "common_name": "Germany VAT number"} + ] +} diff --git a/packages/data/database/data/codelists/icd-schemes.json b/packages/data/database/data/codelists/icd-schemes.json new file mode 100644 index 0000000000..8e9b0d96f9 --- /dev/null +++ b/packages/data/database/data/codelists/icd-schemes.json @@ -0,0 +1,22 @@ +{ + "_source": "ISO/IEC 6523 ICD — International Code Designator (Peppol / EN 16931 subset)", + "_todo": "complete ~230 entries from official ISO/IEC 6523 ICD / CEF code list — partial seed", + "codes": [ + { + "code": "0002", + "common_name": "System Information et Repertoire des Entreprise et des Etablissements: SIRENE", + "description": "Notes on Use of Code: Identification of an organization or establishment in France. Issuing agency: Institut national de la statistique et des études économiques (INSEE)." + }, + {"code": "0060", "common_name": "Data Universal Numbering System (D-U-N-S Number)"}, + {"code": "0088", "common_name": "EAN Location Code"}, + {"code": "0096", "common_name": "The Danish Business Authority - P-number (DK:P)"}, + {"code": "0184", "common_name": "DIGSTORG"}, + {"code": "0204", "common_name": "Leitweg-ID"}, + {"code": "9918", "common_name": "SOCIETY FOR WORLDWIDE INTERBANK FINANCIAL, TELECOMMUNICATION S.W.I.F.T"}, + { + "code": "9930", + "common_name": "Germany VAT number", + "description": "Notes on Use of Code: German value added tax identification number. Issuing agency: Bundeszentralamt für Steuern." + } + ] +} diff --git a/packages/data/database/data/codelists/incoterms.json b/packages/data/database/data/codelists/incoterms.json new file mode 100644 index 0000000000..e02bdeec12 --- /dev/null +++ b/packages/data/database/data/codelists/incoterms.json @@ -0,0 +1,17 @@ +{ + "_source": "Incoterms 2020 (ICC)", + "_todo": "verify against official ICC Incoterms 2020", + "codes": [ + {"code": "EXW", "version": "2020", "common_name": "Ex Works"}, + {"code": "FCA", "version": "2020", "common_name": "Free Carrier"}, + {"code": "CPT", "version": "2020", "common_name": "Carriage Paid To"}, + {"code": "CIP", "version": "2020", "common_name": "Carriage and Insurance Paid To"}, + {"code": "DAP", "version": "2020", "common_name": "Delivered At Place"}, + {"code": "DPU", "version": "2020", "common_name": "Delivered at Place Unloaded"}, + {"code": "DDP", "version": "2020", "common_name": "Delivered Duty Paid"}, + {"code": "FAS", "version": "2020", "common_name": "Free Alongside Ship"}, + {"code": "FOB", "version": "2020", "common_name": "Free On Board"}, + {"code": "CFR", "version": "2020", "common_name": "Cost and Freight"}, + {"code": "CIF", "version": "2020", "common_name": "Cost, Insurance and Freight"} + ] +} diff --git a/packages/data/database/data/codelists/payment-means.json b/packages/data/database/data/codelists/payment-means.json new file mode 100644 index 0000000000..97c0b4792a --- /dev/null +++ b/packages/data/database/data/codelists/payment-means.json @@ -0,0 +1,16 @@ +{ + "_source": "UNTDID 4461 — payment means codes (EN 16931 subset)", + "_todo": "verify/complete against official UNTDID 4461", + "codes": [ + {"code": "10", "common_name": "In cash"}, + {"code": "20", "common_name": "Cheque"}, + {"code": "30", "common_name": "Credit transfer"}, + {"code": "42", "common_name": "Payment to bank account"}, + {"code": "48", "common_name": "Bank card"}, + {"code": "49", "common_name": "Direct debit"}, + {"code": "57", "common_name": "Standing agreement"}, + {"code": "58", "common_name": "SEPA credit transfer"}, + {"code": "59", "common_name": "SEPA direct debit"}, + {"code": "97", "common_name": "Clearing between partners"} + ] +} diff --git a/packages/data/database/data/codelists/uncl7161.json b/packages/data/database/data/codelists/uncl7161.json new file mode 100644 index 0000000000..838a8376c3 --- /dev/null +++ b/packages/data/database/data/codelists/uncl7161.json @@ -0,0 +1,42 @@ +{ + "_source": "UNCL 7161 (UN/CEFACT) — charge reason codes", + "_todo": "incomplete — fill full list from official CEF/Peppol UNCL 7161 publication", + "codes": [ + { + "code": "AA", + "common_name": "Advertising" + }, + { + "code": "AAA", + "common_name": "Telecommunication" + }, + { + "code": "FC", + "common_name": "Freight charge" + }, + { + "code": "HD", + "common_name": "Handling" + }, + { + "code": "WH", + "common_name": "Warehousing" + }, + { + "code": "PC", + "common_name": "Packing" + }, + { + "code": "DL", + "common_name": "Delivery" + }, + { + "code": "SH", + "common_name": "Special handling" + }, + { + "code": "ZZZ", + "common_name": "Mutually defined" + } + ] +} diff --git a/packages/data/database/data/codelists/units.json b/packages/data/database/data/codelists/units.json new file mode 100644 index 0000000000..16b3239e6b --- /dev/null +++ b/packages/data/database/data/codelists/units.json @@ -0,0 +1,21 @@ +{ + "_source": "UN/ECE Recommendation 20 + 21 — units of measure (EN 16931 subset)", + "_todo": "complete from EN 16931 Rec 20/21 subset", + "codes": [ + {"code": "C62", "common_name": "One (piece)", "symbol": null}, + {"code": "MTR", "common_name": "Metre", "symbol": "m"}, + {"code": "KGM", "common_name": "Kilogram", "symbol": "kg"}, + {"code": "GRM", "common_name": "Gram", "symbol": "g"}, + {"code": "LTR", "common_name": "Litre", "symbol": "l"}, + {"code": "MTK", "common_name": "Square metre", "symbol": "m²"}, + {"code": "MTQ", "common_name": "Cubic metre", "symbol": "m³"}, + {"code": "TNE", "common_name": "Tonne", "symbol": "t"}, + {"code": "H87", "common_name": "Piece", "symbol": null}, + {"code": "PR", "common_name": "Pair", "symbol": null}, + {"code": "NPR", "common_name": "Number of pairs", "symbol": null}, + {"code": "SET", "common_name": "Set", "symbol": null}, + {"code": "HUR", "common_name": "Hour", "symbol": "h"}, + {"code": "DAY", "common_name": "Day", "symbol": "d"}, + {"code": "KWH", "common_name": "Kilowatt hour", "symbol": "kWh"} + ] +} diff --git a/packages/data/database/data/codelists/vat-categories.json b/packages/data/database/data/codelists/vat-categories.json new file mode 100644 index 0000000000..d1d9c7898f --- /dev/null +++ b/packages/data/database/data/codelists/vat-categories.json @@ -0,0 +1,15 @@ +{ + "_source": "UNTDID 5305 — VAT category codes (EN 16931 subset)", + "_todo": "verify/complete against official UNTDID 5305", + "codes": [ + {"code": "S", "common_name": "Standard rate"}, + {"code": "Z", "common_name": "Zero rated"}, + {"code": "E", "common_name": "Exempt from tax"}, + {"code": "AE", "common_name": "VAT Reverse Charge"}, + {"code": "K", "common_name": "VAT exempt for EEA intra-community supply"}, + {"code": "G", "common_name": "Free export item, tax not charged"}, + {"code": "O", "common_name": "Services outside scope of tax"}, + {"code": "L", "common_name": "Canary Islands general indirect tax"}, + {"code": "M", "common_name": "Tax for production, services and importation in Ceuta and Melilla"} + ] +} diff --git a/packages/data/database/data/codelists/vat-exemption-reasons.json b/packages/data/database/data/codelists/vat-exemption-reasons.json new file mode 100644 index 0000000000..60eab4e375 --- /dev/null +++ b/packages/data/database/data/codelists/vat-exemption-reasons.json @@ -0,0 +1,16 @@ +{ + "_source": "CEF VATEX — VAT exemption reason codes (EN 16931 / Peppol)", + "_todo": "core EN subset + FR codes; complete remaining FR/country-specific codes from official CEF VATEX list", + "codes": [ + {"code": "VATEX-EU-AE", "common_name": "Reverse charge", "vat_category_code": "AE", "description": "Only use with VAT category code AE (reverse charge)."}, + {"code": "VATEX-EU-D", "common_name": "Travel agents VAT scheme", "vat_category_code": "E", "description": "Only use with VAT category code E."}, + {"code": "VATEX-EU-F", "common_name": "Second hand goods VAT scheme", "vat_category_code": "E", "description": "Only use with VAT category code E."}, + {"code": "VATEX-EU-G", "common_name": "Export outside the EU", "vat_category_code": "G", "description": "Only use with VAT category code G."}, + {"code": "VATEX-EU-I", "common_name": "Works of art VAT scheme", "vat_category_code": "E", "description": "Only use with VAT category code E."}, + {"code": "VATEX-EU-IC", "common_name": "Intra-community supply", "vat_category_code": "K", "description": "Only use with VAT category code K."}, + {"code": "VATEX-EU-J", "common_name": "Collectors items and antiques VAT scheme", "vat_category_code": "E", "description": "Only use with VAT category code E."}, + {"code": "VATEX-EU-O", "common_name": "Not subject to VAT", "vat_category_code": "O", "description": "Only use with VAT category code O."}, + {"code": "VATEX-FR-FRANCHISE", "common_name": "France domestic VAT franchise in base", "vat_category_code": null, "description": "For domestic invoicing in France."}, + {"code": "VATEX-FR-CNWVAT", "common_name": "France domestic Credit Notes without VAT, due to supplier forfeit of VAT for discount", "vat_category_code": null, "description": "For domestic Credit Notes only in France."} + ] +} diff --git a/packages/data/database/migrations/create_static_allowance_reasons_table.php.stub b/packages/data/database/migrations/create_static_allowance_reasons_table.php.stub new file mode 100644 index 0000000000..5833a388ce --- /dev/null +++ b/packages/data/database/migrations/create_static_allowance_reasons_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_allowance_reasons'); + } +}; diff --git a/packages/data/database/migrations/create_static_charge_reasons_table.php.stub b/packages/data/database/migrations/create_static_charge_reasons_table.php.stub new file mode 100644 index 0000000000..e1d961642d --- /dev/null +++ b/packages/data/database/migrations/create_static_charge_reasons_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_charge_reasons'); + } +}; diff --git a/packages/data/database/migrations/create_static_document_types_table.php.stub b/packages/data/database/migrations/create_static_document_types_table.php.stub new file mode 100644 index 0000000000..399178e44c --- /dev/null +++ b/packages/data/database/migrations/create_static_document_types_table.php.stub @@ -0,0 +1,25 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->string('en16931_interpretation')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_document_types'); + } +}; diff --git a/packages/data/database/migrations/create_static_eas_schemes_table.php.stub b/packages/data/database/migrations/create_static_eas_schemes_table.php.stub new file mode 100644 index 0000000000..9800f7d74b --- /dev/null +++ b/packages/data/database/migrations/create_static_eas_schemes_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_eas_schemes'); + } +}; diff --git a/packages/data/database/migrations/create_static_icd_schemes_table.php.stub b/packages/data/database/migrations/create_static_icd_schemes_table.php.stub new file mode 100644 index 0000000000..29181ba770 --- /dev/null +++ b/packages/data/database/migrations/create_static_icd_schemes_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_icd_schemes'); + } +}; diff --git a/packages/data/database/migrations/create_static_incoterms_table.php.stub b/packages/data/database/migrations/create_static_incoterms_table.php.stub new file mode 100644 index 0000000000..bbc82c5464 --- /dev/null +++ b/packages/data/database/migrations/create_static_incoterms_table.php.stub @@ -0,0 +1,27 @@ +id(); + $table->string('code', 10); + $table->string('version', 10); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['code', 'version']); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_incoterms'); + } +}; diff --git a/packages/data/database/migrations/create_static_payment_means_table.php.stub b/packages/data/database/migrations/create_static_payment_means_table.php.stub new file mode 100644 index 0000000000..ff2a8b65e6 --- /dev/null +++ b/packages/data/database/migrations/create_static_payment_means_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_payment_means'); + } +}; diff --git a/packages/data/database/migrations/create_static_units_table.php.stub b/packages/data/database/migrations/create_static_units_table.php.stub new file mode 100644 index 0000000000..27e258fb9c --- /dev/null +++ b/packages/data/database/migrations/create_static_units_table.php.stub @@ -0,0 +1,25 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->string('symbol', 20)->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_units'); + } +}; diff --git a/packages/data/database/migrations/create_static_vat_categories_table.php.stub b/packages/data/database/migrations/create_static_vat_categories_table.php.stub new file mode 100644 index 0000000000..2b9718ac10 --- /dev/null +++ b/packages/data/database/migrations/create_static_vat_categories_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->string('code', 10)->unique(); + $table->string('common_name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_vat_categories'); + } +}; diff --git a/packages/data/database/migrations/create_static_vat_exemption_reasons_table.php.stub b/packages/data/database/migrations/create_static_vat_exemption_reasons_table.php.stub new file mode 100644 index 0000000000..f68650e096 --- /dev/null +++ b/packages/data/database/migrations/create_static_vat_exemption_reasons_table.php.stub @@ -0,0 +1,25 @@ +id(); + $table->string('code', 64)->unique(); + $table->string('common_name'); + $table->string('vat_category_code', 10)->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('static_vat_exemption_reasons'); + } +}; diff --git a/packages/data/resources/lang/de/enums/allowance-reasons.php b/packages/data/resources/lang/de/enums/allowance-reasons.php new file mode 100644 index 0000000000..11ed4559ba --- /dev/null +++ b/packages/data/resources/lang/de/enums/allowance-reasons.php @@ -0,0 +1,26 @@ + 'Prämie für vorzeitige Arbeiten', + '42' => 'Sonstige Prämie', + '60' => 'Herstellerrabatt für Endverbraucher', + '62' => 'Aufgrund Militärstatus', + '63' => 'Aufgrund Arbeitsunfall', + '64' => 'Sondervereinbarung', + '65' => 'Produktionsfehlerrabatt', + '66' => 'Neueröffnungsrabatt', + '67' => 'Musterrabatt', + '68' => 'Restpostenrabatt', + '70' => 'Incoterm-Rabatt', + '71' => 'Umsatzschwellen-Nachlass', + '88' => 'Materialzuschlag/-abzug', + '95' => 'Rabatt', + '100' => 'Sonderrabatt', + '102' => 'Fest langfristig', + '103' => 'Temporär', + '104' => 'Standard', + '105' => 'Jahresumsatz', +]; diff --git a/packages/data/resources/lang/de/enums/charge-reasons.php b/packages/data/resources/lang/de/enums/charge-reasons.php new file mode 100644 index 0000000000..be661322a9 --- /dev/null +++ b/packages/data/resources/lang/de/enums/charge-reasons.php @@ -0,0 +1,16 @@ + 'Werbung', + 'AAA' => 'Telekommunikation', + 'FC' => 'Frachtkosten', + 'HD' => 'Bearbeitung', + 'WH' => 'Lagerung', + 'PC' => 'Verpackung', + 'DL' => 'Lieferung', + 'SH' => 'Sonderbehandlung', + 'ZZZ' => 'Gegenseitig vereinbart', +]; diff --git a/packages/data/resources/lang/de/enums/document-types.php b/packages/data/resources/lang/de/enums/document-types.php new file mode 100644 index 0000000000..ccc20b6b2f --- /dev/null +++ b/packages/data/resources/lang/de/enums/document-types.php @@ -0,0 +1,61 @@ + 'Request for payment', + '80' => 'Debit note related to goods/services', + '81' => 'Credit note related to goods/services', + '82' => 'Metered services invoice', + '83' => 'Credit note related to financial adjustments', + '84' => 'Debit note related to financial adjustments', + '102' => 'Tax notification', + '130' => 'Invoicing data sheet', + '202' => 'Direct payment valuation', + '203' => 'Provisional payment valuation', + '204' => 'Payment valuation', + '211' => 'Interim application for payment', + '218' => 'Final payment request', + '219' => 'Payment request for completed units', + '261' => 'Self billed credit note', + '262' => 'Consolidated credit note', + '295' => 'Price variation invoice', + '296' => 'Credit note for price variation', + '308' => 'Delcredere credit note', + '325' => 'Proforma-Rechnung', + '326' => 'Teilrechnung', + '331' => 'Commercial invoice with packing list', + '380' => 'Handelsrechnung', + '381' => 'Gutschrift', + '382' => 'Commission note', + '383' => 'Belastungsanzeige', + '384' => 'Berichtigte Rechnung', + '385' => 'Consolidated invoice', + '386' => 'Prepayment invoice', + '387' => 'Hire invoice', + '388' => 'Tax invoice', + '389' => 'Gutschrift (Selbstfakturierung)', + '390' => 'Delcredere invoice', + '393' => 'Factored invoice', + '394' => 'Lease invoice', + '395' => 'Consignment invoice', + '396' => 'Factored credit note', + '420' => 'OCR payment credit note', + '456' => 'Debit advice', + '457' => 'Reversal of debit', + '458' => 'Reversal of credit', + '527' => 'Self billed debit note', + '532' => 'Forwarder\'s credit note', + '575' => 'Insurer\'s invoice', + '623' => 'Forwarder\'s invoice', + '633' => 'Port charges documents', + '751' => 'Invoice information for accounting purposes', + '780' => 'Freight invoice', + '817' => 'Claim notification', + '870' => 'Consular invoice', + '875' => 'Partial construction invoice', + '876' => 'Partial final construction invoice', + '877' => 'Final construction invoice', + '935' => 'Customs invoice', +]; diff --git a/packages/data/resources/lang/de/enums/eas-schemes.php b/packages/data/resources/lang/de/enums/eas-schemes.php new file mode 100644 index 0000000000..896e632dc7 --- /dev/null +++ b/packages/data/resources/lang/de/enums/eas-schemes.php @@ -0,0 +1,17 @@ + 'SIRENE (französisches Unternehmensregister)', + '0060' => 'D-U-N-S-Nummer', + '0088' => 'EAN-Standortcode (GLN)', + '0096' => 'Dänische Geschäftsbehörde – P-Nummer (DK:P)', + '0184' => 'DIGSTORG', + '0192' => 'Norwegisches Enhetsregister', + '0204' => 'Leitweg-ID', + '0208' => 'Belgische Unternehmensnummer', + '9918' => 'SWIFT (internationale Bankverbindung)', + '9930' => 'Deutsche Umsatzsteuer-Identifikationsnummer', +]; diff --git a/packages/data/resources/lang/de/enums/icd-schemes.php b/packages/data/resources/lang/de/enums/icd-schemes.php new file mode 100644 index 0000000000..151118659a --- /dev/null +++ b/packages/data/resources/lang/de/enums/icd-schemes.php @@ -0,0 +1,15 @@ + 'SIRENE (französisches Unternehmensregister)', + '0060' => 'D-U-N-S-Nummer', + '0088' => 'EAN-Standortcode (GLN)', + '0096' => 'Dänische Geschäftsbehörde – P-Nummer (DK:P)', + '0184' => 'DIGSTORG', + '0204' => 'Leitweg-ID', + '9918' => 'SWIFT (internationale Bankverbindung)', + '9930' => 'Deutsche Umsatzsteuer-Identifikationsnummer', +]; diff --git a/packages/data/resources/lang/de/enums/incoterms.php b/packages/data/resources/lang/de/enums/incoterms.php new file mode 100644 index 0000000000..7aa5479962 --- /dev/null +++ b/packages/data/resources/lang/de/enums/incoterms.php @@ -0,0 +1,18 @@ + 'Ab Werk', + 'FCA' => 'Frei Frachtführer', + 'CPT' => 'Frachtfrei', + 'CIP' => 'Frachtfrei versichert', + 'DAP' => 'Geliefert benannter Ort', + 'DPU' => 'Geliefert benannter Ort entladen', + 'DDP' => 'Geliefert verzollt', + 'FAS' => 'Frei Längsseite Schiff', + 'FOB' => 'Frei an Bord', + 'CFR' => 'Kosten und Fracht', + 'CIF' => 'Kosten, Versicherung und Fracht', +]; diff --git a/packages/data/resources/lang/de/enums/payment-means.php b/packages/data/resources/lang/de/enums/payment-means.php new file mode 100644 index 0000000000..9b7a57bcb4 --- /dev/null +++ b/packages/data/resources/lang/de/enums/payment-means.php @@ -0,0 +1,17 @@ + 'Bar', + '20' => 'Scheck', + '30' => 'Überweisung', + '42' => 'Zahlung auf Bankkonto', + '48' => 'Bankkarte', + '49' => 'Lastschrift', + '57' => 'Dauerauftrag/vereinbarte Zahlung', + '58' => 'SEPA-Überweisung', + '59' => 'SEPA-Lastschrift', + '97' => 'Verrechnung zwischen Partnern', +]; diff --git a/packages/data/resources/lang/de/enums/units.php b/packages/data/resources/lang/de/enums/units.php new file mode 100644 index 0000000000..c0b56fb1db --- /dev/null +++ b/packages/data/resources/lang/de/enums/units.php @@ -0,0 +1,22 @@ + 'Stück (eins)', + 'MTR' => 'Meter', + 'KGM' => 'Kilogramm', + 'GRM' => 'Gramm', + 'LTR' => 'Liter', + 'MTK' => 'Quadratmeter', + 'MTQ' => 'Kubikmeter', + 'TNE' => 'Tonne', + 'H87' => 'Stück', + 'PR' => 'Paar', + 'NPR' => 'Anzahl Paare', + 'SET' => 'Satz', + 'HUR' => 'Stunde', + 'DAY' => 'Tag', + 'KWH' => 'Kilowattstunde', +]; diff --git a/packages/data/resources/lang/de/enums/vat-categories.php b/packages/data/resources/lang/de/enums/vat-categories.php new file mode 100644 index 0000000000..b8de6de229 --- /dev/null +++ b/packages/data/resources/lang/de/enums/vat-categories.php @@ -0,0 +1,16 @@ + 'Normalsatz', + 'Z' => 'Nullsatz', + 'E' => 'Steuerbefreit', + 'AE' => 'Reverse Charge', + 'K' => 'Innergemeinschaftliche Lieferung', + 'G' => 'Ausfuhr außerhalb der EU', + 'O' => 'Außerhalb des Anwendungsbereichs', + 'L' => 'Kanarische Inseln', + 'M' => 'Ceuta und Melilla', +]; diff --git a/packages/data/resources/lang/de/enums/vat-exemption-reasons.php b/packages/data/resources/lang/de/enums/vat-exemption-reasons.php new file mode 100644 index 0000000000..b9127cf071 --- /dev/null +++ b/packages/data/resources/lang/de/enums/vat-exemption-reasons.php @@ -0,0 +1,17 @@ + 'Reverse Charge', + 'VATEX-EU-D' => 'Reisebüro-Sonderregelung', + 'VATEX-EU-F' => 'Gebrauchtwaren-Sonderregelung', + 'VATEX-EU-G' => 'Ausfuhr außerhalb der EU', + 'VATEX-EU-I' => 'Kunstgegenstände-Sonderregelung', + 'VATEX-EU-IC' => 'Innergemeinschaftliche Lieferung', + 'VATEX-EU-J' => 'Sammlerstücke und Antiquitäten-Sonderregelung', + 'VATEX-EU-O' => 'Nicht steuerbar', + 'VATEX-FR-FRANCHISE' => 'Französische Inlands-Kleinunternehmerregelung', + 'VATEX-FR-CNWVAT' => 'Französische Inlands-Gutschriften ohne USt', +]; diff --git a/packages/data/resources/lang/de/fields.php b/packages/data/resources/lang/de/fields.php index 953b03aaa9..b9ae53d771 100644 --- a/packages/data/resources/lang/de/fields.php +++ b/packages/data/resources/lang/de/fields.php @@ -47,6 +47,12 @@ 'symbol' => 'Symbol', 'currency_name' => 'Währungsname', + // EN 16931 codelists + 'description' => 'Beschreibung', + 'en16931_interpretation' => 'EN-16931-Interpretation', + 'vat_category_code' => 'Umsatzsteuerkategorie-Code', + 'version' => 'Version', + // Static Locale 'locale' => 'Sprache', 'name' => 'Name', diff --git a/packages/data/resources/lang/de/static-allowance-reason.php b/packages/data/resources/lang/de/static-allowance-reason.php new file mode 100644 index 0000000000..e1dfbf772c --- /dev/null +++ b/packages/data/resources/lang/de/static-allowance-reason.php @@ -0,0 +1,6 @@ + 'Nachlass', + 'static_allowance_reasons' => 'Nachlässe', +]; diff --git a/packages/data/resources/lang/de/static-charge-reason.php b/packages/data/resources/lang/de/static-charge-reason.php new file mode 100644 index 0000000000..e6f6593e37 --- /dev/null +++ b/packages/data/resources/lang/de/static-charge-reason.php @@ -0,0 +1,6 @@ + 'Gebühr', + 'static_charge_reasons' => 'Gebühren', +]; diff --git a/packages/data/resources/lang/de/static-document-type.php b/packages/data/resources/lang/de/static-document-type.php new file mode 100644 index 0000000000..b464f1e870 --- /dev/null +++ b/packages/data/resources/lang/de/static-document-type.php @@ -0,0 +1,6 @@ + 'Dokumententyp', + 'static_document_types' => 'Dokumententypen', +]; diff --git a/packages/data/resources/lang/de/static-eas-scheme.php b/packages/data/resources/lang/de/static-eas-scheme.php new file mode 100644 index 0000000000..9b07be17d7 --- /dev/null +++ b/packages/data/resources/lang/de/static-eas-scheme.php @@ -0,0 +1,6 @@ + 'EAS-Schema', + 'static_eas_schemes' => 'EAS-Schemata', +]; diff --git a/packages/data/resources/lang/de/static-icd-scheme.php b/packages/data/resources/lang/de/static-icd-scheme.php new file mode 100644 index 0000000000..17d4fba1d6 --- /dev/null +++ b/packages/data/resources/lang/de/static-icd-scheme.php @@ -0,0 +1,6 @@ + 'ICD-Schema', + 'static_icd_schemes' => 'ICD-Schemata', +]; diff --git a/packages/data/resources/lang/de/static-incoterm.php b/packages/data/resources/lang/de/static-incoterm.php new file mode 100644 index 0000000000..f6fabf49ab --- /dev/null +++ b/packages/data/resources/lang/de/static-incoterm.php @@ -0,0 +1,6 @@ + 'Incoterm', + 'static_incoterms' => 'Incoterms', +]; diff --git a/packages/data/resources/lang/de/static-payment-mean.php b/packages/data/resources/lang/de/static-payment-mean.php new file mode 100644 index 0000000000..fc3038e819 --- /dev/null +++ b/packages/data/resources/lang/de/static-payment-mean.php @@ -0,0 +1,6 @@ + 'Zahlungsart', + 'static_payment_means' => 'Zahlungsarten', +]; diff --git a/packages/data/resources/lang/de/static-unit.php b/packages/data/resources/lang/de/static-unit.php new file mode 100644 index 0000000000..22579223f3 --- /dev/null +++ b/packages/data/resources/lang/de/static-unit.php @@ -0,0 +1,6 @@ + 'Einheit', + 'static_units' => 'Einheiten', +]; diff --git a/packages/data/resources/lang/de/static-vat-category.php b/packages/data/resources/lang/de/static-vat-category.php new file mode 100644 index 0000000000..8d01a3ef3b --- /dev/null +++ b/packages/data/resources/lang/de/static-vat-category.php @@ -0,0 +1,6 @@ + 'Umsatzsteuerkategorie', + 'static_vat_categories' => 'Umsatzsteuerkategorien', +]; diff --git a/packages/data/resources/lang/de/static-vat-exemption-reason.php b/packages/data/resources/lang/de/static-vat-exemption-reason.php new file mode 100644 index 0000000000..0cfa50f1fa --- /dev/null +++ b/packages/data/resources/lang/de/static-vat-exemption-reason.php @@ -0,0 +1,6 @@ + 'Steuerbefreiungsgrund', + 'static_vat_exemption_reasons' => 'Steuerbefreiungsgründe', +]; diff --git a/packages/data/resources/lang/en/enums/allowance-reasons.php b/packages/data/resources/lang/en/enums/allowance-reasons.php new file mode 100644 index 0000000000..e490797d7b --- /dev/null +++ b/packages/data/resources/lang/en/enums/allowance-reasons.php @@ -0,0 +1,26 @@ + 'Bonus for works ahead of schedule', + '42' => 'Other bonus', + '60' => 'Manufacturer\'s consumer discount', + '62' => 'Due to military status', + '63' => 'Due to work accident', + '64' => 'Special agreement', + '65' => 'Production error discount', + '66' => 'New outlet discount', + '67' => 'Sample discount', + '68' => 'End-of-range discount', + '70' => 'Incoterm discount', + '71' => 'Point of sales threshold allowance', + '88' => 'Material surcharge/deduction', + '95' => 'Discount', + '100' => 'Special rebate', + '102' => 'Fixed long term', + '103' => 'Temporary', + '104' => 'Standard', + '105' => 'Yearly turnover', +]; diff --git a/packages/data/resources/lang/en/enums/charge-reasons.php b/packages/data/resources/lang/en/enums/charge-reasons.php new file mode 100644 index 0000000000..25871df96b --- /dev/null +++ b/packages/data/resources/lang/en/enums/charge-reasons.php @@ -0,0 +1,16 @@ + 'Advertising', + 'AAA' => 'Telecommunication', + 'FC' => 'Freight charge', + 'HD' => 'Handling', + 'WH' => 'Warehousing', + 'PC' => 'Packing', + 'DL' => 'Delivery', + 'SH' => 'Special handling', + 'ZZZ' => 'Mutually defined', +]; diff --git a/packages/data/resources/lang/en/enums/document-types.php b/packages/data/resources/lang/en/enums/document-types.php new file mode 100644 index 0000000000..80266a7513 --- /dev/null +++ b/packages/data/resources/lang/en/enums/document-types.php @@ -0,0 +1,61 @@ + 'Request for payment', + '80' => 'Debit note related to goods/services', + '81' => 'Credit note related to goods/services', + '82' => 'Metered services invoice', + '83' => 'Credit note related to financial adjustments', + '84' => 'Debit note related to financial adjustments', + '102' => 'Tax notification', + '130' => 'Invoicing data sheet', + '202' => 'Direct payment valuation', + '203' => 'Provisional payment valuation', + '204' => 'Payment valuation', + '211' => 'Interim application for payment', + '218' => 'Final payment request', + '219' => 'Payment request for completed units', + '261' => 'Self billed credit note', + '262' => 'Consolidated credit note', + '295' => 'Price variation invoice', + '296' => 'Credit note for price variation', + '308' => 'Delcredere credit note', + '325' => 'Proforma invoice', + '326' => 'Partial invoice', + '331' => 'Commercial invoice with packing list', + '380' => 'Commercial invoice', + '381' => 'Credit note', + '382' => 'Commission note', + '383' => 'Debit note', + '384' => 'Corrected invoice', + '385' => 'Consolidated invoice', + '386' => 'Prepayment invoice', + '387' => 'Hire invoice', + '388' => 'Tax invoice', + '389' => 'Self-billed invoice', + '390' => 'Delcredere invoice', + '393' => 'Factored invoice', + '394' => 'Lease invoice', + '395' => 'Consignment invoice', + '396' => 'Factored credit note', + '420' => 'OCR payment credit note', + '456' => 'Debit advice', + '457' => 'Reversal of debit', + '458' => 'Reversal of credit', + '527' => 'Self billed debit note', + '532' => 'Forwarder\'s credit note', + '575' => 'Insurer\'s invoice', + '623' => 'Forwarder\'s invoice', + '633' => 'Port charges documents', + '751' => 'Invoice information for accounting purposes', + '780' => 'Freight invoice', + '817' => 'Claim notification', + '870' => 'Consular invoice', + '875' => 'Partial construction invoice', + '876' => 'Partial final construction invoice', + '877' => 'Final construction invoice', + '935' => 'Customs invoice', +]; diff --git a/packages/data/resources/lang/en/enums/eas-schemes.php b/packages/data/resources/lang/en/enums/eas-schemes.php new file mode 100644 index 0000000000..23d67a77d3 --- /dev/null +++ b/packages/data/resources/lang/en/enums/eas-schemes.php @@ -0,0 +1,17 @@ + 'System Information et Repertoire des Entreprise et des Etablissements: SIRENE', + '0060' => 'Data Universal Numbering System (D-U-N-S Number)', + '0088' => 'EAN Location Code', + '0096' => 'The Danish Business Authority - P-number (DK:P)', + '0184' => 'DIGSTORG', + '0192' => 'Enhetsregisteret ved Bronnoysundregisterne', + '0204' => 'Leitweg-ID', + '0208' => "Numero d'entreprise / ondernemingsnummer / Unternehmensnummer", + '9918' => 'SOCIETY FOR WORLDWIDE INTERBANK FINANCIAL, TELECOMMUNICATION S.W.I.F.T', + '9930' => 'Germany VAT number', +]; diff --git a/packages/data/resources/lang/en/enums/icd-schemes.php b/packages/data/resources/lang/en/enums/icd-schemes.php new file mode 100644 index 0000000000..e5f3956b82 --- /dev/null +++ b/packages/data/resources/lang/en/enums/icd-schemes.php @@ -0,0 +1,15 @@ + 'System Information et Repertoire des Entreprise et des Etablissements: SIRENE', + '0060' => 'Data Universal Numbering System (D-U-N-S Number)', + '0088' => 'EAN Location Code', + '0096' => 'The Danish Business Authority - P-number (DK:P)', + '0184' => 'DIGSTORG', + '0204' => 'Leitweg-ID', + '9918' => 'SOCIETY FOR WORLDWIDE INTERBANK FINANCIAL, TELECOMMUNICATION S.W.I.F.T', + '9930' => 'Germany VAT number', +]; diff --git a/packages/data/resources/lang/en/enums/incoterms.php b/packages/data/resources/lang/en/enums/incoterms.php new file mode 100644 index 0000000000..ff8efc913e --- /dev/null +++ b/packages/data/resources/lang/en/enums/incoterms.php @@ -0,0 +1,18 @@ + 'Ex Works', + 'FCA' => 'Free Carrier', + 'CPT' => 'Carriage Paid To', + 'CIP' => 'Carriage and Insurance Paid To', + 'DAP' => 'Delivered At Place', + 'DPU' => 'Delivered at Place Unloaded', + 'DDP' => 'Delivered Duty Paid', + 'FAS' => 'Free Alongside Ship', + 'FOB' => 'Free On Board', + 'CFR' => 'Cost and Freight', + 'CIF' => 'Cost, Insurance and Freight', +]; diff --git a/packages/data/resources/lang/en/enums/payment-means.php b/packages/data/resources/lang/en/enums/payment-means.php new file mode 100644 index 0000000000..79e6934df2 --- /dev/null +++ b/packages/data/resources/lang/en/enums/payment-means.php @@ -0,0 +1,17 @@ + 'In cash', + '20' => 'Cheque', + '30' => 'Credit transfer', + '42' => 'Payment to bank account', + '48' => 'Bank card', + '49' => 'Direct debit', + '57' => 'Standing agreement', + '58' => 'SEPA credit transfer', + '59' => 'SEPA direct debit', + '97' => 'Clearing between partners', +]; diff --git a/packages/data/resources/lang/en/enums/units.php b/packages/data/resources/lang/en/enums/units.php new file mode 100644 index 0000000000..edcb97fdc9 --- /dev/null +++ b/packages/data/resources/lang/en/enums/units.php @@ -0,0 +1,22 @@ + 'One (piece)', + 'MTR' => 'Metre', + 'KGM' => 'Kilogram', + 'GRM' => 'Gram', + 'LTR' => 'Litre', + 'MTK' => 'Square metre', + 'MTQ' => 'Cubic metre', + 'TNE' => 'Tonne', + 'H87' => 'Piece', + 'PR' => 'Pair', + 'NPR' => 'Number of pairs', + 'SET' => 'Set', + 'HUR' => 'Hour', + 'DAY' => 'Day', + 'KWH' => 'Kilowatt hour', +]; diff --git a/packages/data/resources/lang/en/enums/vat-categories.php b/packages/data/resources/lang/en/enums/vat-categories.php new file mode 100644 index 0000000000..ad3edd765e --- /dev/null +++ b/packages/data/resources/lang/en/enums/vat-categories.php @@ -0,0 +1,16 @@ + 'Standard rate', + 'Z' => 'Zero rated', + 'E' => 'Exempt from tax', + 'AE' => 'VAT Reverse Charge', + 'K' => 'VAT exempt for EEA intra-community supply', + 'G' => 'Free export item, tax not charged', + 'O' => 'Services outside scope of tax', + 'L' => 'Canary Islands general indirect tax', + 'M' => 'Tax for production, services and importation in Ceuta and Melilla', +]; diff --git a/packages/data/resources/lang/en/enums/vat-exemption-reasons.php b/packages/data/resources/lang/en/enums/vat-exemption-reasons.php new file mode 100644 index 0000000000..b69277eee7 --- /dev/null +++ b/packages/data/resources/lang/en/enums/vat-exemption-reasons.php @@ -0,0 +1,17 @@ + 'Reverse charge', + 'VATEX-EU-D' => 'Travel agents VAT scheme', + 'VATEX-EU-F' => 'Second hand goods VAT scheme', + 'VATEX-EU-G' => 'Export outside the EU', + 'VATEX-EU-I' => 'Works of art VAT scheme', + 'VATEX-EU-IC' => 'Intra-community supply', + 'VATEX-EU-J' => 'Collectors items and antiques VAT scheme', + 'VATEX-EU-O' => 'Not subject to VAT', + 'VATEX-FR-FRANCHISE' => 'France domestic VAT franchise in base', + 'VATEX-FR-CNWVAT' => 'France domestic Credit Notes without VAT, due to supplier forfeit of VAT for discount', +]; diff --git a/packages/data/resources/lang/en/fields.php b/packages/data/resources/lang/en/fields.php index 4becf6e917..09ad9ca69d 100644 --- a/packages/data/resources/lang/en/fields.php +++ b/packages/data/resources/lang/en/fields.php @@ -47,6 +47,12 @@ 'symbol' => 'Symbol', 'currency_name' => 'Currency Name', + // EN 16931 codelists + 'description' => 'Description', + 'en16931_interpretation' => 'EN 16931 interpretation', + 'vat_category_code' => 'VAT category code', + 'version' => 'Version', + // Static Locale 'locale' => 'Locale', 'name' => 'Name', diff --git a/packages/data/resources/lang/en/static-allowance-reason.php b/packages/data/resources/lang/en/static-allowance-reason.php new file mode 100644 index 0000000000..b640d64fa1 --- /dev/null +++ b/packages/data/resources/lang/en/static-allowance-reason.php @@ -0,0 +1,6 @@ + 'Static Allowance Reason', + 'static_allowance_reasons' => 'Static Allowance Reasons', +]; diff --git a/packages/data/resources/lang/en/static-charge-reason.php b/packages/data/resources/lang/en/static-charge-reason.php new file mode 100644 index 0000000000..59e27e0c19 --- /dev/null +++ b/packages/data/resources/lang/en/static-charge-reason.php @@ -0,0 +1,6 @@ + 'Static Charge Reason', + 'static_charge_reasons' => 'Static Charge Reasons', +]; diff --git a/packages/data/resources/lang/en/static-document-type.php b/packages/data/resources/lang/en/static-document-type.php new file mode 100644 index 0000000000..4b03087fac --- /dev/null +++ b/packages/data/resources/lang/en/static-document-type.php @@ -0,0 +1,6 @@ + 'Static Document Type', + 'static_document_types' => 'Static Document Types', +]; diff --git a/packages/data/resources/lang/en/static-eas-scheme.php b/packages/data/resources/lang/en/static-eas-scheme.php new file mode 100644 index 0000000000..a30b33b5d9 --- /dev/null +++ b/packages/data/resources/lang/en/static-eas-scheme.php @@ -0,0 +1,6 @@ + 'Static EAS Scheme', + 'static_eas_schemes' => 'Static EAS Schemes', +]; diff --git a/packages/data/resources/lang/en/static-icd-scheme.php b/packages/data/resources/lang/en/static-icd-scheme.php new file mode 100644 index 0000000000..41e45ef0de --- /dev/null +++ b/packages/data/resources/lang/en/static-icd-scheme.php @@ -0,0 +1,6 @@ + 'Static ICD Scheme', + 'static_icd_schemes' => 'Static ICD Schemes', +]; diff --git a/packages/data/resources/lang/en/static-incoterm.php b/packages/data/resources/lang/en/static-incoterm.php new file mode 100644 index 0000000000..b4691968cc --- /dev/null +++ b/packages/data/resources/lang/en/static-incoterm.php @@ -0,0 +1,6 @@ + 'Static Incoterm', + 'static_incoterms' => 'Static Incoterms', +]; diff --git a/packages/data/resources/lang/en/static-payment-mean.php b/packages/data/resources/lang/en/static-payment-mean.php new file mode 100644 index 0000000000..3eab568705 --- /dev/null +++ b/packages/data/resources/lang/en/static-payment-mean.php @@ -0,0 +1,6 @@ + 'Static Payment Mean', + 'static_payment_means' => 'Static Payment Means', +]; diff --git a/packages/data/resources/lang/en/static-unit.php b/packages/data/resources/lang/en/static-unit.php new file mode 100644 index 0000000000..8e1ee61853 --- /dev/null +++ b/packages/data/resources/lang/en/static-unit.php @@ -0,0 +1,6 @@ + 'Static Unit', + 'static_units' => 'Static Units', +]; diff --git a/packages/data/resources/lang/en/static-vat-category.php b/packages/data/resources/lang/en/static-vat-category.php new file mode 100644 index 0000000000..ab2675caed --- /dev/null +++ b/packages/data/resources/lang/en/static-vat-category.php @@ -0,0 +1,6 @@ + 'Static VAT Category', + 'static_vat_categories' => 'Static VAT Categories', +]; diff --git a/packages/data/resources/lang/en/static-vat-exemption-reason.php b/packages/data/resources/lang/en/static-vat-exemption-reason.php new file mode 100644 index 0000000000..a1b9609ec2 --- /dev/null +++ b/packages/data/resources/lang/en/static-vat-exemption-reason.php @@ -0,0 +1,6 @@ + 'Static VAT Exemption Reason', + 'static_vat_exemption_reasons' => 'Static VAT Exemption Reasons', +]; diff --git a/packages/data/src/Console/Commands/ImportCodelistsCommand.php b/packages/data/src/Console/Commands/ImportCodelistsCommand.php new file mode 100644 index 0000000000..f3e99d3f72 --- /dev/null +++ b/packages/data/src/Console/Commands/ImportCodelistsCommand.php @@ -0,0 +1,35 @@ +argument('scheme'); + $scheme = is_string($scheme) && $scheme !== '' ? $scheme : null; + + $this->info('Starting codelist import...'); + + try { + $count = $importer->import($scheme); + } catch (\Throwable $e) { + $this->error('Codelist import failed: '.$e->getMessage()); + + return self::FAILURE; + } + + $this->info("Codelist import completed ({$count} rows upserted)."); + + return self::SUCCESS; + } +} diff --git a/packages/data/src/DataServiceProvider.php b/packages/data/src/DataServiceProvider.php index 7c416e2a52..4bbb470d86 100644 --- a/packages/data/src/DataServiceProvider.php +++ b/packages/data/src/DataServiceProvider.php @@ -6,13 +6,22 @@ use Moox\Core\Installer\Contracts\AssetInstallerInterface; use Moox\Core\MooxServiceProvider; +use Moox\Data\Console\Commands\ImportCodelistsCommand; use Moox\Data\Console\Commands\ImportStaticDataCommand; use Moox\Data\Filament\Providers\DataPanelProvider; +use Moox\Data\Installers\StaticCodelistsInstaller; use Moox\Data\Installers\StaticDataInstaller; use Spatie\LaravelPackageTools\Package; class DataServiceProvider extends MooxServiceProvider { + public function boot(): void + { + parent::boot(); + + $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'data'); + } + public function register(): void { parent::register(); @@ -35,6 +44,16 @@ public function mergeConfigFiles() 'static-language' => 'data/static-language', 'static-locale' => 'data/static-locale', 'static-timezone' => 'data/static-timezones', + 'static-charge-reason' => 'data/static-charge-reason', + 'static-allowance-reason' => 'data/static-allowance-reason', + 'static-document-type' => 'data/static-document-type', + 'static-vat-category' => 'data/static-vat-category', + 'static-payment-mean' => 'data/static-payment-mean', + 'static-unit' => 'data/static-unit', + 'static-incoterm' => 'data/static-incoterm', + 'static-vat-exemption-reason' => 'data/static-vat-exemption-reason', + 'static-icd-scheme' => 'data/static-icd-scheme', + 'static-eas-scheme' => 'data/static-eas-scheme', ]; foreach ($configs as $file => $namespace) { @@ -46,10 +65,13 @@ public function configureMoox(Package $package): void { $package ->name('data') - ->hasConfigFile(['data', 'static-countries-static-currencies', 'static-countries-static-timezones', 'static-country', 'static-currency', 'static-language', 'static-locale', 'static-timezone']) + ->hasConfigFile(['data', 'static-countries-static-currencies', 'static-countries-static-timezones', 'static-country', 'static-currency', 'static-language', 'static-locale', 'static-timezone', 'static-charge-reason', 'static-allowance-reason', 'static-document-type', 'static-vat-category', 'static-payment-mean', 'static-unit', 'static-incoterm', 'static-vat-exemption-reason', 'static-icd-scheme', 'static-eas-scheme']) ->hasViews() ->hasTranslations() - ->hasCommand(ImportStaticDataCommand::class) + ->hasCommands([ + ImportStaticDataCommand::class, + ImportCodelistsCommand::class, + ]) ->hasMigrations([ 'create_static_countries_table', 'create_static_languages_table', @@ -58,6 +80,16 @@ public function configureMoox(Package $package): void 'create_static_timezones_table', 'create_static_countries_static_currencies_table', 'create_static_country_static_timezones_table', + 'create_static_charge_reasons_table', + 'create_static_allowance_reasons_table', + 'create_static_document_types_table', + 'create_static_vat_categories_table', + 'create_static_payment_means_table', + 'create_static_units_table', + 'create_static_incoterms_table', + 'create_static_vat_exemption_reasons_table', + 'create_static_icd_schemes_table', + 'create_static_eas_schemes_table', ]); } @@ -70,6 +102,7 @@ public function getCustomInstallers(): array { return [ new StaticDataInstaller, + new StaticCodelistsInstaller, ]; } @@ -85,6 +118,12 @@ public function getCustomInstallAssets(): array 'import-rest-countries-static-data', ], ], + [ + 'type' => 'static-codelists', + 'data' => [ + 'import-committed-codelists', + ], + ], ]; } } diff --git a/packages/data/src/Filament/Plugins/StaticAllowanceReasonPlugin.php b/packages/data/src/Filament/Plugins/StaticAllowanceReasonPlugin.php new file mode 100644 index 0000000000..1ab90b487d --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticAllowanceReasonPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticAllowanceReasonResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticChargeReasonPlugin.php b/packages/data/src/Filament/Plugins/StaticChargeReasonPlugin.php new file mode 100644 index 0000000000..17d75bcc11 --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticChargeReasonPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticChargeReasonResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticDocumentTypePlugin.php b/packages/data/src/Filament/Plugins/StaticDocumentTypePlugin.php new file mode 100644 index 0000000000..6f810dccd6 --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticDocumentTypePlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticDocumentTypeResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticEasSchemePlugin.php b/packages/data/src/Filament/Plugins/StaticEasSchemePlugin.php new file mode 100644 index 0000000000..9668685ab4 --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticEasSchemePlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticEasSchemeResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticIcdSchemePlugin.php b/packages/data/src/Filament/Plugins/StaticIcdSchemePlugin.php new file mode 100644 index 0000000000..d6afa91510 --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticIcdSchemePlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticIcdSchemeResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticIncotermPlugin.php b/packages/data/src/Filament/Plugins/StaticIncotermPlugin.php new file mode 100644 index 0000000000..c7464fd6be --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticIncotermPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticIncotermResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticPaymentMeanPlugin.php b/packages/data/src/Filament/Plugins/StaticPaymentMeanPlugin.php new file mode 100644 index 0000000000..41c9a423ed --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticPaymentMeanPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticPaymentMeanResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticUnitPlugin.php b/packages/data/src/Filament/Plugins/StaticUnitPlugin.php new file mode 100644 index 0000000000..ef326daaec --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticUnitPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticUnitResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticVatCategoryPlugin.php b/packages/data/src/Filament/Plugins/StaticVatCategoryPlugin.php new file mode 100644 index 0000000000..c577de243f --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticVatCategoryPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticVatCategoryResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Plugins/StaticVatExemptionReasonPlugin.php b/packages/data/src/Filament/Plugins/StaticVatExemptionReasonPlugin.php new file mode 100644 index 0000000000..5ba4491d05 --- /dev/null +++ b/packages/data/src/Filament/Plugins/StaticVatExemptionReasonPlugin.php @@ -0,0 +1,37 @@ +resources([ + StaticVatExemptionReasonResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticAllowanceReasonResource.php b/packages/data/src/Filament/Resources/StaticAllowanceReasonResource.php new file mode 100644 index 0000000000..e226b1a0e7 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticAllowanceReasonResource.php @@ -0,0 +1,189 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticAllowanceReasons::route('/'), + 'create' => CreateStaticAllowanceReason::route('/create'), + 'edit' => EditStaticAllowanceReason::route('/{record}/edit'), + 'view' => ViewStaticAllowanceReason::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticAllowanceReasonResource/Pages/CreateStaticAllowanceReason.php b/packages/data/src/Filament/Resources/StaticAllowanceReasonResource/Pages/CreateStaticAllowanceReason.php new file mode 100644 index 0000000000..515947385a --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticAllowanceReasonResource/Pages/CreateStaticAllowanceReason.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-allowance-reason.tabs', StaticAllowanceReason::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticAllowanceReasonResource/Pages/ViewStaticAllowanceReason.php b/packages/data/src/Filament/Resources/StaticAllowanceReasonResource/Pages/ViewStaticAllowanceReason.php new file mode 100644 index 0000000000..e65d4b3c68 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticAllowanceReasonResource/Pages/ViewStaticAllowanceReason.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticChargeReasons::route('/'), + 'create' => CreateStaticChargeReason::route('/create'), + 'edit' => EditStaticChargeReason::route('/{record}/edit'), + 'view' => ViewStaticChargeReason::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticChargeReasonResource/Pages/CreateStaticChargeReason.php b/packages/data/src/Filament/Resources/StaticChargeReasonResource/Pages/CreateStaticChargeReason.php new file mode 100644 index 0000000000..f37f7f53ce --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticChargeReasonResource/Pages/CreateStaticChargeReason.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-charge-reason.tabs', StaticChargeReason::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticChargeReasonResource/Pages/ViewStaticChargeReason.php b/packages/data/src/Filament/Resources/StaticChargeReasonResource/Pages/ViewStaticChargeReason.php new file mode 100644 index 0000000000..cd534b7485 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticChargeReasonResource/Pages/ViewStaticChargeReason.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + TextInput::make('en16931_interpretation') + ->label(__('data::fields.en16931_interpretation')) + ->maxLength(50), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('en16931_interpretation') + ->label(__('data::fields.en16931_interpretation')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + Filter::make('en16931_interpretation') + ->schema([ + TextInput::make('en16931_interpretation') + ->label(__('data::fields.en16931_interpretation')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['en16931_interpretation'], + fn (Builder $query, $value): Builder => $query->where('en16931_interpretation', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['en16931_interpretation']) { + return null; + } + + return 'EN 16931: '.$data['en16931_interpretation']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticDocumentTypes::route('/'), + 'create' => CreateStaticDocumentType::route('/create'), + 'edit' => EditStaticDocumentType::route('/{record}/edit'), + 'view' => ViewStaticDocumentType::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticDocumentTypeResource/Pages/CreateStaticDocumentType.php b/packages/data/src/Filament/Resources/StaticDocumentTypeResource/Pages/CreateStaticDocumentType.php new file mode 100644 index 0000000000..59d3634060 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticDocumentTypeResource/Pages/CreateStaticDocumentType.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-document-type.tabs', StaticDocumentType::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticDocumentTypeResource/Pages/ViewStaticDocumentType.php b/packages/data/src/Filament/Resources/StaticDocumentTypeResource/Pages/ViewStaticDocumentType.php new file mode 100644 index 0000000000..a909488335 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticDocumentTypeResource/Pages/ViewStaticDocumentType.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticEasSchemes::route('/'), + 'create' => CreateStaticEasScheme::route('/create'), + 'edit' => EditStaticEasScheme::route('/{record}/edit'), + 'view' => ViewStaticEasScheme::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticEasSchemeResource/Pages/CreateStaticEasScheme.php b/packages/data/src/Filament/Resources/StaticEasSchemeResource/Pages/CreateStaticEasScheme.php new file mode 100644 index 0000000000..a47389b7d5 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticEasSchemeResource/Pages/CreateStaticEasScheme.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-eas-scheme.tabs', StaticEasScheme::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticEasSchemeResource/Pages/ViewStaticEasScheme.php b/packages/data/src/Filament/Resources/StaticEasSchemeResource/Pages/ViewStaticEasScheme.php new file mode 100644 index 0000000000..032d5344fc --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticEasSchemeResource/Pages/ViewStaticEasScheme.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticIcdSchemes::route('/'), + 'create' => CreateStaticIcdScheme::route('/create'), + 'edit' => EditStaticIcdScheme::route('/{record}/edit'), + 'view' => ViewStaticIcdScheme::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticIcdSchemeResource/Pages/CreateStaticIcdScheme.php b/packages/data/src/Filament/Resources/StaticIcdSchemeResource/Pages/CreateStaticIcdScheme.php new file mode 100644 index 0000000000..debeba6dba --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticIcdSchemeResource/Pages/CreateStaticIcdScheme.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-icd-scheme.tabs', StaticIcdScheme::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticIcdSchemeResource/Pages/ViewStaticIcdScheme.php b/packages/data/src/Filament/Resources/StaticIcdSchemeResource/Pages/ViewStaticIcdScheme.php new file mode 100644 index 0000000000..5fae6b63f4 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticIcdSchemeResource/Pages/ViewStaticIcdScheme.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + TextInput::make('version') + ->label(__('data::fields.version')) + ->maxLength(10) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('version') + ->label(__('data::fields.version')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + Filter::make('version') + ->schema([ + TextInput::make('version') + ->label(__('data::fields.version')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['version'], + fn (Builder $query, $value): Builder => $query->where('version', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['version']) { + return null; + } + + return 'Version: '.$data['version']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticIncoterms::route('/'), + 'create' => CreateStaticIncoterm::route('/create'), + 'edit' => EditStaticIncoterm::route('/{record}/edit'), + 'view' => ViewStaticIncoterm::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticIncotermResource/Pages/CreateStaticIncoterm.php b/packages/data/src/Filament/Resources/StaticIncotermResource/Pages/CreateStaticIncoterm.php new file mode 100644 index 0000000000..8a026d4367 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticIncotermResource/Pages/CreateStaticIncoterm.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-incoterm.tabs', StaticIncoterm::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticIncotermResource/Pages/ViewStaticIncoterm.php b/packages/data/src/Filament/Resources/StaticIncotermResource/Pages/ViewStaticIncoterm.php new file mode 100644 index 0000000000..54e8923e94 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticIncotermResource/Pages/ViewStaticIncoterm.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticPaymentMeans::route('/'), + 'create' => CreateStaticPaymentMean::route('/create'), + 'edit' => EditStaticPaymentMean::route('/{record}/edit'), + 'view' => ViewStaticPaymentMean::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticPaymentMeanResource/Pages/CreateStaticPaymentMean.php b/packages/data/src/Filament/Resources/StaticPaymentMeanResource/Pages/CreateStaticPaymentMean.php new file mode 100644 index 0000000000..11f8485efd --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticPaymentMeanResource/Pages/CreateStaticPaymentMean.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-payment-mean.tabs', StaticPaymentMean::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticPaymentMeanResource/Pages/ViewStaticPaymentMean.php b/packages/data/src/Filament/Resources/StaticPaymentMeanResource/Pages/ViewStaticPaymentMean.php new file mode 100644 index 0000000000..e48bf6e918 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticPaymentMeanResource/Pages/ViewStaticPaymentMean.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + TextInput::make('symbol') + ->label(__('data::fields.symbol')) + ->maxLength(20), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('symbol') + ->label(__('data::fields.symbol')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + Filter::make('symbol') + ->schema([ + TextInput::make('symbol') + ->label(__('data::fields.symbol')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['symbol'], + fn (Builder $query, $value): Builder => $query->where('symbol', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['symbol']) { + return null; + } + + return 'Symbol: '.$data['symbol']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticUnits::route('/'), + 'create' => CreateStaticUnit::route('/create'), + 'edit' => EditStaticUnit::route('/{record}/edit'), + 'view' => ViewStaticUnit::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticUnitResource/Pages/CreateStaticUnit.php b/packages/data/src/Filament/Resources/StaticUnitResource/Pages/CreateStaticUnit.php new file mode 100644 index 0000000000..204256c212 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticUnitResource/Pages/CreateStaticUnit.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-unit.tabs', StaticUnit::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticUnitResource/Pages/ViewStaticUnit.php b/packages/data/src/Filament/Resources/StaticUnitResource/Pages/ViewStaticUnit.php new file mode 100644 index 0000000000..58ad85be31 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticUnitResource/Pages/ViewStaticUnit.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(10) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticVatCategories::route('/'), + 'create' => CreateStaticVatCategory::route('/create'), + 'edit' => EditStaticVatCategory::route('/{record}/edit'), + 'view' => ViewStaticVatCategory::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticVatCategoryResource/Pages/CreateStaticVatCategory.php b/packages/data/src/Filament/Resources/StaticVatCategoryResource/Pages/CreateStaticVatCategory.php new file mode 100644 index 0000000000..ead6dc15f7 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticVatCategoryResource/Pages/CreateStaticVatCategory.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-vat-category.tabs', StaticVatCategory::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticVatCategoryResource/Pages/ViewStaticVatCategory.php b/packages/data/src/Filament/Resources/StaticVatCategoryResource/Pages/ViewStaticVatCategory.php new file mode 100644 index 0000000000..de8df1eaab --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticVatCategoryResource/Pages/ViewStaticVatCategory.php @@ -0,0 +1,13 @@ +schema([ + Grid::make() + ->schema([ + Section::make() + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->maxLength(64) + ->required(), + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->required(), + TextInput::make('vat_category_code') + ->label(__('data::fields.vat_category_code')) + ->maxLength(10), + Textarea::make('description') + ->label(__('data::fields.description')) + ->columnSpanFull(), + ]) + ->columnSpan(2), + Grid::make() + ->schema([ + Section::make() + ->schema([ + static::getFormActions(), + ]), + ]) + ->columns(1) + ->columnSpan(1), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('code') + ->label(__('data::fields.code')) + ->sortable() + ->searchable(), + TextColumn::make('common_name') + ->label(__('data::fields.common_name')) + ->sortable() + ->searchable(), + TextColumn::make('vat_category_code') + ->label(__('data::fields.vat_category_code')) + ->sortable() + ->searchable(), + TextColumn::make('description') + ->label(__('data::fields.description')) + ->limit(80) + ->wrap(), + ]) + ->defaultSort('common_name', 'asc') + ->recordActions([...static::getTableActions()]) + ->toolbarActions([...static::getBulkActions()]) + ->filters([ + Filter::make('id') + ->schema([ + TextInput::make('id') + ->label(__('data::fields.id')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['id'], + fn (Builder $query, $value): Builder => $query->where('id', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['id']) { + return null; + } + + return 'ID: '.$data['id']; + }), + Filter::make('code') + ->schema([ + TextInput::make('code') + ->label(__('data::fields.code')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['code'], + fn (Builder $query, $value): Builder => $query->where('code', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['code']) { + return null; + } + + return 'Code: '.$data['code']; + }), + Filter::make('common_name') + ->schema([ + TextInput::make('common_name') + ->label(__('data::fields.common_name')) + ->placeholder(__('core::core.search')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['common_name'], + fn (Builder $query, $value): Builder => $query->where('common_name', 'like', "%{$value}%"), + ); + }) + ->indicateUsing(function (array $data): ?string { + if (! $data['common_name']) { + return null; + } + + return 'Common Name: '.$data['common_name']; + }), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListStaticVatExemptionReasons::route('/'), + 'create' => CreateStaticVatExemptionReason::route('/create'), + 'edit' => EditStaticVatExemptionReason::route('/{record}/edit'), + 'view' => ViewStaticVatExemptionReason::route('/{record}'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return (string) static::getModel()::count(); + } +} diff --git a/packages/data/src/Filament/Resources/StaticVatExemptionReasonResource/Pages/CreateStaticVatExemptionReason.php b/packages/data/src/Filament/Resources/StaticVatExemptionReasonResource/Pages/CreateStaticVatExemptionReason.php new file mode 100644 index 0000000000..26f9d77f4f --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticVatExemptionReasonResource/Pages/CreateStaticVatExemptionReason.php @@ -0,0 +1,13 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('static-vat-exemption-reason.tabs', StaticVatExemptionReason::class); + } +} diff --git a/packages/data/src/Filament/Resources/StaticVatExemptionReasonResource/Pages/ViewStaticVatExemptionReason.php b/packages/data/src/Filament/Resources/StaticVatExemptionReasonResource/Pages/ViewStaticVatExemptionReason.php new file mode 100644 index 0000000000..3434b8a377 --- /dev/null +++ b/packages/data/src/Filament/Resources/StaticVatExemptionReasonResource/Pages/ViewStaticVatExemptionReason.php @@ -0,0 +1,13 @@ +exists(); + } + + public function install(array $assets): bool + { + if (! Schema::hasTable('static_charge_reasons')) { + note('ℹ️ Table static_charge_reasons not found. Run migrations first (data package).'); + + return false; + } + + try { + note('📋 Importing static codelists from committed JSON …'); + + $count = (new ImportCodelistsService)->import(); + + note("✅ Static codelists import completed ({$count} rows upserted)."); + + return true; + } catch (\Throwable $e) { + error('⚠️ Static codelists import failed: '.$e->getMessage()); + + return false; + } + } +} diff --git a/packages/data/src/Models/StaticAllowanceReason.php b/packages/data/src/Models/StaticAllowanceReason.php new file mode 100644 index 0000000000..144048718c --- /dev/null +++ b/packages/data/src/Models/StaticAllowanceReason.php @@ -0,0 +1,22 @@ +belongsTo(StaticVatCategory::class, 'vat_category_code', 'code'); + } +} diff --git a/packages/data/src/Services/ImportCodelistsService.php b/packages/data/src/Services/ImportCodelistsService.php new file mode 100644 index 0000000000..d3fc5d6ed4 --- /dev/null +++ b/packages/data/src/Services/ImportCodelistsService.php @@ -0,0 +1,111 @@ + */ + private const OPTIONAL_ROW_ATTRIBUTES = [ + 'description', + 'en16931_interpretation', + 'symbol', + 'version', + 'vat_category_code', + ]; + + public function import(?string $scheme = null): int + { + $entries = $scheme !== null + ? array_filter([$scheme => CodelistRegistry::get($scheme)], fn ($entry) => $entry !== null) + : CodelistRegistry::all(); + + if ($scheme !== null && $entries === []) { + throw new RuntimeException("Unknown codelist scheme [{$scheme}]."); + } + + $imported = 0; + + foreach ($entries as $schemeKey => $entry) { + $imported += $this->importScheme((string) $schemeKey, $entry); + } + + return $imported; + } + + /** + * @param array{file: string, model: class-string, upsert_keys?: list} $entry + */ + protected function importScheme(string $scheme, array $entry): int + { + $path = $this->resolveJsonPath($entry['file']); + + if (! File::isFile($path)) { + throw new RuntimeException("Codelist JSON not found for [{$scheme}]: {$path}"); + } + + $payload = json_decode(File::get($path), true, flags: JSON_THROW_ON_ERROR); + + if (! is_array($payload) || ! isset($payload['codes']) || ! is_array($payload['codes'])) { + throw new RuntimeException("Invalid codelist JSON shape for [{$scheme}]: missing codes array."); + } + + /** @var class-string $modelClass */ + $modelClass = $entry['model']; + $upsertKeys = $entry['upsert_keys'] ?? ['code']; + $count = 0; + + foreach ($payload['codes'] as $row) { + if (! is_array($row) || ! isset($row['common_name'])) { + Log::channel('daily')->warning("Skipping invalid codelist row in [{$scheme}].", ['row' => $row]); + + continue; + } + + foreach ($upsertKeys as $key) { + if (! isset($row[$key])) { + Log::channel('daily')->warning("Skipping codelist row missing upsert key [{$key}] in [{$scheme}].", ['row' => $row]); + + continue 2; + } + } + + $criteria = []; + foreach ($upsertKeys as $key) { + $criteria[$key] = (string) $row[$key]; + } + + $attributes = [ + 'common_name' => (string) $row['common_name'], + ]; + + foreach (self::OPTIONAL_ROW_ATTRIBUTES as $attribute) { + if (array_key_exists($attribute, $row)) { + $attributes[$attribute] = $row[$attribute] !== null + ? (string) $row[$attribute] + : null; + } + } + + $modelClass::updateOrCreate($criteria, $attributes); + + $count++; + } + + Log::channel('daily')->info("Imported {$count} rows for codelist [{$scheme}] from {$entry['file']}."); + + return $count; + } + + protected function resolveJsonPath(string $filename): string + { + return dirname(__DIR__, 2).'/database/data/codelists/'.$filename; + } +} diff --git a/packages/data/src/Support/CodelistRegistry.php b/packages/data/src/Support/CodelistRegistry.php new file mode 100644 index 0000000000..a17ac5b543 --- /dev/null +++ b/packages/data/src/Support/CodelistRegistry.php @@ -0,0 +1,84 @@ + codelist definition (committed JSON file + Eloquent model). + * + * @var array}> + */ + public const ENTRIES = [ + 'uncl7161' => [ + 'file' => 'uncl7161.json', + 'model' => StaticChargeReason::class, + ], + 'uncl5189' => [ + 'file' => 'allowance-reasons.json', + 'model' => StaticAllowanceReason::class, + ], + 'untdid1001' => [ + 'file' => 'document-types.json', + 'model' => StaticDocumentType::class, + ], + 'untdid5305' => [ + 'file' => 'vat-categories.json', + 'model' => StaticVatCategory::class, + ], + 'untdid4461' => [ + 'file' => 'payment-means.json', + 'model' => StaticPaymentMean::class, + ], + 'rec20' => [ + 'file' => 'units.json', + 'model' => StaticUnit::class, + ], + 'incoterms2020' => [ + 'file' => 'incoterms.json', + 'model' => StaticIncoterm::class, + 'upsert_keys' => ['code', 'version'], + ], + 'vatex' => [ + 'file' => 'vat-exemption-reasons.json', + 'model' => StaticVatExemptionReason::class, + ], + 'icd' => [ + 'file' => 'icd-schemes.json', + 'model' => StaticIcdScheme::class, + ], + 'eas' => [ + 'file' => 'eas-schemes.json', + 'model' => StaticEasScheme::class, + ], + ]; + + /** + * @return array}> + */ + public static function all(): array + { + return self::ENTRIES; + } + + /** + * @return array{file: string, model: class-string, upsert_keys?: list}|null + */ + public static function get(string $scheme): ?array + { + return self::ENTRIES[$scheme] ?? null; + } +} diff --git a/packages/data/tests/Feature/ImportCodelistsTest.php b/packages/data/tests/Feature/ImportCodelistsTest.php new file mode 100644 index 0000000000..2f1d89d867 --- /dev/null +++ b/packages/data/tests/Feature/ImportCodelistsTest.php @@ -0,0 +1,198 @@ +artisan('moox:data:import-codelists') + ->assertSuccessful(); + + expect(StaticChargeReason::query()->count())->toBe(9) + ->and(StaticAllowanceReason::query()->count())->toBe(19) + ->and(StaticDocumentType::query()->count())->toBe(54) + ->and(StaticVatCategory::query()->count())->toBe(9) + ->and(StaticPaymentMean::query()->count())->toBe(10) + ->and(StaticUnit::query()->count())->toBe(15) + ->and(StaticIncoterm::query()->count())->toBe(11) + ->and(StaticVatExemptionReason::query()->count())->toBe(10) + ->and(StaticIcdScheme::query()->count())->toBe(8) + ->and(StaticEasScheme::query()->count())->toBe(10) + ->and(StaticChargeReason::query()->where('code', 'FC')->value('common_name'))->toBe('Freight charge') + ->and(StaticDocumentType::query()->where('code', '380')->value('en16931_interpretation'))->toBe('invoice') + ->and(StaticDocumentType::query()->where('code', '381')->value('en16931_interpretation'))->toBe('credit_note') + ->and(StaticUnit::query()->where('code', 'KGM')->value('symbol'))->toBe('kg') + ->and(StaticIncoterm::query()->where('code', 'FOB')->where('version', '2020')->value('common_name'))->toBe('Free On Board'); + + $chargeColumns = Schema::getColumnListing('static_charge_reasons'); + expect($chargeColumns)->toContain('id', 'code', 'common_name', 'description', 'created_at', 'updated_at') + ->and($chargeColumns)->not->toContain('exonyms'); + + expect(Schema::getColumnListing('static_allowance_reasons'))->toContain('description') + ->and(Schema::getColumnListing('static_document_types'))->toContain('en16931_interpretation', 'description') + ->and(Schema::getColumnListing('static_vat_categories'))->toContain('description') + ->and(Schema::getColumnListing('static_payment_means'))->toContain('description') + ->and(Schema::getColumnListing('static_units'))->toContain('symbol', 'description') + ->and(Schema::getColumnListing('static_incoterms'))->toContain('version', 'description') + ->and(Schema::getColumnListing('static_vat_exemption_reasons'))->toContain('vat_category_code', 'description') + ->and(Schema::getColumnListing('static_icd_schemes'))->toContain('description') + ->and(Schema::getColumnListing('static_eas_schemes'))->toContain('description'); +}); + +test('codelist import is idempotent for each scheme', function (): void { + $importer = app(ImportCodelistsService::class); + + foreach (['uncl7161', 'uncl5189', 'untdid1001', 'untdid5305', 'untdid4461', 'rec20', 'incoterms2020', 'vatex', 'icd', 'eas'] as $scheme) { + $first = $importer->import($scheme); + $second = $importer->import($scheme); + + expect($second)->toBe($first); + } + + expect(StaticIncoterm::query()->count())->toBe(11); +}); + +test('static_charge_reasons code column is unique', function (): void { + StaticChargeReason::query()->create([ + 'code' => 'FC', + 'common_name' => 'Freight charge', + ]); + + expect(fn () => StaticChargeReason::query()->create([ + 'code' => 'FC', + 'common_name' => 'Duplicate', + ]))->toThrow(QueryException::class); +}); + +test('static_incoterms uses composite unique on code and version', function (): void { + StaticIncoterm::query()->create([ + 'code' => 'FOB', + 'version' => '2020', + 'common_name' => 'Free On Board', + ]); + + StaticIncoterm::query()->create([ + 'code' => 'FOB', + 'version' => '2010', + 'common_name' => 'Free On Board (2010)', + ]); + + expect(StaticIncoterm::query()->where('code', 'FOB')->count())->toBe(2); + + expect(fn () => StaticIncoterm::query()->create([ + 'code' => 'FOB', + 'version' => '2020', + 'common_name' => 'Duplicate', + ]))->toThrow(QueryException::class); +}); + +test('incoterms import upserts on code and version composite key', function (): void { + $importer = app(ImportCodelistsService::class); + + $importer->import('incoterms2020'); + + StaticIncoterm::query()->where('code', 'EXW')->where('version', '2020')->update(['common_name' => 'Changed']); + + $importer->import('incoterms2020'); + + expect(StaticIncoterm::query()->where('code', 'EXW')->where('version', '2020')->value('common_name')) + ->toBe('Ex Works') + ->and(StaticIncoterm::query()->count())->toBe(11); +}); + +test('per-code lang files resolve under DE locale', function (): void { + app()->setLocale('de'); + + expect(__('data::enums/charge-reasons.FC'))->toBe('Frachtkosten') + ->and(__('data::enums/allowance-reasons.95'))->toBe('Rabatt') + ->and(__('data::enums/vat-categories.S'))->toBe('Normalsatz') + ->and(__('data::enums/payment-means.58'))->toBe('SEPA-Überweisung') + ->and(__('data::enums/units.KGM'))->toBe('Kilogramm') + ->and(__('data::enums/incoterms.FOB'))->toBe('Frei an Bord') + ->and(__('data::enums/document-types.380'))->toBe('Handelsrechnung') + ->and(__('data::enums/vat-exemption-reasons.VATEX-EU-AE'))->toBe('Reverse Charge') + ->and(__('data::enums/icd-schemes.9930'))->toBe('Deutsche Umsatzsteuer-Identifikationsnummer') + ->and(__('data::enums/eas-schemes.0204'))->toBe('Leitweg-ID') + ->and(__('data::fields.code'))->toBe('Code'); +}); + +test('static_vat_exemption_reasons code column is unique', function (): void { + StaticVatExemptionReason::query()->create([ + 'code' => 'VATEX-EU-AE', + 'common_name' => 'Reverse charge', + ]); + + expect(fn () => StaticVatExemptionReason::query()->create([ + 'code' => 'VATEX-EU-AE', + 'common_name' => 'Duplicate', + ]))->toThrow(QueryException::class); +}); + +test('vatex import maps description and vat_category_code and vatCategory resolves by code', function (): void { + $this->artisan('moox:data:import-codelists', ['scheme' => 'untdid5305']) + ->assertSuccessful(); + $this->artisan('moox:data:import-codelists', ['scheme' => 'vatex']) + ->assertSuccessful(); + + $reason = StaticVatExemptionReason::query()->where('code', 'VATEX-EU-AE')->first(); + + expect($reason)->not->toBeNull() + ->and($reason->vat_category_code)->toBe('AE') + ->and($reason->description)->toBe('Only use with VAT category code AE (reverse charge).') + ->and($reason->vatCategory)->not->toBeNull() + ->and($reason->vatCategory->code)->toBe('AE'); + + $franchise = StaticVatExemptionReason::query()->where('code', 'VATEX-FR-FRANCHISE')->first(); + + expect($franchise)->not->toBeNull() + ->and($franchise->vat_category_code)->toBeNull() + ->and($franchise->vatCategory)->toBeNull(); +}); + +test('icd import maps description when present in JSON', function (): void { + $this->artisan('moox:data:import-codelists', ['scheme' => 'icd']) + ->assertSuccessful(); + + expect(StaticIcdScheme::query()->where('code', '0002')->value('description')) + ->toContain('INSEE') + ->and(StaticIcdScheme::query()->where('code', '0060')->value('description'))->toBeNull(); +}); + +test('static_icd_schemes code column is unique', function (): void { + StaticIcdScheme::query()->create([ + 'code' => '9930', + 'common_name' => 'Germany VAT number', + ]); + + expect(fn () => StaticIcdScheme::query()->create([ + 'code' => '9930', + 'common_name' => 'Duplicate', + ]))->toThrow(QueryException::class); +}); + +test('static_eas_schemes code column is unique', function (): void { + StaticEasScheme::query()->create([ + 'code' => '9930', + 'common_name' => 'Germany VAT number', + ]); + + expect(fn () => StaticEasScheme::query()->create([ + 'code' => '9930', + 'common_name' => 'Duplicate', + ]))->toThrow(QueryException::class); +}); diff --git a/packages/docs/PACKAGES.md b/packages/docs/PACKAGES.md index b3530ab886..a6c8c19706 100644 --- a/packages/docs/PACKAGES.md +++ b/packages/docs/PACKAGES.md @@ -69,7 +69,7 @@ All rows use the Composer-style name `moox/…`. Includes packages with `compose | moox/devtools | moox | Stable | No | ... | | moox/docs | moox | Idea | No | ... | | moox/draft | moox | Stable | Yes | ... | -| moox/ebilling-gateway | moox | Idea | No | ... | +| moox/e-billing | moox | Idea | No | ... | | moox/expiry | moox | Stable | Todo | ... | | moox/featherlight | moox | Stable | No | ... | | moox/file-icons | moox | Stable | No | ... | diff --git a/packages/e-billing/.gitignore b/packages/e-billing/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/e-billing/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/e-billing/CHANGELOG.md b/packages/e-billing/CHANGELOG.md new file mode 100644 index 0000000000..d806cfe835 --- /dev/null +++ b/packages/e-billing/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +We currently don't track changes in this package. Please refer to the [Moox Monorepo](https://github.com/mooxphp/moox) for the latest changes. diff --git a/packages/e-billing/LICENSE.md b/packages/e-billing/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/e-billing/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/e-billing/README.md b/packages/e-billing/README.md new file mode 100644 index 0000000000..e2e002527d --- /dev/null +++ b/packages/e-billing/README.md @@ -0,0 +1,209 @@ +![Moox EBilling](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox EBilling + +Moox e-billing orchestrates the Moox e-invoice pipeline: PDF ingestion through XML generation, KoSIT validation, and ZUGFeRD PDF merge, with a Filament review UI for operators. + +## Features + + + +- PDF-to-invoice pipeline orchestration (mail-inbox handoff through ZUGFeRD merge) +- EN 16931 / ZUGFeRD XML preparation via `moox/zugferd` +- KoSIT validation integration via `moox/kosit-validator` +- Foreign-invoice filtering (non-domestic invoices moved to an ignored mailbox folder) +- MoSCoW field validation and validation scoring on `EbillingDocument` +- Filament `InvoiceResource` for list, filter, and manual review workflows +- Host-bound invoice parser via `InvoiceParserInterface` (no parser ships with this package) + + + +## How it works + +Upstream, `moox/mail-inbox` dispatches `ParsePdfJob` on each PDF attachment; early in that job it fires `InboxAttachmentProcessed` so host listeners can parse the PDF and persist invoice data, which hands control to this package. + +The pipeline then runs in order: + +| Step | Class | What it does | +| --- | --- | --- | +| 1 | `ProcessInboxAttachmentListener` | Creates or finds an `EbillingDocument` for the attachment and dispatches `StoreBillDataJob`. | +| 2 | `StoreBillDataJob` | Reads parsed `bill_data` on the document (populated upstream by the host parser) and dispatches `FilterForeignInvoiceJob`. | +| 3 | `FilterForeignInvoiceJob` | Classifies domestic vs. foreign invoices; foreign invoices are moved to the ignored Graph folder and marked `IgnoredForeign`; domestic invoices advance to XML generation. | +| 4 | `GenerateXmlJob` | Maps `bill_data` to a persisted `Invoice`, generates EN 16931 XML, runs field validation, and dispatches `ValidateXmlJob`. | +| 5 | `ValidateXmlJob` | Runs KoSIT validation on the XML; on pass, dispatches `MergeZugferdPdfJob`. | +| 6 | `MergeZugferdPdfJob` | Embeds validated XML into the source PDF and stores the ZUGFeRD artefact. | + +There is no `HandleFailedJob`. Failure handling uses each job's `failed()` method plus `InboxMessagePipelineFinalizer` to update attachment and message status. + +The host application must bind `InvoiceParserInterface` to parse PDF text into the `Moox\EBilling\Data\Invoice` DTO before `bill_data` is available on the document. See [Parser integration](#parser-integration). + +## Requirements + +This package composes the other Moox e-billing packages. Composer requires: + +| Package | Role | +| --- | --- | +| `moox/company` | Company FK on `EbillingDocument` | +| `moox/core` | Base model, Filament resource, Moox installer | +| `moox/invoice` | Invoice domain models (`Invoice`, lines, parties) | +| `moox/jobs` | Job progress traits | +| `moox/kosit-validator` | KoSIT XML validation and audit persistence | +| `moox/mail-inbox` | Graph inbox, attachment storage, `ParsePdfJob` | +| `moox/pdf-parser` | PDF text extraction (used by the host parser) | +| `moox/zugferd` | EN 16931 / ZUGFeRD XML generation and PDF merge | + +See [Requirements](https://github.com/mooxphp/moox/blob/main/docs/Requirements.md). + +## Installation + +```bash +composer require moox/e-billing +php artisan moox:install +``` + +Curious what the install command does? See [Installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md). + +Register the Filament plugin on your panel (see [Filament](#filament)) and bind `InvoiceParserInterface` in your host `ServiceProvider` (see [Parser integration](#parser-integration)). + +## Configuration + +Published as `config/e-billing.php`. + +### Config keys + +| Key | Controls | +| --- | --- | +| `resources` | Filament resource registration (`invoices` → `InvoiceResource`) | +| `tabs` | List-page tab filters (`all`, `needs_review`, `confirmed`, `deleted`) | +| `zugferd` | ZUGFeRD filesystem disk (`storage_disk`, `storage_root`) | +| `foreign_invoice` | Foreign-invoice handling (`ignored_folder_name`) | +| `zugferd_profile` | ZUGFeRD / XRechnung profile (default `EN16931`) | +| `default_customer_country` | Transitional fallback buyer country when the parser derives none (default `DE`); removed in a future master-data phase | +| `supplier` | Central supplier master data copied onto invoices as a snapshot at creation time | +| `field_validation` | MoSCoW priority rules for invoice and line fields | +| `morph_relations` | Morph pivot config for KoSIT validations (`kosit_validatables`) | + +### Environment variables + +This package exposes one environment variable. Microsoft Graph credentials and mailbox settings belong to `moox/mail-inbox`. + +```env +# Optional — Graph folder display name for ignored foreign invoices (default: Ignored) +EBILLING_IGNORED_FOLDER=Ignored +``` + +| Variable | Config key | Default | Required | +| --- | --- | --- | --- | +| `EBILLING_IGNORED_FOLDER` | `foreign_invoice.ignored_folder_name` | `Ignored` | No | + +### Supplier block + +Override `supplier` in your published `config/e-billing.php` with your company details (name, VAT ID, address, bank accounts). Values are snapshotted onto each `Invoice` when `GenerateXmlJob` creates the record. + +### `default_customer_country` + +When the parser cannot derive a buyer country from the PDF, this ISO code is used as a fallback for domestic classification. It is transitional and will be replaced by Company / Address master-data lookup. + +## Parser integration + +No invoice parser ships with this package. Implement `Moox\EBilling\Contracts\InvoiceParserInterface` in your host application and bind it in a `ServiceProvider`: + +```php +use Moox\EBilling\Contracts\InvoiceParserInterface; +use Moox\EBilling\Data\Invoice; + +// YourParser must implement: +// public function parse(string $rawText): Invoice + +$this->app->bind(InvoiceParserInterface::class, YourParser::class); +``` + +The parser receives extracted PDF text (from `moox/pdf-parser`) and returns a `Moox\EBilling\Data\Invoice` DTO. The host is responsible for persisting `bill_data` on the `EbillingDocument` before the pipeline jobs run. + +## Commands + +Backfill `validation_score` on documents that have `field_validations` but no stored score (for example after a schema or scoring change): + +```bash +php artisan ebilling:backfill-scores +``` + +Queries `EbillingDocument` rows where `field_validations` is not null and `validation_score` is null, computes each score via `calculateValidationScore()`, and saves quietly. + +## The EbillingDocument Model + +`EbillingDocument` (`Moox\EBilling\Models\EbillingDocument`) is the gateway state record for one inbox attachment. It links the source attachment (morph) to a persisted `Invoice` and tracks pipeline status, validation results, and artefact paths. + +### Attributes + +| Column | Type | Nullability | Notes | +| --- | --- | --- | --- | +| `id` | `uuid` | NOT NULL | Primary key | +| `source_type` | `string` | nullable | Morph type (typically `InboxAttachment`) | +| `source_id` | `unsignedBigInteger` | nullable | Morph key (`InboxAttachment` uses a bigInteger PK) | +| `bill_data` | `json` | nullable | Parsed invoice DTO as JSON | +| `xml_storage_path` | `string` | nullable | Relative path to generated XML on the `zugferd` disk | +| `zugferd_storage_disk` | `string` | nullable | Filesystem disk name for ZUGFeRD artefacts | +| `zugferd_storage_path` | `string` | nullable | Relative path to merged ZUGFeRD PDF | +| `ignored_reason` | `json` | nullable | Foreign-invoice classification details | +| `gateway_status` | `string` | nullable | Pipeline stage (indexed) | +| `review_status` | `string` | NOT NULL | Review stage; default `parser_created` (indexed) | +| `validation_score` | `unsignedTinyInteger` | nullable | Aggregated field-validation score | +| `field_validations` | `json` | nullable | Per-field validation results | +| `processed_at` | `timestamp` | nullable | Set when ZUGFeRD PDF merge completes | +| `error_message` | `text` | nullable | Last pipeline error | +| `created_at` | `timestamp` | NOT NULL | | +| `updated_at` | `timestamp` | NOT NULL | | +| `company_id` | `uuid` FK | nullable | References `companies.id` (`nullOnDelete`) | +| `invoice_id` | `uuid` FK | nullable | References `invoices.id` (`nullOnDelete`) | +| `scope` | `string` | nullable | Tenant / mailbox scope (indexed) | + +### Relationships + +- `source()` — `MorphTo` (typically `InboxAttachment`) +- `invoice()` — `BelongsTo` `Moox\Invoice\Models\Invoice` +- `company()` — `BelongsTo` `Moox\Company\Models\Company` +- `kositValidations()` — `MorphToMany` via `kosit_validatables` + +## Filament + +Register the plugin on your panel: + +```php +use Moox\EBilling\Plugins\EBillingPlugin; + +$panel->plugins([ + EBillingPlugin::make(), +]); +``` + +`EBillingPlugin` registers `InvoiceResource` (slug `invoices`), which manages `Moox\Invoice\Models\Invoice`. Create and edit are disabled; operators use the list and view pages to review parsed invoices, validation scores, KoSIT status, and confirm or reject records. + +## Relation to moox/invoice + +Invoice domain models (`Invoice`, line items, parties, and related tables) live in **`moox/invoice`**, not in this package. + +This package owns: + +- **`EbillingDocument`** — gateway state, validation scores, and artefact paths +- **The processing pipeline** — listener and jobs from inbox handoff through ZUGFeRD merge +- **The Filament review UI** — read-only `InvoiceResource` +- **`Invoice::ebillingDocument()`** — registered via `resolveRelationUsing` in `EBillingServiceProvider` + +`GenerateXmlJob` creates and updates `Invoice` records through `moox/invoice`; this package orchestrates that step but does not define the invoice schema. + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security + +Please review [our security policy](https://github.com/mooxphp/moox/security/policy) on how to report security vulnerabilities. + +## Credits + +Thanks to so many [people for their contributions](https://github.com/mooxphp/moox#contributors) to this package. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/e-billing/SECURITY.md b/packages/e-billing/SECURITY.md new file mode 100644 index 0000000000..647d30d57d --- /dev/null +++ b/packages/e-billing/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `Moox EBilling` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use Github issues, to give us enough time to review and fix the issue, before others can use it, to do stupid things. diff --git a/packages/e-billing/banner.jpg b/packages/e-billing/banner.jpg new file mode 100644 index 0000000000..d889a2b55d Binary files /dev/null and b/packages/e-billing/banner.jpg differ diff --git a/packages/e-billing/composer.json b/packages/e-billing/composer.json new file mode 100644 index 0000000000..cb11772476 --- /dev/null +++ b/packages/e-billing/composer.json @@ -0,0 +1,74 @@ +{ + "name": "moox/e-billing", + "description": "Moox eBilling extracts invoice data from PDF text, maps it to a canonical bill model and prepares it for e-invoicing conversion.", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "eBilling", + "Invoice", + "XRechnung", + "ZUGFeRD", + "Moox package", + "Laravel package" + ], + "homepage": "https://moox.org/docs/e-billing", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/company": "dev-main", + "moox/core": "dev-main", + "moox/invoice": "dev-main", + "moox/jobs": "dev-main", + "moox/kosit-validator": "dev-main", + "moox/mail-inbox": "dev-main", + "moox/pdf-parser": "dev-main", + "moox/zugferd": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\EBilling\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\EBilling\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\EBilling\\EBillingServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main", + "pestphp/pest": "^4.7", + "pestphp/pest-plugin-livewire": "^4.0" + }, + "scripts": { + "test": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/Unit tests/Feature" + ], + "test:arch": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/ArchTest.php" + ] + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} \ No newline at end of file diff --git a/packages/e-billing/config/e-billing.php b/packages/e-billing/config/e-billing.php new file mode 100644 index 0000000000..51f60f8354 --- /dev/null +++ b/packages/e-billing/config/e-billing.php @@ -0,0 +1,363 @@ + [ + 'invoices' => [ + 'enabled' => true, + 'label' => 'trans//e-billing::ebilling.invoice', + 'plural_label' => 'trans//e-billing::ebilling.invoices', + 'navigation_group' => 'trans//e-billing::ebilling.navigation_group', + 'navigation_icon' => 'heroicon-o-document-text', + 'navigation_sort' => 1, + 'navigation_count_badge' => true, + /* + |-------------------------------------------------------------------------- + | Soft Delete Tab Key + |-------------------------------------------------------------------------- + | + | This key must match a tab key under 'tabs.invoices'. It tells our custom + | applySoftDeleteQuery() which tab to treat as the trash view. + | + | IMPORTANT: Moox Core's SingleSoftDeleteInResource::getHardDeleteBulkAction() + | still hardcodes 'deleted' and 'trash' for visibility. If you rename this + | key to something else, the hard-delete bulk action from the vendor trait + | may not appear on the correct tab. This is a known limitation. + | + | @see https://github.com/mooxphp/core — open an issue if this needs + | to be configurable in the trait. + | + */ + 'soft_delete_tab_key' => 'deleted', + 'resource' => InvoiceResource::class, + ], + ], + + 'tabs' => [ + 'invoices' => [ + 'all' => [ + 'label' => 'trans//e-billing::fields.tab_all', + 'icon' => 'gmdi-filter-list', + 'query' => [ + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'needs_review' => [ + 'label' => 'trans//e-billing::fields.tab_needs_review', + 'icon' => 'gmdi-warning', + 'query' => [ + [ + 'field' => 'review_status', + 'operator' => 'in', + 'value' => ['parser_created', 'db_validated'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'confirmed' => [ + 'label' => 'trans//e-billing::fields.tab_confirmed', + 'icon' => 'gmdi-check-circle', + 'query' => [ + [ + 'field' => 'review_status', + 'operator' => 'in', + 'value' => ['human_confirmed', 'validated'], + ], + [ + 'field' => 'deleted_at', + 'operator' => '=', + 'value' => null, + ], + ], + ], + 'deleted' => [ + 'label' => 'trans//e-billing::fields.tab_deleted', + 'icon' => 'gmdi-delete', + 'query' => [ + [ + 'field' => 'deleted_at', + 'operator' => '!=', + 'value' => null, + ], + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | ZUGFeRD storage (filesystem disk `zugferd`) + |-------------------------------------------------------------------------- + | + | Relative paths on this disk follow `{scope}/{Y-m}/{invoiceNumber}_{date}.xml|.pdf`. + | When `storage_root` is null, it defaults to `storage/app/private/{mail-inbox.zugferd.path}`. + | + */ + + 'zugferd' => [ + 'storage_disk' => 'zugferd', + 'storage_root' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Foreign invoice handling (pre-XML filter) + |-------------------------------------------------------------------------- + | + | Non-German invoices are moved to this Microsoft 365 folder and marked + | ignored on the attachment row (no Invoice record). + | + */ + + 'foreign_invoice' => [ + 'ignored_folder_name' => env('EBILLING_IGNORED_FOLDER', 'Ignored'), + ], + + /* + |-------------------------------------------------------------------------- + | ZUGFeRD Profile + |-------------------------------------------------------------------------- + | + | The ZUGFeRD/XRechnung profile to use for conversion. + | Options: MINIMUM, BASIC, EN16931, EXTENDED, XRECHNUNG + | + */ + + 'zugferd_profile' => 'EN16931', + + /* + |-------------------------------------------------------------------------- + | Default customer country (TRANSITIONAL) + |-------------------------------------------------------------------------- + | + | Fallback buyer/delivery country when the parser derives none (domestic + | addresses without a country line). Removed by the master-data phase + | (Company/Address lookup). Foreign invoices are still detected via + | BILLING_COUNTRY_MAP and filtered before XML. + | + */ + + 'default_customer_country' => 'DE', + + /* + |-------------------------------------------------------------------------- + | Supplier (master data) + |-------------------------------------------------------------------------- + | + | Central supplier data for invoices. + | Copied onto the invoice as a snapshot when the invoice is created. + | + */ + + // Example supplier data — override in your application's config/e-billing.php + // or set via environment variables. + 'supplier' => [ + 'name' => 'Acme Stainless GmbH', + 'vat_id' => 'DE123456789', + 'tax_number' => '12345/67890', + 'address' => "Acme Stainless GmbH\nMusterstraße 1\nD-12345 Musterstadt", + 'country_code' => 'DE', + 'phone' => '+49 (0)1234 567890', + 'email' => 'billing@example.com', + + /* + |-------------------------------------------------------------------------- + | Bank accounts + |-------------------------------------------------------------------------- + | + | Multiple accounts allowed (e.g. different banks / currencies). + | + */ + + 'bank_accounts' => [ + + [ + 'bank_name' => 'Example Bank eG', + 'iban' => 'DE00100000000000001234', + 'bic' => 'EXMPDEDB', + ], + + [ + 'bank_name' => 'Muster Sparkasse', + 'iban' => 'DE00200000000000005678', + 'bic' => 'MSPKDEDB', + ], + + ], + ], + + /* + |-------------------------------------------------------------------------- + | Field validation (MoSCoW) + |-------------------------------------------------------------------------- + */ + + 'field_validation' => [ + + /* + |-------------------------------------------------------------------------- + | MoSCoW Priority per Field + |-------------------------------------------------------------------------- + | + | Determines how the InvoiceFieldValidator treats each field: + | - must: Missing/invalid → blocks transition to validated, forces human review + | - should: Missing/invalid → warning, ideally reviewed but not blocking + | - could: Missing/invalid → info only, auto-accepted + | + | Fields not listed here are treated as 'could' by default. + | + */ + + 'invoice_fields' => [ + // Document identification — MUST + 'invoice_number' => 'must', // BT-1 + 'invoice_date' => 'must', // BT-2 + 'document_type' => 'must', // BT-3 + 'due_date' => 'should', // BT-9 + 'currency' => 'must', // BT-5 + + // Buyer — MUST (core identification) + 'customer_number' => 'must', + 'customer_name' => 'must', // BT-44 + 'customer_address' => 'must', // BG-8 + 'country' => 'could', // BT-55 + 'customer_vat_id' => 'should', // BT-48 + + // Buyer reference + 'customer_reference' => 'should', // BT-10 + 'order_number' => 'should', // BT-13 + 'order_date' => 'could', // BT-13 date + + // Delivery + 'delivery_address' => 'could', // BG-15 + + // Seller — MUST (own company data, from system settings later) + 'supplier_name' => 'must', // BT-27 + 'supplier_vat_id' => 'must', // BT-31 + 'supplier_tax_number' => 'should', // BT-32 + 'supplier_address' => 'must', // BG-5 + 'supplier_bank_accounts' => 'should', // BG-17 + + // Agent & terms + 'agent' => 'could', + 'payment_terms' => 'should', // BT-20 + 'pricing_basis' => 'could', + 'shipping_method' => 'could', + + // Amounts — MUST + 'net_total' => 'must', // BT-109 + 'vat_rate' => 'must', // BT-119 + 'vat_amount' => 'must', // BT-110 + 'gross_total' => 'must', // BT-112 + + // Optional amounts + 'discount_percent' => 'could', + 'discount_amount' => 'could', + 'shipping_cost' => 'could', + 'minimum_quantity_surcharge' => 'could', + 'freight_flat_rate' => 'could', + 'packaging_cost' => 'could', + ], + + 'invoice_line_fields' => [ + 'position' => 'must', + 'description' => 'must', // BT-153 + 'quantity' => 'must', // BT-129 + 'unit' => 'must', // BT-130 + 'unit_price' => 'must', // BT-146 + 'line_total' => 'must', // BT-131 + + 'article_number' => 'should', // BT-155 + 'material' => 'should', // Werkstoffnummer (industry-specific) + 'customs_tariff_number' => 'could', // BT-158 + + 'description_detail' => 'could', + 'material_test_certificate' => 'could', + 'material_test_certificate_price' => 'could', + 'weight_kg_total' => 'could', + 'weight_kg_net' => 'could', + 'surcharge_amount' => 'could', + 'surcharge_description' => 'could', + 'delivery_date' => 'should', // BT-134 + 'delivery_note_number' => 'could', // BT-16 + 'order_number' => 'could', // BT-132 (item-level override) + 'order_date' => 'could', + 'delivery_address' => 'could', + ], + + /* + |-------------------------------------------------------------------------- + | Contextual Rules (should-priority fields) + |-------------------------------------------------------------------------- + | + | When a 'should' field is empty: if it appears in the contextual list below, + | status is 'missing'; otherwise 'not_applicable'. Fields not listed default + | to 'not_applicable' when empty. + | + */ + + 'invoice_contextual_should' => [ + 'customer_vat_id', + 'payment_terms', + 'supplier_tax_number', + 'supplier_bank_accounts', + ], + + 'invoice_line_contextual_should' => [ + 'article_number', + 'material', + 'delivery_date', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Morph pivots (owner side → kosit_validatables) + |-------------------------------------------------------------------------- + */ + 'morph_relations' => [ + 'kosit_validatables' => [ + 'relationship' => 'kositValidations', + 'model' => KositValidation::class, + 'pivot_model' => KositValidatable::class, + 'pivot_table' => 'kosit_validatables', + 'morph_name' => 'validatable', + 'pivot_columns' => [], + 'related_key' => 'kosit_validation_id', + ], + ], + +]; diff --git a/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub b/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub new file mode 100644 index 0000000000..d61770e36e --- /dev/null +++ b/packages/e-billing/database/migrations/create_ebilling_documents_table.php.stub @@ -0,0 +1,40 @@ +uuid('id')->primary(); + $table->nullableMorphs('source'); + $table->json('bill_data')->nullable(); + $table->string('xml_storage_path')->nullable(); + $table->string('zugferd_storage_disk')->nullable(); + $table->string('zugferd_storage_path')->nullable(); + $table->json('ignored_reason')->nullable(); + $table->string('gateway_status')->nullable()->index(); + $table->string('review_status')->default('parser_created')->index(); + $table->unsignedTinyInteger('validation_score')->nullable(); + $table->json('field_validations')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->foreignUuid('company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->foreignUuid('invoice_id')->nullable()->constrained('invoices')->nullOnDelete(); + + $table->string('scope')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ebilling_documents'); + } +}; diff --git a/packages/e-billing/resources/lang/de/ebilling.php b/packages/e-billing/resources/lang/de/ebilling.php new file mode 100644 index 0000000000..e2c1f45d39 --- /dev/null +++ b/packages/e-billing/resources/lang/de/ebilling.php @@ -0,0 +1,7 @@ + 'Rechnung', + 'invoices' => 'Rechnungen', + 'navigation_group' => 'E-Billing', +]; diff --git a/packages/e-billing/resources/lang/de/fields.php b/packages/e-billing/resources/lang/de/fields.php new file mode 100644 index 0000000000..3c48d4733d --- /dev/null +++ b/packages/e-billing/resources/lang/de/fields.php @@ -0,0 +1,230 @@ + 'Rechnungsnummer', + 'invoice_number_short' => 'Rechnungsnr.', + 'invoice_date' => 'Rechnungsdatum', + 'document_type' => 'Dokumenttyp', + 'due_date' => 'Fälligkeitsdatum', + 'currency' => 'Währung', + 'customer_number' => 'Kundennummer', + 'customer_name' => 'Name', + 'supplier_name' => 'Name', + 'customer_address' => 'Adresse', + 'supplier_address' => 'Adresse', + 'country' => 'Land', + 'customer_vat_id' => 'USt-IdNr.', + 'supplier_vat_id' => 'USt-IdNr.', + 'customer_reference' => 'Kundenreferenz', + 'order_number' => 'Bestellnummer', + 'order_date' => 'Bestelldatum', + 'delivery_address' => 'Lieferadresse', + 'supplier_number' => 'Lieferantennummer', + 'supplier_phone' => 'Telefon Lieferant', + 'supplier_email' => 'E-Mail Lieferant', + 'bank_accounts' => 'Bankverbindungen', + 'agent' => 'Vertreter', + 'payment_terms' => 'Zahlungsbedingungen', + 'pricing_basis' => 'Preisgrundlage', + 'shipping_method' => 'Versandart', + 'net_total' => 'Nettobetrag', + 'vat_rate' => 'Steuersatz', + 'vat_amount' => 'Steuerbetrag', + 'gross_total' => 'Bruttobetrag', + 'gross_total_short' => 'Brutto', + 'discount_percent' => 'Rabatt (%)', + 'discount_amount' => 'Rabattbetrag', + 'shipping_cost' => 'Versandkosten', + 'minimum_quantity_surcharge' => 'Mindermengenzuschlag', + 'freight_flat_rate' => 'Frachtkostenpauschale', + 'packaging_cost' => 'Verpackungskosten', + 'notes' => 'Bemerkungen', + 'position' => 'Position', + 'description' => 'Bezeichnung', + 'description_detail' => 'Bezeichnung (Detail)', + 'quantity' => 'Menge', + 'unit' => 'Einheit', + 'unit_price' => 'Einzelpreis', + 'line_total' => 'Positionsbetrag', + 'article_number' => 'Artikelnummer', + 'material' => 'Werkstoff', + 'material_number' => 'Werkstoffnummer', + 'material_test_certificate' => 'Werksprüfzeugnis', + 'material_test_certificate_price' => 'Werksprüfzeugnis (Preis)', + 'customs_tariff_number' => 'Zolltarifnummer', + 'weight_kg_total' => 'Gewicht (brutto, kg)', + 'weight_kg_net' => 'Gewicht (netto, kg)', + 'weight' => 'Gewicht', + 'weight_unit' => 'Gewichtseinheit', + 'surcharge_amount' => 'Zuschlagsbetrag', + 'surcharge_description' => 'Zuschlagsbeschreibung', + 'surcharge_rate' => 'Zuschlagssatz', + 'delivery_date' => 'Lieferdatum', + 'delivery_note_number' => 'Lieferscheinnummer', + 'seller_address' => 'Lieferantenadresse', + 'seller_tax_id' => 'USt-IdNr. Lieferant', + 'seller_phone' => 'Telefon Lieferant', + 'seller_email' => 'E-Mail Lieferant', + 'seller_bank_iban' => 'IBAN Lieferant', + 'seller_bank_bic' => 'BIC Lieferant', + 'seller_bank_name' => 'Bank Lieferant', + 'buyer_address' => 'Empfängeradresse', + 'buyer_tax_id' => 'USt-IdNr. Empfänger', + 'tax_number' => 'Steuernummer', + 'supplier' => 'Lieferant', + 'recipient' => 'Empfänger', + 'kosit' => 'KOSIT', + 'validation' => 'Validierung', + 'score' => 'Score', + 'status' => 'Status', + 'created_at' => 'Erstellt', + 'kosit_status' => 'KOSIT-Status', + + // Invoice status + 'status_parser_created' => 'Vom Parser erstellt', + 'status_db_validated' => 'Automatisch vorgeprüft', + 'status_db_validated_short' => 'Geprüft', + 'status_human_confirmed' => 'Manuell bestätigt', + 'status_validated' => 'Validiert', + + // Gateway status + 'gateway_status_xml_generating' => 'XML wird erzeugt', + 'gateway_status_xml_generation_failed' => 'XML-Erzeugung fehlgeschlagen', + 'gateway_status_xml_validated' => 'XML validiert', + 'gateway_status_xml_validation_failed' => 'XML-Validierung fehlgeschlagen', + 'gateway_status_kosit_error' => 'KOSIT-Fehler', + 'gateway_status_zugferd_pdf_generating' => 'ZUGFeRD-PDF wird erzeugt', + 'gateway_status_zugferd_pdf_generated' => 'ZUGFeRD-PDF erzeugt', + 'gateway_status_zugferd_pdf_failed' => 'ZUGFeRD-PDF fehlgeschlagen', + 'gateway_status_ignored_foreign' => 'Ignoriert (Ausland)', + + // Validation badges + 'validation_badge_validated' => 'Bestätigt', + 'validation_badge_db_validated' => 'Automatisch geprüft', + 'validation_badge_parsed' => 'Geparst', + 'validation_badge_needs_review' => 'Prüfung nötig', + 'validation_badge_missing' => 'Fehlt', + 'validation_badge_not_applicable' => 'Nicht relevant', + 'validation_badge_unknown' => 'Unbekannt', + + // Validation status labels + 'validation_status_valid' => 'Gültig', + 'validation_status_db_validated' => 'Mit Datenbank abgeglichen', + 'validation_status_parsed' => 'Vom Parser erkannt', + 'validation_status_not_applicable' => 'Nicht zutreffend', + 'validation_status_needs_review' => 'Prüfung erforderlich', + 'validation_status_invalid' => 'Ungültig', + 'validation_status_missing' => 'Fehlt', + 'validation_status_unmatched' => 'Nicht in Stammdaten', + + // Validation messages + 'validation_message_ok' => 'In Ordnung', + 'validation_message_required_missing' => 'Pflichtfeld fehlt', + 'validation_message_recommended_missing' => 'Feld fehlt (empfohlen)', + 'validation_message_field_missing' => 'Feld fehlt', + 'validation_message_required_not_in_master' => 'Pflichtfeld: Nicht in Stammdaten gefunden', + 'validation_message_not_in_master' => 'Nicht in Stammdaten gefunden', + 'validation_message_required_review' => 'Pflichtfeld: Bitte prüfen', + 'validation_message_review_deviation' => 'Bitte prüfen (Abweichung oder Stammdaten)', + 'validation_message_required_invalid' => 'Pflichtfeld: Ungültiger Wert', + 'validation_message_invalid_value' => 'Ungültiger Wert', + 'validation_message_please_review' => 'Bitte prüfen', + + // Sections + 'section_document_data' => 'Dokumentdaten', + 'section_seller_supplier' => 'Verkäufer / Lieferant', + 'section_buyer_customer' => 'Käufer / Kunde', + 'section_delivery' => 'Lieferung', + 'section_amounts' => 'Beträge', + 'section_line_items' => 'Positionen', + 'section_notes' => 'Notizen', + + // Status banners + 'banner_incomplete' => '⚠ Unvollständig — Pflichtfelder fehlen', + 'banner_db_validated' => 'Automatisch vorgeprüft — Manuelle Bestätigung nötig', + 'banner_human_confirmed' => 'Manuell bestätigt — Warte auf finale Freigabe', + 'banner_validated' => '✓ Freigegeben', + + // Tabs + 'tab_all' => 'Alle', + 'tab_needs_review' => 'Prüfung nötig', + 'tab_confirmed' => 'Bestätigt', + 'tab_deleted' => 'Gelöscht', + + // Filters + 'filter_needs_review' => 'Prüfung nötig', + 'filter_from' => 'Von', + 'filter_until' => 'Bis', + 'filter_yes' => 'Ja', + 'filter_no' => 'Nein', + 'filter_kosit_passed' => 'Bestanden', + 'filter_kosit_failed' => 'Fehlgeschlagen', + + // Tooltips + 'tooltip_kosit_passed' => 'KOSIT-Validierung bestanden', + 'tooltip_kosit_failed' => 'KOSIT-Validierung fehlgeschlagen', + 'tooltip_not_validated_yet' => 'Noch nicht validiert', + 'tooltip_validation_score' => 'Validierungsergebnis: :score % der Pflicht- und empfohlenen Felder sind gültig', + 'tooltip_please_review' => 'Bitte prüfen', + 'tooltip_all_fields_valid' => 'Alle Felder geprüft und gültig', + 'tooltip_manual_review_required' => 'Manuelle Prüfung erforderlich', + 'tooltip_auto_validated' => 'Automatisch validiert', + 'tooltip_validation_errors_present' => 'Validierungsfehler vorhanden', + 'tooltip_reviewed_database' => 'Geprüft (Datenbank)', + + // Actions + 'action_details' => 'Details', + 'action_kosit_report' => 'KOSIT-Bericht', + 'action_confirm' => 'Rechnung bestätigen', + 'action_confirm_with_attention' => 'Rechnung bestätigen (:count Felder erfordern Aufmerksamkeit)', + 'action_confirm_modal_heading' => 'Rechnung bestätigen', + 'action_confirm_modal_description' => 'Hiermit bestätigen Sie, dass alle Felder dieser Rechnung korrekt sind. Dieser Schritt kann nicht rückgängig gemacht werden.', + 'action_confirm_submit' => 'Ja, bestätigen', + + // Notifications + 'notification_confirmed_title' => 'Rechnung bestätigt', + 'notification_confirmed_body' => 'Die Rechnung wurde als manuell geprüft markiert.', + 'notification_confirm_failed_title' => 'Bestätigung nicht möglich', + 'notification_confirm_failed_body' => 'Die Rechnung befindet sich nicht im Status „Automatisch vorgeprüft“.', + + // Downloads & preview + 'download_zugferd_pdf' => 'ZUGFeRD-PDF', + 'download_xml' => 'XML', + 'preview_pdf_title' => 'PDF-Vorschau', + 'preview_no_original_pdf' => 'Kein Original-PDF verfügbar', + + // Empty states + 'empty_no_field_data' => 'Keine Felddaten verfügbar.', + 'empty_no_line_items' => 'Keine Positionen vorhanden.', + 'line_item_position' => 'Position :position', + + // Field hints — missing + 'hint_missing_invoice_number' => 'Rechnungsnummer konnte nicht aus dem Dokument gelesen werden.', + 'hint_missing_customer_number' => 'Kundennummer fehlt im Dokument.', + 'hint_missing_customer_name' => 'Kundenname konnte nicht erkannt werden.', + 'hint_missing_net_total' => 'Nettobetrag fehlt oder konnte nicht berechnet werden.', + 'hint_missing_vat_rate' => 'Mehrwertsteuersatz fehlt im Dokument.', + 'hint_missing_gross_total' => 'Bruttobetrag fehlt oder konnte nicht berechnet werden.', + 'hint_missing_invoice_date' => 'Rechnungsdatum fehlt im Dokument.', + 'hint_missing_currency' => 'Währung fehlt im Dokument.', + 'hint_missing_supplier_name' => 'Verkäufername fehlt im Dokument.', + 'hint_missing_minimum_quantity_surcharge' => 'Mindermengenzuschlag fehlt im Dokument.', + 'hint_missing_freight_flat_rate' => 'Frachtkostenpauschale fehlt im Dokument.', + 'hint_missing_quantity' => 'Menge fehlt in dieser Position.', + 'hint_missing_description' => 'Positionsbeschreibung fehlt.', + 'hint_missing_default' => 'Dieses Pflichtfeld fehlt im Dokument.', + + // Field hints — needs review + 'hint_review_customer_number' => 'Kundennummer konnte nicht in den Stammdaten gefunden werden.', + 'hint_review_customer_name' => 'Kundenname stimmt nicht mit den Stammdaten überein.', + 'hint_review_article_number' => 'Artikelnummer nicht in den Stammdaten gefunden.', + 'hint_review_material' => 'Werkstoff nicht in den Stammdaten gefunden.', + 'hint_review_customer_vat_id' => 'USt-IdNr. konnte nicht verifiziert werden.', + 'hint_review_supplier_vat_id' => 'USt-IdNr. des Verkäufers konnte nicht verifiziert werden.', + 'hint_review_unit_price' => 'Einzelpreis weicht von den Stammdaten ab.', + 'hint_review_minimum_quantity_surcharge' => 'Mindermengenzuschlag sollte manuell überprüft werden.', + 'hint_review_freight_flat_rate' => 'Frachtkostenpauschale sollte manuell überprüft werden.', + 'hint_review_default' => 'Dieses Feld sollte manuell überprüft werden.', +]; diff --git a/packages/e-billing/resources/lang/en/ebilling.php b/packages/e-billing/resources/lang/en/ebilling.php new file mode 100644 index 0000000000..b2d44d0b75 --- /dev/null +++ b/packages/e-billing/resources/lang/en/ebilling.php @@ -0,0 +1,7 @@ + 'Invoice', + 'invoices' => 'Invoices', + 'navigation_group' => 'E-Billing', +]; diff --git a/packages/e-billing/resources/lang/en/fields.php b/packages/e-billing/resources/lang/en/fields.php new file mode 100644 index 0000000000..583e2a4df8 --- /dev/null +++ b/packages/e-billing/resources/lang/en/fields.php @@ -0,0 +1,230 @@ + 'Invoice number', + 'invoice_number_short' => 'Invoice no.', + 'invoice_date' => 'Invoice date', + 'document_type' => 'Document type', + 'due_date' => 'Due date', + 'currency' => 'Currency', + 'customer_number' => 'Customer number', + 'customer_name' => 'Name', + 'supplier_name' => 'Name', + 'customer_address' => 'Address', + 'supplier_address' => 'Address', + 'country' => 'Country', + 'customer_vat_id' => 'VAT ID', + 'supplier_vat_id' => 'VAT ID', + 'customer_reference' => 'Customer reference', + 'order_number' => 'Order number', + 'order_date' => 'Order date', + 'delivery_address' => 'Delivery address', + 'supplier_number' => 'Supplier number', + 'supplier_phone' => 'Supplier phone', + 'supplier_email' => 'Supplier email', + 'bank_accounts' => 'Bank accounts', + 'agent' => 'Agent', + 'payment_terms' => 'Payment terms', + 'pricing_basis' => 'Pricing basis', + 'shipping_method' => 'Shipping method', + 'net_total' => 'Net amount', + 'vat_rate' => 'Tax rate', + 'vat_amount' => 'Tax amount', + 'gross_total' => 'Gross amount', + 'gross_total_short' => 'Gross', + 'discount_percent' => 'Discount (%)', + 'discount_amount' => 'Discount amount', + 'shipping_cost' => 'Shipping cost', + 'minimum_quantity_surcharge' => 'Minimum quantity surcharge', + 'freight_flat_rate' => 'Freight flat rate', + 'packaging_cost' => 'Packaging cost', + 'notes' => 'Notes', + 'position' => 'Line item', + 'description' => 'Description', + 'description_detail' => 'Description (detail)', + 'quantity' => 'Quantity', + 'unit' => 'Unit', + 'unit_price' => 'Unit price', + 'line_total' => 'Line total', + 'article_number' => 'Article number', + 'material' => 'Material', + 'material_number' => 'Material number', + 'material_test_certificate' => 'Material test certificate', + 'material_test_certificate_price' => 'Material test certificate (price)', + 'customs_tariff_number' => 'Customs tariff number', + 'weight_kg_total' => 'Weight (gross, kg)', + 'weight_kg_net' => 'Weight (net, kg)', + 'weight' => 'Weight', + 'weight_unit' => 'Weight unit', + 'surcharge_amount' => 'Surcharge amount', + 'surcharge_description' => 'Surcharge description', + 'surcharge_rate' => 'Surcharge rate', + 'delivery_date' => 'Delivery date', + 'delivery_note_number' => 'Delivery note number', + 'seller_address' => 'Supplier address', + 'seller_tax_id' => 'Supplier VAT ID', + 'seller_phone' => 'Supplier phone', + 'seller_email' => 'Supplier email', + 'seller_bank_iban' => 'Supplier IBAN', + 'seller_bank_bic' => 'Supplier BIC', + 'seller_bank_name' => 'Supplier bank', + 'buyer_address' => 'Recipient address', + 'buyer_tax_id' => 'Recipient VAT ID', + 'tax_number' => 'Tax number', + 'supplier' => 'Supplier', + 'recipient' => 'Recipient', + 'kosit' => 'KOSIT', + 'validation' => 'Validation', + 'score' => 'Score', + 'status' => 'Status', + 'created_at' => 'Created', + 'kosit_status' => 'KOSIT status', + + // Invoice status + 'status_parser_created' => 'Created by parser', + 'status_db_validated' => 'Automatically pre-reviewed', + 'status_db_validated_short' => 'Reviewed', + 'status_human_confirmed' => 'Manually confirmed', + 'status_validated' => 'Validated', + + // Gateway status + 'gateway_status_xml_generating' => 'XML is being generated', + 'gateway_status_xml_generation_failed' => 'XML generation failed', + 'gateway_status_xml_validated' => 'XML validated', + 'gateway_status_xml_validation_failed' => 'XML validation failed', + 'gateway_status_kosit_error' => 'KOSIT error', + 'gateway_status_zugferd_pdf_generating' => 'ZUGFeRD PDF is being generated', + 'gateway_status_zugferd_pdf_generated' => 'ZUGFeRD PDF generated', + 'gateway_status_zugferd_pdf_failed' => 'ZUGFeRD PDF failed', + 'gateway_status_ignored_foreign' => 'Ignored (foreign)', + + // Validation badges + 'validation_badge_validated' => 'Confirmed', + 'validation_badge_db_validated' => 'Automatically reviewed', + 'validation_badge_parsed' => 'Parsed', + 'validation_badge_needs_review' => 'Review needed', + 'validation_badge_missing' => 'Missing', + 'validation_badge_not_applicable' => 'Not applicable', + 'validation_badge_unknown' => 'Unknown', + + // Validation status labels + 'validation_status_valid' => 'Valid', + 'validation_status_db_validated' => 'Matched with database', + 'validation_status_parsed' => 'Recognized by parser', + 'validation_status_not_applicable' => 'Not applicable', + 'validation_status_needs_review' => 'Review required', + 'validation_status_invalid' => 'Invalid', + 'validation_status_missing' => 'Missing', + 'validation_status_unmatched' => 'Not in master data', + + // Validation messages + 'validation_message_ok' => 'OK', + 'validation_message_required_missing' => 'Required field missing', + 'validation_message_recommended_missing' => 'Field missing (recommended)', + 'validation_message_field_missing' => 'Field missing', + 'validation_message_required_not_in_master' => 'Required: not found in master data', + 'validation_message_not_in_master' => 'Not found in master data', + 'validation_message_required_review' => 'Required: please review', + 'validation_message_review_deviation' => 'Please review (deviation or master data)', + 'validation_message_required_invalid' => 'Required: invalid value', + 'validation_message_invalid_value' => 'Invalid value', + 'validation_message_please_review' => 'Please review', + + // Sections + 'section_document_data' => 'Document data', + 'section_seller_supplier' => 'Seller / Supplier', + 'section_buyer_customer' => 'Buyer / Customer', + 'section_delivery' => 'Delivery', + 'section_amounts' => 'Amounts', + 'section_line_items' => 'Line items', + 'section_notes' => 'Notes', + + // Status banners + 'banner_incomplete' => '⚠ Incomplete — required fields missing', + 'banner_db_validated' => 'Automatically pre-reviewed — manual confirmation needed', + 'banner_human_confirmed' => 'Manually confirmed — awaiting final approval', + 'banner_validated' => '✓ Approved', + + // Tabs + 'tab_all' => 'All', + 'tab_needs_review' => 'Review needed', + 'tab_confirmed' => 'Confirmed', + 'tab_deleted' => 'Deleted', + + // Filters + 'filter_needs_review' => 'Review needed', + 'filter_from' => 'From', + 'filter_until' => 'Until', + 'filter_yes' => 'Yes', + 'filter_no' => 'No', + 'filter_kosit_passed' => 'Passed', + 'filter_kosit_failed' => 'Failed', + + // Tooltips + 'tooltip_kosit_passed' => 'KOSIT validation passed', + 'tooltip_kosit_failed' => 'KOSIT validation failed', + 'tooltip_not_validated_yet' => 'Not validated yet', + 'tooltip_validation_score' => 'Validation result: :score% of required and recommended fields are valid', + 'tooltip_please_review' => 'Please review', + 'tooltip_all_fields_valid' => 'All fields reviewed and valid', + 'tooltip_manual_review_required' => 'Manual review required', + 'tooltip_auto_validated' => 'Automatically validated', + 'tooltip_validation_errors_present' => 'Validation errors present', + 'tooltip_reviewed_database' => 'Reviewed (database)', + + // Actions + 'action_details' => 'Details', + 'action_kosit_report' => 'KOSIT report', + 'action_confirm' => 'Confirm invoice', + 'action_confirm_with_attention' => 'Confirm invoice (:count fields need attention)', + 'action_confirm_modal_heading' => 'Confirm invoice', + 'action_confirm_modal_description' => 'By confirming, you attest that all fields on this invoice are correct. This step cannot be undone.', + 'action_confirm_submit' => 'Yes, confirm', + + // Notifications + 'notification_confirmed_title' => 'Invoice confirmed', + 'notification_confirmed_body' => 'The invoice was marked as manually reviewed.', + 'notification_confirm_failed_title' => 'Confirmation not possible', + 'notification_confirm_failed_body' => 'The invoice is not in status "Automatically pre-reviewed".', + + // Downloads & preview + 'download_zugferd_pdf' => 'ZUGFeRD PDF', + 'download_xml' => 'XML', + 'preview_pdf_title' => 'PDF preview', + 'preview_no_original_pdf' => 'No original PDF available', + + // Empty states + 'empty_no_field_data' => 'No field data available.', + 'empty_no_line_items' => 'No line items.', + 'line_item_position' => 'Line item :position', + + // Field hints — missing + 'hint_missing_invoice_number' => 'Invoice number could not be read from the document.', + 'hint_missing_customer_number' => 'Customer number missing in document.', + 'hint_missing_customer_name' => 'Customer name could not be recognized.', + 'hint_missing_net_total' => 'Net amount missing or could not be calculated.', + 'hint_missing_vat_rate' => 'VAT rate missing in document.', + 'hint_missing_gross_total' => 'Gross amount missing or could not be calculated.', + 'hint_missing_invoice_date' => 'Invoice date missing in document.', + 'hint_missing_currency' => 'Currency missing in document.', + 'hint_missing_supplier_name' => 'Seller name missing in document.', + 'hint_missing_minimum_quantity_surcharge' => 'Minimum quantity surcharge missing in document.', + 'hint_missing_freight_flat_rate' => 'Freight flat rate missing in document.', + 'hint_missing_quantity' => 'Quantity missing on this line item.', + 'hint_missing_description' => 'Line description missing.', + 'hint_missing_default' => 'This required field is missing in the document.', + + // Field hints — needs review + 'hint_review_customer_number' => 'Customer number not found in master data.', + 'hint_review_customer_name' => 'Customer name does not match master data.', + 'hint_review_article_number' => 'Article number not found in master data.', + 'hint_review_material' => 'Material not found in master data.', + 'hint_review_customer_vat_id' => 'VAT ID could not be verified.', + 'hint_review_supplier_vat_id' => 'Seller VAT ID could not be verified.', + 'hint_review_unit_price' => 'Unit price differs from master data.', + 'hint_review_minimum_quantity_surcharge' => 'Minimum quantity surcharge should be reviewed manually.', + 'hint_review_freight_flat_rate' => 'Freight flat rate should be reviewed manually.', + 'hint_review_default' => 'This field should be reviewed manually.', +]; diff --git a/packages/e-billing/resources/views/components/validation-score-ring-display.blade.php b/packages/e-billing/resources/views/components/validation-score-ring-display.blade.php new file mode 100644 index 0000000000..5194eda87f --- /dev/null +++ b/packages/e-billing/resources/views/components/validation-score-ring-display.blade.php @@ -0,0 +1,30 @@ +@if(! isset($score) || $score === null) + +@else + @php + $color = match (true) { + $score >= 100 => '#22c55e', + $score >= 70 => '#f59e0b', + default => '#ef4444', + }; + $radius = 16; + $circumference = 2 * M_PI * $radius; + $offset = $circumference - ($score / 100) * $circumference; + @endphp +
+ +
+@endif diff --git a/packages/e-billing/resources/views/components/validation-score-ring.blade.php b/packages/e-billing/resources/views/components/validation-score-ring.blade.php new file mode 100644 index 0000000000..7b94798816 --- /dev/null +++ b/packages/e-billing/resources/views/components/validation-score-ring.blade.php @@ -0,0 +1,35 @@ +@php + $score = $getState(); +@endphp + +@if($score === null) + +@else + @php + $color = match (true) { + $score >= 100 => '#22c55e', + $score >= 70 => '#f59e0b', + default => '#ef4444', + }; + $radius = 16; + $circumference = 2 * M_PI * $radius; + $offset = $circumference - ($score / 100) * $circumference; + @endphp +
+ + + + + {{ $score }}% + + +
+@endif diff --git a/packages/e-billing/resources/views/filament/pages/view-invoice.blade.php b/packages/e-billing/resources/views/filament/pages/view-invoice.blade.php new file mode 100644 index 0000000000..81240b3a7e --- /dev/null +++ b/packages/e-billing/resources/views/filament/pages/view-invoice.blade.php @@ -0,0 +1,27 @@ + + @include('e-billing::filament.partials.invoice-status-banner', ['viewModel' => $this->invoiceViewModel]) + +
+
+ @include('e-billing::filament.partials.invoice-field-groups', ['viewModel' => $this->invoiceViewModel]) + +
+

+ {{ __('e-billing::fields.section_line_items') }} + BG-25 +

+ @include('e-billing::filament.partials.invoice-line-table', ['viewModel' => $this->invoiceViewModel]) +
+ + @include('e-billing::filament.partials.invoice-notes', ['viewModel' => $this->invoiceViewModel]) +
+ +
+ @include('e-billing::filament.partials.invoice-pdf-preview', [ + 'invoice' => $this->record, + ]) +
+
+
diff --git a/packages/e-billing/resources/views/filament/partials/invoice-field-groups.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-field-groups.blade.php new file mode 100644 index 0000000000..bdc6170c9a --- /dev/null +++ b/packages/e-billing/resources/views/filament/partials/invoice-field-groups.blade.php @@ -0,0 +1,25 @@ +@php + $groups = $viewModel->groupedFields(); +@endphp +@forelse($groups as $group) +
+
+

+ {{ $group['title'] }} + {{ $group['subtitle'] }} +

+
+
+ @foreach($group['fields'] as $field) + @include('e-billing::filament.partials.invoice-field-row', ['field' => $field]) + @endforeach +
+
+@empty +
+ {{ __('e-billing::fields.empty_no_field_data') }} +
+@endforelse diff --git a/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php new file mode 100644 index 0000000000..a7c846808e --- /dev/null +++ b/packages/e-billing/resources/views/filament/partials/invoice-field-row.blade.php @@ -0,0 +1,57 @@ +@php + /** @var \Moox\EBilling\ViewModels\FieldViewData $field */ + $color = $field->badgeColor(); + $badgeSurface = match ($color) { + 'green' => 'border border-emerald-500/20 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300', + 'blue' => 'border border-blue-500/20 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-300', + 'yellow' => 'border border-amber-500/20 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300', + 'red' => 'border border-red-500/20 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300', + default => 'border border-gray-500/20 bg-gray-100 text-gray-600 dark:border-gray-500/30 dark:bg-gray-500/15 dark:text-gray-300', + }; +@endphp +
+
+
+ {{ $field->label }} + @if($field->btNumber) + {{ $field->btNumber }} + @endif +
+
+ @if(is_array($field->value) && isset($field->value[0]) && is_array($field->value[0])) +
    + @foreach($field->value as $row) + @if(is_array($row)) +
  • {{ implode(' · ', array_filter([ + $row['iban'] ?? null, + $row['bic'] ?? null, + $row['bank_name'] ?? null, + ], static fn (mixed $v): bool => is_string($v) && $v !== '')) }}
  • + @endif + @endforeach +
+ @elseif(is_string($field->value) && str_contains($field->value, "\n")) +
{{ $field->value }}
+ @elseif($field->value !== null && $field->value !== '') + {{ $field->value }} + @else + + @endif +
+ @if($field->hint) +
+ {{ $field->hint }} +
+ @endif +
+
+ + + {{ $field->badgeLabel() }} + +
+
diff --git a/packages/e-billing/resources/views/filament/partials/invoice-line-table.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-line-table.blade.php new file mode 100644 index 0000000000..8d470b0d5f --- /dev/null +++ b/packages/e-billing/resources/views/filament/partials/invoice-line-table.blade.php @@ -0,0 +1,28 @@ +@php + $lines = $viewModel->lines(); +@endphp +@forelse($lines as $lineVm) +
+ + + {{ __('e-billing::fields.line_item_position', ['position' => $lineVm->position() ?? '—']) }} + + + +
+
+ @foreach($lineVm->relevantFields() as $field) + @include('e-billing::filament.partials.invoice-field-row', ['field' => $field]) + @endforeach +
+
+
+@empty +
+ {{ __('e-billing::fields.empty_no_line_items') }} +
+@endforelse diff --git a/packages/e-billing/resources/views/filament/partials/invoice-notes.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-notes.blade.php new file mode 100644 index 0000000000..e0b6e636ea --- /dev/null +++ b/packages/e-billing/resources/views/filament/partials/invoice-notes.blade.php @@ -0,0 +1,16 @@ +@php + $notes = $viewModel->notes(); +@endphp +@if(count($notes) > 0) +
+

{{ __('e-billing::fields.section_notes') }}

+
+ @foreach($notes as $note) +

+ {{ $note }}

+ @endforeach +
+
+@endif diff --git a/packages/e-billing/resources/views/filament/partials/invoice-pdf-preview.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-pdf-preview.blade.php new file mode 100644 index 0000000000..6250bc5e1a --- /dev/null +++ b/packages/e-billing/resources/views/filament/partials/invoice-pdf-preview.blade.php @@ -0,0 +1,39 @@ +@php + $document = $invoice->ebillingDocument; + $source = $document?->source; + $hasZugferdDownload = filled($document?->zugferd_storage_path) || filled($source?->zugferd_storage_path); + $hasXmlDownload = filled($document?->xml_storage_path) || filled($source?->xml_storage_path); +@endphp +
+ @if($hasZugferdDownload || $hasXmlDownload) +
+ @if($hasZugferdDownload && $source instanceof \Moox\MailInbox\Models\InboxAttachment) + + {{ __('e-billing::fields.download_zugferd_pdf') }} + + @endif + + @if($hasXmlDownload && $source instanceof \Moox\MailInbox\Models\InboxAttachment) + + {{ __('e-billing::fields.download_xml') }} + + @endif +
+ @endif + + @if($source instanceof \Moox\MailInbox\Models\InboxAttachment) + + @else +
+

{{ __('e-billing::fields.preview_no_original_pdf') }}

+
+ @endif +
diff --git a/packages/e-billing/resources/views/filament/partials/invoice-status-banner.blade.php b/packages/e-billing/resources/views/filament/partials/invoice-status-banner.blade.php new file mode 100644 index 0000000000..b35b415ca4 --- /dev/null +++ b/packages/e-billing/resources/views/filament/partials/invoice-status-banner.blade.php @@ -0,0 +1,21 @@ +@php + $banner = $viewModel->statusBanner(); + $bannerInnerClasses = match ($banner['color']) { + 'red' => 'bg-red-50 text-red-800 dark:bg-red-500/15 dark:text-red-300', + 'yellow' => 'bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300', + 'blue' => 'bg-blue-50 text-blue-800 dark:bg-blue-500/15 dark:text-blue-300', + 'green' => 'bg-emerald-50 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', + default => 'bg-gray-50 text-gray-800 dark:bg-gray-500/15 dark:text-gray-300', + }; +@endphp +
+
+ {{ $banner['text'] }} +
+
+ {{ __('e-billing::fields.validation') }} + @include('e-billing::components.validation-score-ring-display', ['score' => $viewModel->validationScore()]) +
+
diff --git a/packages/e-billing/routes/web.php b/packages/e-billing/routes/web.php new file mode 100644 index 0000000000..e45cf48f06 --- /dev/null +++ b/packages/e-billing/routes/web.php @@ -0,0 +1,17 @@ +prefix('ebilling')->group(function (): void { + Route::get('pdf/{attachment}', [InvoiceDocumentController::class, 'previewOriginal']) + ->name('ebilling.pdf.preview'); + + Route::get('zugferd-download/{attachment}', [InvoiceDocumentController::class, 'downloadZugferd']) + ->name('ebilling.zugferd.download'); + + Route::get('xml-download/{attachment}', [InvoiceDocumentController::class, 'downloadXml']) + ->name('ebilling.xml.download'); +}); diff --git a/packages/e-billing/screenshot/main.jpg b/packages/e-billing/screenshot/main.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/e-billing/screenshot/main.jpg differ diff --git a/packages/e-billing/src/Actions/ConfirmInvoiceAction.php b/packages/e-billing/src/Actions/ConfirmInvoiceAction.php new file mode 100644 index 0000000000..5d404531e5 --- /dev/null +++ b/packages/e-billing/src/Actions/ConfirmInvoiceAction.php @@ -0,0 +1,48 @@ +relationLoaded('ebillingDocument') + ? $invoice->ebillingDocument + : EbillingDocument::query()->where('invoice_id', $invoice->id)->first(); + + if (! $document instanceof EbillingDocument) { + return false; + } + + $status = $document->review_status; + if (! $status instanceof InvoiceProcessingStatus) { + $raw = $document->getAttributes()['review_status'] ?? null; + $status = is_string($raw) ? InvoiceProcessingStatus::tryFrom($raw) : null; + } + + if ($status !== InvoiceProcessingStatus::DbValidated) { + return false; + } + + $document->transitionTo(InvoiceProcessingStatus::HumanConfirmed); + + event(new InvoiceManuallyConfirmed( + document: $document, + confirmedBy: auth()->user()?->name, + wasAutoValidatedFirst: false, + )); + + return true; + } +} diff --git a/packages/e-billing/src/Adapters/ZugferdAddressAdapter.php b/packages/e-billing/src/Adapters/ZugferdAddressAdapter.php new file mode 100644 index 0000000000..39e5b65116 --- /dev/null +++ b/packages/e-billing/src/Adapters/ZugferdAddressAdapter.php @@ -0,0 +1,63 @@ +address->line1); + + return $line1 !== '' ? $line1 : null; + } + } + + public ?string $addressLine2 { + get { + if ($this->address->line2 === null) { + return null; + } + + $line2 = trim($this->address->line2); + + return $line2 !== '' ? $line2 : null; + } + } + + public ?string $addressLine3 { + get => null; + } + + public ?string $zip { + get { + $postalCode = trim($this->address->postal_code); + + return $postalCode !== '' ? $postalCode : null; + } + } + + public ?string $city { + get { + $city = trim($this->address->city); + + return $city !== '' ? $city : null; + } + } + + public ?string $country { + get { + $countryCode = trim($this->address->country_code); + + return $countryCode !== '' ? $countryCode : null; + } + } +} diff --git a/packages/e-billing/src/Adapters/ZugferdBankAccountAdapter.php b/packages/e-billing/src/Adapters/ZugferdBankAccountAdapter.php new file mode 100644 index 0000000000..a1c1145f95 --- /dev/null +++ b/packages/e-billing/src/Adapters/ZugferdBankAccountAdapter.php @@ -0,0 +1,31 @@ + $this->account->iban; + } + + public ?string $bic { + get => $this->account->bic; + } + + public ?string $bankName { + get => $this->account->bank_name; + } + + public ?string $accountHolder { + get => $this->account->account_holder; + } +} diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php new file mode 100644 index 0000000000..8e452277cf --- /dev/null +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceAdapter.php @@ -0,0 +1,173 @@ + (string) $this->model->invoice_number; + } + + public string $invoiceDate { + get => (string) $this->model->invoice_date; + } + + public string $documentType { + get => (string) $this->model->document_type; + } + + public ?string $dueDate { + get => $this->model->due_date !== null ? (string) $this->model->due_date : null; + } + + public string $currency { + get => (string) ($this->model->currency ?? 'EUR'); + } + + public string $customerNumber { + get => ''; + } + + public ?string $customerReference { + get => $this->model->customer_reference; + } + + public string $customerName { + get => $this->model->buyer?->name ?? ''; + } + + public ?ZugferdAddress $customerAddress { + get => $this->model->buyer !== null + ? new ZugferdAddressAdapter($this->model->buyer->address) + : null; + } + + public ?string $customerVatId { + get => $this->model->buyer?->vat_id; + } + + public string $supplierName { + get => $this->model->seller?->name ?? ''; + } + + public ?ZugferdAddress $supplierAddress { + get => $this->model->seller !== null + ? new ZugferdAddressAdapter($this->model->seller->address) + : null; + } + + public ?string $supplierPhone { + get => $this->model->seller?->contact?->phone; + } + + public ?string $supplierEmail { + get => $this->model->seller?->contact?->email; + } + + public ?string $agent { + get { + $name = $this->model->seller?->contact?->name; + + if ($name === null || trim($name) === '') { + return null; + } + + return trim($name); + } + } + + public ?string $supplierVatId { + get => $this->model->seller?->vat_id; + } + + public ?string $supplierTaxNumber { + get => $this->model->seller?->tax_number; + } + + public ?string $paymentTerms { + get => null; + } + + public ?string $paymentMeansCode { + get => $this->model->payment_means?->payment_means_code; + } + + public float $vatRate { + get => (float) $this->model->vat_rate; + } + + public float $netTotal { + get => (float) $this->model->net_total; + } + + public float $vatAmount { + get => (float) $this->model->vat_amount; + } + + public float $grossTotal { + get => (float) $this->model->gross_total; + } + + /** @var list */ + public array $allowanceCharges { + get { + $this->model->loadMissing('allowanceCharges'); + + return $this->model->allowanceCharges + ->map(fn (InvoiceAllowanceCharge $charge): AllowanceCharge => self::mapAllowanceCharge($charge)) + ->values() + ->all(); + } + } + + /** @var list */ + public array $lines { + get { + $this->model->loadMissing(['lines.allowanceCharges']); + + return $this->model->lines + ->map(fn ($line): ZugferdInvoiceLineAdapter => new ZugferdInvoiceLineAdapter($line)) + ->values() + ->all(); + } + } + + /** @var list */ + public array $bankAccounts { + get { + $accounts = $this->model->payment_means?->bank_accounts ?? []; + + return array_map( + fn ($account): ZugferdBankAccountAdapter => new ZugferdBankAccountAdapter($account), + $accounts, + ); + } + } + + private static function mapAllowanceCharge(InvoiceAllowanceCharge $charge): AllowanceCharge + { + return new AllowanceCharge( + isCharge: (bool) $charge->is_charge, + amount: (float) $charge->amount, + reasonCode: $charge->reason_code, + reasonText: $charge->reason_text, + basisAmount: $charge->base_amount !== null ? (float) $charge->base_amount : null, + percentage: $charge->percentage !== null ? (float) $charge->percentage : null, + ); + } +} diff --git a/packages/e-billing/src/Adapters/ZugferdInvoiceLineAdapter.php b/packages/e-billing/src/Adapters/ZugferdInvoiceLineAdapter.php new file mode 100644 index 0000000000..4ea9b4d0bc --- /dev/null +++ b/packages/e-billing/src/Adapters/ZugferdInvoiceLineAdapter.php @@ -0,0 +1,74 @@ + (int) $this->line->position; + } + + public string $description { + get => (string) ($this->line->description ?? ''); + } + + public ?string $descriptionDetail { + get => $this->line->description_detail; + } + + public ?string $articleNumber { + get => $this->line->article_number; + } + + public float $unitPrice { + get => (float) $this->line->unit_price; + } + + public float $quantity { + get => (float) $this->line->quantity; + } + + public string $unit { + get => (string) ($this->line->unit ?? 'Stück'); + } + + public float $lineTotal { + get => (float) $this->line->line_total; + } + + /** @var list */ + public array $allowanceCharges { + get { + $this->line->loadMissing('allowanceCharges'); + + return $this->line->allowanceCharges + ->map(fn (InvoiceAllowanceCharge $charge): AllowanceCharge => self::mapAllowanceCharge($charge)) + ->values() + ->all(); + } + } + + private static function mapAllowanceCharge(InvoiceAllowanceCharge $charge): AllowanceCharge + { + return new AllowanceCharge( + isCharge: (bool) $charge->is_charge, + amount: (float) $charge->amount, + reasonCode: $charge->reason_code, + reasonText: $charge->reason_text, + basisAmount: $charge->base_amount !== null ? (float) $charge->base_amount : null, + percentage: $charge->percentage !== null ? (float) $charge->percentage : null, + ); + } +} diff --git a/packages/e-billing/src/Console/Commands/BackfillValidationScoresCommand.php b/packages/e-billing/src/Console/Commands/BackfillValidationScoresCommand.php new file mode 100644 index 0000000000..02d8c11939 --- /dev/null +++ b/packages/e-billing/src/Console/Commands/BackfillValidationScoresCommand.php @@ -0,0 +1,44 @@ + $documents */ + $documents = EbillingDocument::query() + ->whereNotNull('field_validations') + ->whereNull('validation_score') + ->get(); + + $this->info("Backfilling scores for {$documents->count()} documents..."); + + $bar = $this->output->createProgressBar($documents->count()); + + foreach ($documents as $document) { + $score = $document->calculateValidationScore(); + if ($score !== null) { + $document->validation_score = $score; + $document->saveQuietly(); + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info('Done.'); + + return self::SUCCESS; + } +} diff --git a/packages/e-billing/src/Contracts/InvoiceParserInterface.php b/packages/e-billing/src/Contracts/InvoiceParserInterface.php new file mode 100644 index 0000000000..7c3059140e --- /dev/null +++ b/packages/e-billing/src/Contracts/InvoiceParserInterface.php @@ -0,0 +1,17 @@ +app->bind(InvoiceParserInterface::class, YourInvoiceParser::class); + */ + public function parse(string $rawText): Invoice; +} diff --git a/packages/e-billing/src/Data/Address.php b/packages/e-billing/src/Data/Address.php new file mode 100644 index 0000000000..5446635a80 --- /dev/null +++ b/packages/e-billing/src/Data/Address.php @@ -0,0 +1,534 @@ +company ?? '') === ($other->company ?? '') + && ($this->street ?? '') === ($other->street ?? '') + && ($this->zip ?? '') === ($other->zip ?? '') + && ($this->city ?? '') === ($other->city ?? '') + && ($this->country ?? '') === ($other->country ?? '') + && ($this->addressLine2 ?? '') === ($other->addressLine2 ?? '') + && ($this->addressLine3 ?? '') === ($other->addressLine3 ?? ''); + } + + /** + * @return array{company: ?string, street: ?string, zip: ?string, city: ?string, country: ?string, address_line_2: ?string, address_line_3: ?string} + */ + public function toArray(): array + { + return [ + 'company' => $this->company, + 'street' => $this->street, + 'zip' => $this->zip, + 'city' => $this->city, + 'country' => $this->country, + 'address_line_2' => $this->addressLine2, + 'address_line_3' => $this->addressLine3, + ]; + } + + /** + * @return array{zip: string, city: string}|null + */ + private static function extractPostalAndCity(string $line): ?array + { + $candidate = trim($line); + if ($candidate === '') { + return null; + } + + if (preg_match('/^(?:(DE-|D-))?(\d{5})\h+(\D.+)$/ui', $candidate, $m) === 1) { + $city = trim($m[3]); + if ($city === '') { + return null; + } + + return [ + 'zip' => $m[2], + 'city' => $city, + ]; + } + + if (! preg_match('/^(?A|AT|BE|CH|CZ|FR|IT|LU|NL|PL)-(?[A-Z0-9][A-Z0-9\h-]{2,10})\h+(?\D.+)$/ui', $candidate, $m)) { + return null; + } + + $city = trim($m['city']); + if ($city === '') { + return null; + } + + return [ + 'zip' => trim($m['zip']), + 'city' => $city, + ]; + } + + /** + * @param array $lines + * @return array{zip: string, city: string}|null + */ + private static function pullFirstPostalAndCity(array &$lines): ?array + { + foreach ($lines as $idx => $line) { + $postal = self::extractPostalAndCity($line); + if ($postal === null) { + continue; + } + + unset($lines[$idx]); + $lines = array_values($lines); + + return $postal; + } + + return null; + } + + /** + * @param array $lines + */ + private static function streetFromLines(array $lines): ?string + { + return self::postalAddressPartsFromLines($lines)['street']; + } + + /** + * @param array $lines + * @return array{street: ?string, address_line_2: ?string, address_line_3: ?string} + */ + private static function postalAddressPartsFromLines(array $lines): array + { + $streetIdx = self::findStreetLineIndex($lines); + if ($streetIdx === null) { + return [ + 'street' => $lines !== [] ? implode("\n", $lines) : null, + 'address_line_2' => null, + 'address_line_3' => null, + ]; + } + + $additionalLines = array_values(array_filter( + array_slice($lines, 0, $streetIdx), + fn (string $line): bool => trim($line) !== '' + )); + + return [ + 'street' => $lines[$streetIdx], + 'address_line_2' => $additionalLines[0] ?? null, + 'address_line_3' => $additionalLines[1] ?? null, + ]; + } + + /** + * @param array $lines + * @return array{company: ?string, street: ?string, address_line_2: ?string, address_line_3: ?string, discarded_address_info_lines: array} + */ + private static function companyAddressPartsFromLines(array $lines): array + { + if ($lines === []) { + return [ + 'company' => null, + 'street' => null, + 'address_line_2' => null, + 'address_line_3' => null, + 'discarded_address_info_lines' => [], + ]; + } + + $companyParts = []; + $addressInfoLines = []; + $streetCandidateLines = []; + $isCollectingAddressInfo = false; + $companyIsComplete = false; + + foreach (array_values($lines) as $idx => $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + if ($idx === 0) { + $companyParts[] = $line; + $companyIsComplete = self::lineEndsWithLegalFormSuffix($line); + + continue; + } + + if (self::isStreetLine($line)) { + $streetCandidateLines[] = $line; + + break; + } + + if ($isCollectingAddressInfo || self::lineStartsWithAddressInfoMarker($line) || $companyIsComplete) { + $isCollectingAddressInfo = true; + $addressInfoLines[] = $line; + + continue; + } + + $companyParts[] = $line; + if (self::lineEndsWithLegalFormSuffix($line)) { + $companyIsComplete = true; + } + } + + return [ + 'company' => $companyParts !== [] ? implode(' ', $companyParts) : null, + 'street' => self::streetFromLines($streetCandidateLines), + 'address_line_2' => $addressInfoLines[0] ?? null, + 'address_line_3' => $addressInfoLines[1] ?? null, + 'discarded_address_info_lines' => array_slice($addressInfoLines, 2), + ]; + } + + private static function lineStartsWithAddressInfoMarker(string $line): bool + { + foreach (self::ADDRESS_INFO_MARKERS as $marker) { + if (preg_match('/^'.preg_quote($marker, '/').'(?:\s|$|[:.,-])/ui', $line) === 1) { + return true; + } + } + + return false; + } + + private static function lineEndsWithLegalFormSuffix(string $line): bool + { + foreach (self::LEGAL_FORM_SUFFIXES as $suffix) { + if (preg_match('/(?:^|\s)'.preg_quote($suffix, '/').'\.?$/ui', trim($line)) === 1) { + return true; + } + } + + return false; + } + + /** + * @param array $lines + */ + private static function findStreetLineIndex(array $lines): ?int + { + foreach ($lines as $idx => $line) { + if (self::isStreetLine($line)) { + return $idx; + } + } + + return null; + } + + private static function isStreetLine(string $line): bool + { + $candidate = trim($line); + if ($candidate === '' || self::extractPostalAndCity($candidate) !== null) { + return false; + } + if (preg_match('/^Postfach\b/ui', $candidate) === 1) { + return false; + } + if (preg_match('/\b[\p{L}.-]*(?:straße|strasse|str\.|weg|allee|platz|gasse|ring|damm|ufer|chaussee|markt|pfad|steig)\.?\s*\d*\p{L}*(?:\s*[-\/]\s*\d*\p{L}*)?$/ui', $candidate) === 1) { + return true; + } + + return preg_match('/\p{L}/u', $candidate) === 1 + && preg_match('/\b\d+\s*[A-Z]?(?:[-\/]\s*\d+\s*[A-Z]?)?$/ui', $candidate) === 1; + } + + /** + * Postal lines only: last line may be "12345 City", "D-12345 City", or "DE-12345 City"; preceding lines become street. Company is always null. + * + * @param array $lines + */ + public static function fromPostalLines(array $lines): ?Address + { + $addrLines = array_values(array_filter(array_map('trim', $lines), fn (string $l): bool => $l !== '')); + if ($addrLines === []) { + return null; + } + $zip = null; + $city = null; + + $postal = self::pullFirstPostalAndCity($addrLines); + if ($postal !== null) { + $zip = $postal['zip']; + $city = $postal['city']; + } + + if ($addrLines === []) { + return new Address( + zip: $zip, + city: $city, + ); + } + + $streetAndAdditionalLines = self::postalAddressPartsFromLines($addrLines); + + return new Address( + street: $streetAndAdditionalLines['street'], + zip: $zip, + city: $city, + addressLine2: $streetAndAdditionalLines['address_line_2'], + addressLine3: $streetAndAdditionalLines['address_line_3'], + ); + } + + /** + * Same as newline text split + {@see fromPostalLines()}, after removing leading lines equal to the party name (e.g. bill customer or supplier). + * Sets {@see $company} to the trimmed party name when non-empty. + */ + public static function fromMultilineStringForParty(?string $text, string $partyName): ?Address + { + $partyTrim = trim($partyName); + if ($text === null || trim($text) === '') { + return $partyTrim !== '' ? new Address($partyTrim) : null; + } + $raw = preg_split('/\r\n|\n|\r/u', $text, -1, PREG_SPLIT_NO_EMPTY); + if (! is_array($raw)) { + return $partyTrim !== '' ? new Address($partyTrim) : null; + } + $lines = array_values(array_filter(array_map('trim', $raw), fn (string $l): bool => $l !== '')); + while ($lines !== [] && $partyTrim !== '' && self::lineMatchesPartyName($lines[0], $partyTrim)) { + array_shift($lines); + } + $postal = self::fromPostalLines($lines); + if ($postal === null) { + return $partyTrim !== '' ? new Address($partyTrim) : null; + } + + return new Address( + $partyTrim !== '' ? $partyTrim : null, + $postal->street, + $postal->zip, + $postal->city, + $postal->country, + $postal->addressLine2, + $postal->addressLine3, + ); + } + + /** + * @param array|string|null $value + */ + public static function fromMixedWithParty(array|string|null $value, string $partyName): ?Address + { + if ($value === null) { + return null; + } + $partyTrim = trim($partyName); + if (is_array($value)) { + $addr = self::fromMixed($value); + if ($addr === null) { + return $partyTrim !== '' ? new Address($partyTrim) : null; + } + + return new Address( + $partyTrim !== '' ? $partyTrim : $addr->company, + $addr->street, + $addr->zip, + $addr->city, + $addr->country, + $addr->addressLine2, + $addr->addressLine3, + ); + } + + return self::fromMultilineStringForParty($value, $partyName); + } + + public static function lineMatchesPartyName(string $line, string $partyName): bool + { + $line = trim(preg_replace('/\s+/u', ' ', $line)); + $party = trim(preg_replace('/\s+/u', ' ', $partyName)); + if ($party === '' || $line === '') { + return false; + } + + return mb_strtolower($line) === mb_strtolower($party); + } + + /** + * First line is always treated as the company / recipient name; postal and street lines are detected by pattern. + * + * @param array $lines + */ + public static function fromLines(array $lines): ?Address + { + $addrLines = array_values(array_filter(array_map('trim', $lines), fn (string $l): bool => $l !== '')); + if ($addrLines === []) { + return null; + } + $zip = null; + $city = null; + + $postal = self::pullFirstPostalAndCity($addrLines); + if ($postal !== null) { + $zip = $postal['zip']; + $city = $postal['city']; + } + + if ($addrLines === []) { + return new Address( + zip: $zip, + city: $city, + ); + } + + $companyAddressParts = self::companyAddressPartsFromLines($addrLines); + + return new Address( + company: $companyAddressParts['company'], + street: $companyAddressParts['street'], + zip: $zip, + city: $city, + addressLine2: $companyAddressParts['address_line_2'], + addressLine3: $companyAddressParts['address_line_3'], + ); + } + + /** + * @param array $lines + * @return array + */ + public static function discardedAddressInfoLines(array $lines): array + { + $addrLines = array_values(array_filter(array_map('trim', $lines), fn (string $l): bool => $l !== '')); + if ($addrLines === []) { + return []; + } + + self::pullFirstPostalAndCity($addrLines); + + return self::companyAddressPartsFromLines($addrLines)['discarded_address_info_lines']; + } + + public static function fromMultilineString(?string $text): ?Address + { + if ($text === null || trim($text) === '') { + return null; + } + $lines = preg_split('/\r\n|\n|\r/u', $text, -1, PREG_SPLIT_NO_EMPTY); + if (! is_array($lines)) { + return null; + } + $lines = array_values(array_map('trim', $lines)); + + return self::fromLines($lines); + } + + /** + * Accepts a newline-separated address string or an array (e.g. from config / API). + * + * @param array|string|null $value + */ + public static function fromMixed(array|string|null $value): ?Address + { + if ($value === null) { + return null; + } + if (is_array($value)) { + $company = isset($value['company']) ? trim((string) $value['company']) : null; + $street = $value['street'] ?? null; + if ($street !== null) { + $street = trim((string) $street); + if ($street === '') { + $street = null; + } + } + $zip = isset($value['zip']) ? trim((string) $value['zip']) : null; + $city = isset($value['city']) ? trim((string) $value['city']) : null; + $country = isset($value['country']) ? trim((string) $value['country']) : null; + $addressLine2 = isset($value['address_line_2']) ? trim((string) $value['address_line_2']) : null; + $addressLine3 = isset($value['address_line_3']) ? trim((string) $value['address_line_3']) : null; + $company = $company === '' ? null : $company; + $zip = $zip === '' ? null : $zip; + $city = $city === '' ? null : $city; + $country = $country === '' ? null : $country; + $addressLine2 = $addressLine2 === '' ? null : $addressLine2; + $addressLine3 = $addressLine3 === '' ? null : $addressLine3; + + $addr = new Address( + company: $company, + street: $street, + zip: $zip, + city: $city, + country: $country, + addressLine2: $addressLine2, + addressLine3: $addressLine3, + ); + if ($addr->company === null && $addr->street === null && $addr->zip === null && $addr->city === null && $addr->country === null && $addr->addressLine2 === null && $addr->addressLine3 === null) { + return null; + } + + return $addr; + } + + return self::fromMultilineString($value); + } +} diff --git a/packages/e-billing/src/Data/BankAccount.php b/packages/e-billing/src/Data/BankAccount.php new file mode 100644 index 0000000000..3c4ca1149d --- /dev/null +++ b/packages/e-billing/src/Data/BankAccount.php @@ -0,0 +1,43 @@ + $this->bankName, + 'iban' => $this->iban, + 'bic' => $this->bic, + 'account_holder' => $this->accountHolder, + ], fn (mixed $value): bool => $value !== null); + } +} diff --git a/packages/e-billing/src/Data/Invoice.php b/packages/e-billing/src/Data/Invoice.php new file mode 100644 index 0000000000..506e1d602a --- /dev/null +++ b/packages/e-billing/src/Data/Invoice.php @@ -0,0 +1,370 @@ + */ + public array $supplierBankAccounts = [], + + // Agent & payment terms + public ?string $agent = null, + public ?string $paymentTerms = null, + public ?string $pricingBasis = null, + public ?string $paymentMeansCode = null, + + // Amounts + public float $netTotal = 0, + public float $vatRate = 19.00, + public float $vatAmount = 0, + public float $grossTotal = 0, + public ?float $discountPercent = null, + public ?float $discountAmount = null, + public ?float $shippingCost = null, + public ?float $minimumQuantitySurcharge = null, + public ?float $freightFlatRate = null, + public ?float $packagingCost = null, + public ?string $shippingMethod = null, + + /** @var InvoiceLine[] */ + public array $lines = [], + + /** @var array */ + public array $notes = [], + + // Currency + public string $currency = 'EUR', + ) {} + + /** @var list */ + public array $bankAccounts { + get { + $accounts = []; + foreach ($this->supplierBankAccounts as $row) { + if (is_array($row)) { + $accounts[] = BankAccount::fromArray($row); + } + } + + return $accounts; + } + } + + /** @var list */ + public array $allowanceCharges { + get { + return BillDataAllowanceChargeMapper::fromHeaderScalars( + $this->shippingCost, + $this->packagingCost, + $this->minimumQuantitySurcharge, + $this->freightFlatRate, + $this->discountAmount, + $this->discountPercent, + ); + } + } + + /** + * Factory: creates an Invoice and pulls supplier data from config. + */ + public static function fromConfig(array $data): self + { + $supplier = config('e-billing.supplier'); + $customerName = is_string($data['customer_name'] ?? null) ? $data['customer_name'] : ''; + $customerAddress = Address::fromMixedWithParty($data['customer_address'] ?? null, $customerName); + $billingCountry = self::normalizeCountry($data['billing_country'] ?? $data['country'] ?? null); + + // Legacy backfill: root 'billing_country'/'country' keys flow into customerAddress->country when missing. + // customerAddress->country is the canonical DTO source of truth for buyer country. + if ($customerAddress !== null && $customerAddress->country === null && $billingCountry !== null) { + $customerAddress->country = $billingCountry; + } + + $supplierAddress = Address::fromMixedWithParty($supplier['address'] ?? null, $supplier['name'] ?? ''); + if ($supplierAddress !== null + && ($supplierAddress->country === null || trim((string) $supplierAddress->country) === '') + && isset($supplier['country_code']) && is_string($supplier['country_code']) && trim($supplier['country_code']) !== '') { + $supplierAddress->country = strtoupper(trim($supplier['country_code'])); + } + + $invoice = new self( + invoiceNumber: $data['invoice_number'], + invoiceDate: $data['invoice_date'], + + // Customer + customerNumber: $data['customer_number'] ?? '', + customerName: $customerName, + customerAddress: $customerAddress, + customerVatId: $data['customer_vat_id'] ?? null, + customerReference: $data['customer_reference'] ?? null, + + // Supplier snapshot from config + supplierName: $supplier['name'] ?? '', + supplierVatId: $supplier['vat_id'] ?? null, + supplierTaxNumber: $supplier['tax_number'] ?? null, + supplierAddress: $supplierAddress, + supplierPhone: $supplier['phone'] ?? null, + supplierEmail: $supplier['email'] ?? null, + + // Bank accounts + supplierBankAccounts: $supplier['bank_accounts'] ?? [], + + // Amounts + netTotal: $data['net_total'] ?? 0, + vatRate: $data['vat_rate'] ?? 19.0, + vatAmount: $data['vat_amount'] ?? 0, + grossTotal: $data['gross_total'] ?? 0, + ); + + $invoice->applyDefaultCustomerCountry(); + + return $invoice; + } + + /** + * Validate that essential fields are present. + */ + public function isValid(): bool + { + return $this->invoiceNumber !== '' + && $this->invoiceDate !== '' + && $this->grossTotal > 0; + } + + /** + * Reconstruct a DTO from {@see toArray()} output (e.g. persisted `bill_data` on an attachment). + * + * @param array $data + */ + public static function fromArray(array $data): self + { + $customerName = is_string($data['customer_name'] ?? null) ? $data['customer_name'] : ''; + $supplierName = is_string($data['supplier_name'] ?? null) ? $data['supplier_name'] : ''; + $customerAddress = Address::fromMixedWithParty($data['customer_address'] ?? null, $customerName); + $billingCountry = self::normalizeCountry($data['billing_country'] ?? $data['country'] ?? null); + + // Legacy backfill: root 'billing_country'/'country' keys flow into customerAddress->country when missing. + // customerAddress->country is the canonical DTO source of truth for buyer country. + if ($customerAddress !== null && $customerAddress->country === null && $billingCountry !== null) { + $customerAddress->country = $billingCountry; + } + + $supplierAddress = Address::fromMixedWithParty($data['supplier_address'] ?? null, $supplierName); + $supplierCountryFromConfig = self::normalizeCountry(config('e-billing.supplier.country_code')); + if ($supplierAddress !== null && $supplierAddress->country === null && $supplierCountryFromConfig !== null) { + $supplierAddress->country = $supplierCountryFromConfig; + } + + $lines = []; + if (isset($data['lines']) && is_array($data['lines'])) { + foreach ($data['lines'] as $row) { + if (is_array($row)) { + $lines[] = InvoiceLine::fromArray($row); + } + } + } + + $notes = []; + if (isset($data['notes']) && is_array($data['notes'])) { + foreach ($data['notes'] as $n) { + if (is_string($n)) { + $notes[] = $n; + } + } + } + + $supplierBanks = []; + if (isset($data['supplier_bank_accounts']) && is_array($data['supplier_bank_accounts'])) { + foreach ($data['supplier_bank_accounts'] as $row) { + if (is_array($row)) { + $supplierBanks[] = $row; + } + } + } + + $invoice = new self( + invoiceNumber: is_string($data['invoice_number'] ?? null) ? $data['invoice_number'] : '', + invoiceDate: is_string($data['invoice_date'] ?? null) ? $data['invoice_date'] : '', + documentType: is_string($data['document_type'] ?? null) && $data['document_type'] !== '' ? $data['document_type'] : 'Rechnung', + dueDate: isset($data['due_date']) && is_string($data['due_date']) ? $data['due_date'] : null, + customerNumber: is_string($data['customer_number'] ?? null) ? $data['customer_number'] : '', + customerName: $customerName, + customerAddress: $customerAddress, + customerVatId: isset($data['customer_vat_id']) && is_string($data['customer_vat_id']) ? $data['customer_vat_id'] : null, + customerReference: isset($data['customer_reference']) && is_string($data['customer_reference']) ? $data['customer_reference'] : null, + orderNumber: isset($data['order_number']) && is_string($data['order_number']) ? $data['order_number'] : null, + orderDate: isset($data['order_date']) && is_string($data['order_date']) ? $data['order_date'] : null, + deliveryAddress: Address::fromMixed($data['delivery_address'] ?? null), + supplierName: $supplierName, + supplierVatId: isset($data['supplier_vat_id']) && is_string($data['supplier_vat_id']) ? $data['supplier_vat_id'] : null, + supplierTaxNumber: isset($data['supplier_tax_number']) && is_string($data['supplier_tax_number']) ? $data['supplier_tax_number'] : null, + supplierAddress: $supplierAddress, + supplierNumber: isset($data['supplier_number']) && is_string($data['supplier_number']) ? $data['supplier_number'] : null, + supplierPhone: isset($data['supplier_phone']) && is_string($data['supplier_phone']) ? $data['supplier_phone'] : null, + supplierEmail: isset($data['supplier_email']) && is_string($data['supplier_email']) ? $data['supplier_email'] : null, + supplierBankAccounts: $supplierBanks, + agent: isset($data['agent']) && is_string($data['agent']) ? $data['agent'] : null, + paymentTerms: isset($data['payment_terms']) && is_string($data['payment_terms']) ? $data['payment_terms'] : null, + pricingBasis: isset($data['pricing_basis']) && is_string($data['pricing_basis']) ? $data['pricing_basis'] : null, + netTotal: (float) ($data['net_total'] ?? 0), + vatRate: (float) ($data['vat_rate'] ?? 19.0), + vatAmount: (float) ($data['vat_amount'] ?? 0), + grossTotal: (float) ($data['gross_total'] ?? 0), + discountPercent: isset($data['discount_percent']) && is_numeric($data['discount_percent']) ? (float) $data['discount_percent'] : null, + discountAmount: isset($data['discount_amount']) && is_numeric($data['discount_amount']) ? (float) $data['discount_amount'] : null, + shippingCost: isset($data['shipping_cost']) && is_numeric($data['shipping_cost']) ? (float) $data['shipping_cost'] : null, + minimumQuantitySurcharge: isset($data['minimum_quantity_surcharge']) && is_numeric($data['minimum_quantity_surcharge']) ? (float) $data['minimum_quantity_surcharge'] : null, + freightFlatRate: isset($data['freight_flat_rate']) && is_numeric($data['freight_flat_rate']) ? (float) $data['freight_flat_rate'] : null, + packagingCost: isset($data['packaging_cost']) && is_numeric($data['packaging_cost']) ? (float) $data['packaging_cost'] : null, + shippingMethod: isset($data['shipping_method']) && is_string($data['shipping_method']) ? $data['shipping_method'] : null, + lines: $lines, + notes: $notes, + currency: is_string($data['currency'] ?? null) && $data['currency'] !== '' ? $data['currency'] : 'EUR', + ); + + $invoice->applyDefaultCustomerCountry(); + + return $invoice; + } + + /** + * TRANSITIONAL: fill buyer/delivery country from config when still empty after parse or legacy backfill. + * Replaced by master-data Company/Address lookup; remove with {@see config('e-billing.default_customer_country')}. + */ + public function applySupplierSnapshotFromConfig(): void + { + $supplier = config('e-billing.supplier'); + if (! is_array($supplier)) { + $this->applyDefaultCustomerCountry(); + + return; + } + + $this->supplierName = is_string($supplier['name'] ?? null) ? $supplier['name'] : ''; + $this->supplierVatId = is_string($supplier['vat_id'] ?? null) ? $supplier['vat_id'] : null; + $this->supplierTaxNumber = is_string($supplier['tax_number'] ?? null) ? $supplier['tax_number'] : null; + $this->supplierAddress = Address::fromMixedWithParty($supplier['address'] ?? null, $this->supplierName); + if ($this->supplierAddress !== null + && ($this->supplierAddress->country === null || trim((string) $this->supplierAddress->country) === '') + && isset($supplier['country_code']) && is_string($supplier['country_code']) && trim($supplier['country_code']) !== '') { + $this->supplierAddress->country = strtoupper(trim($supplier['country_code'])); + } + $this->supplierPhone = is_string($supplier['phone'] ?? null) ? $supplier['phone'] : null; + $this->supplierEmail = is_string($supplier['email'] ?? null) ? $supplier['email'] : null; + $this->supplierBankAccounts = is_array($supplier['bank_accounts'] ?? null) ? $supplier['bank_accounts'] : []; + $this->applyDefaultCustomerCountry(); + } + + public function applyDefaultCustomerCountry(): void + { + $default = self::normalizeCountry(config('e-billing.default_customer_country')); + if ($default === null) { + return; + } + + if ($this->customerAddress !== null && self::isCountryEmpty($this->customerAddress->country)) { + $this->customerAddress->country = $default; + } + + if ($this->deliveryAddress !== null && self::isCountryEmpty($this->deliveryAddress->country)) { + $this->deliveryAddress->country = $default; + } + } + + public function toArray(): array + { + return [ + 'invoice_number' => $this->invoiceNumber, + 'invoice_date' => $this->invoiceDate, + 'document_type' => $this->documentType, + 'due_date' => $this->dueDate, + + 'customer_number' => $this->customerNumber, + 'customer_name' => $this->customerName, + 'customer_address' => $this->customerAddress?->toArray(), + 'billing_country' => $this->customerAddress?->country, + 'customer_vat_id' => $this->customerVatId, + 'customer_reference' => $this->customerReference, + + 'order_number' => $this->orderNumber, + 'order_date' => $this->orderDate, + 'delivery_address' => $this->deliveryAddress?->toArray(), + + 'supplier_name' => $this->supplierName, + 'supplier_vat_id' => $this->supplierVatId, + 'supplier_tax_number' => $this->supplierTaxNumber, + 'supplier_address' => $this->supplierAddress?->toArray(), + 'supplier_number' => $this->supplierNumber, + 'supplier_phone' => $this->supplierPhone, + 'supplier_email' => $this->supplierEmail, + 'supplier_bank_accounts' => $this->supplierBankAccounts, + + 'agent' => $this->agent, + 'payment_terms' => $this->paymentTerms, + 'pricing_basis' => $this->pricingBasis, + + 'net_total' => $this->netTotal, + 'vat_rate' => $this->vatRate, + 'vat_amount' => $this->vatAmount, + 'gross_total' => $this->grossTotal, + 'discount_percent' => $this->discountPercent, + 'discount_amount' => $this->discountAmount, + 'shipping_cost' => $this->shippingCost, + 'minimum_quantity_surcharge' => $this->minimumQuantitySurcharge, + 'freight_flat_rate' => $this->freightFlatRate, + 'packaging_cost' => $this->packagingCost, + 'shipping_method' => $this->shippingMethod, + 'currency' => $this->currency, + + 'lines' => array_map(fn (InvoiceLine $line) => $line->toArray(), $this->lines), + 'notes' => $this->notes, + ]; + } + + private static function normalizeCountry(mixed $country): ?string + { + if (! is_string($country) || trim($country) === '') { + return null; + } + + return strtoupper(trim($country)); + } + + private static function isCountryEmpty(?string $country): bool + { + return $country === null || trim($country) === ''; + } +} diff --git a/packages/e-billing/src/Data/InvoiceLine.php b/packages/e-billing/src/Data/InvoiceLine.php new file mode 100644 index 0000000000..50c63e1918 --- /dev/null +++ b/packages/e-billing/src/Data/InvoiceLine.php @@ -0,0 +1,121 @@ +weightKgNet === null && $this->weightKgTotal !== null && $this->quantity > 0) { + $this->weightKgNet = round($this->weightKgTotal / $this->quantity, 3); + } + } + + /** @var list */ + public array $allowanceCharges { + get { + return BillDataAllowanceChargeMapper::fromLineScalars( + $this->surchargeAmount, + $this->surchargeDescription, + $this->materialTestCertificatePrice, + $this->materialTestCertificate, + ); + } + } + + public function totalWithSurcharge(): float + { + return $this->lineTotal + + ($this->surchargeAmount ?? 0) + + ($this->materialTestCertificatePrice ?? 0); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $deliveryAddr = isset($data['delivery_address']) && is_array($data['delivery_address']) + ? Address::fromMixed($data['delivery_address']) + : null; + + return new self( + position: (int) ($data['position'] ?? 0), + unit: is_string($data['unit'] ?? null) && $data['unit'] !== '' ? $data['unit'] : 'Stück', + quantity: (float) ($data['quantity'] ?? 0), + description: is_string($data['description'] ?? null) ? $data['description'] : '', + descriptionDetail: isset($data['description_detail']) && is_string($data['description_detail']) ? $data['description_detail'] : null, + articleNumber: isset($data['article_number']) && is_string($data['article_number']) ? $data['article_number'] : null, + material: isset($data['material']) && is_string($data['material']) ? $data['material'] : null, + materialTestCertificate: isset($data['material_test_certificate']) && is_string($data['material_test_certificate']) ? $data['material_test_certificate'] : null, + materialTestCertificatePrice: isset($data['material_test_certificate_price']) && is_numeric($data['material_test_certificate_price']) + ? (float) $data['material_test_certificate_price'] : null, + customsTariffNumber: isset($data['customs_tariff_number']) && is_string($data['customs_tariff_number']) ? $data['customs_tariff_number'] : null, + weightKgTotal: isset($data['weight_kg_total']) && is_numeric($data['weight_kg_total']) ? (float) $data['weight_kg_total'] : null, + weightKgNet: isset($data['weight_kg_net']) && is_numeric($data['weight_kg_net']) ? (float) $data['weight_kg_net'] : null, + unitPrice: (float) ($data['unit_price'] ?? 0), + lineTotal: (float) ($data['line_total'] ?? 0), + surchargeAmount: isset($data['surcharge_amount']) && is_numeric($data['surcharge_amount']) ? (float) $data['surcharge_amount'] : null, + surchargeDescription: isset($data['surcharge_description']) && is_string($data['surcharge_description']) ? $data['surcharge_description'] : null, + deliveryDate: isset($data['delivery_date']) && is_string($data['delivery_date']) ? $data['delivery_date'] : null, + deliveryNoteNumber: isset($data['delivery_note_number']) && is_string($data['delivery_note_number']) ? $data['delivery_note_number'] : null, + orderNumber: isset($data['order_number']) && is_string($data['order_number']) ? $data['order_number'] : null, + orderDate: isset($data['order_date']) && is_string($data['order_date']) ? $data['order_date'] : null, + deliveryAddress: $deliveryAddr, + ); + } + + public function toArray(): array + { + return [ + 'position' => $this->position, + 'unit' => $this->unit, + 'quantity' => $this->quantity, + 'description' => $this->description, + 'description_detail' => $this->descriptionDetail, + 'article_number' => $this->articleNumber, + 'material' => $this->material, + 'material_test_certificate' => $this->materialTestCertificate, + 'material_test_certificate_price' => $this->materialTestCertificatePrice, + 'customs_tariff_number' => $this->customsTariffNumber, + 'weight_kg_total' => $this->weightKgTotal, + 'weight_kg_net' => $this->weightKgNet, + 'unit_price' => $this->unitPrice, + 'line_total' => $this->lineTotal, + 'surcharge_amount' => $this->surchargeAmount, + 'surcharge_description' => $this->surchargeDescription, + 'delivery_date' => $this->deliveryDate, + 'delivery_note_number' => $this->deliveryNoteNumber, + 'order_number' => $this->orderNumber, + 'order_date' => $this->orderDate, + 'delivery_address' => $this->deliveryAddress?->toArray(), + ]; + } +} diff --git a/packages/e-billing/src/EBillingServiceProvider.php b/packages/e-billing/src/EBillingServiceProvider.php new file mode 100644 index 0000000000..7d5ee440fd --- /dev/null +++ b/packages/e-billing/src/EBillingServiceProvider.php @@ -0,0 +1,106 @@ +name('e-billing') + ->hasConfigFile() + ->hasViews() + ->hasTranslations() + ->hasRoutes('web') + ->hasCommands([ + BackfillValidationScoresCommand::class, + ]) + ->hasMigrations([ + 'create_ebilling_documents_table', + ]); + + $this->getMooxPackage() + ->title('Moox eBilling') + ->released(false) + ->stability('dev') + ->category('billing') + ->usedFor([ + 'extracting invoice data from PDFs and converting to e-invoices', + ]); + } + + public function packageRegistered(): void + { + parent::packageRegistered(); + + $this->app->singleton(InvoiceFieldValidator::class); + $this->app->singleton(ConfirmInvoiceAction::class); + } + + public function boot(): void + { + parent::boot(); + + // Bind InvoiceParserInterface in your host app ServiceProvider: + // $this->app->bind(\Moox\EBilling\Contracts\InvoiceParserInterface::class, YourParser::class); + + $this->registerInvoiceEbillingDocumentRelation(); + + $this->registerEbillingDocumentConfigAlias(); + + $this->registerZugferdFilesystemDisk(); + + Event::listen(InboxAttachmentProcessed::class, ProcessInboxAttachmentListener::class); + } + + private function registerInvoiceEbillingDocumentRelation(): void + { + Invoice::resolveRelationUsing('ebillingDocument', function (Invoice $invoice): HasOne { + return $invoice->hasOne(EbillingDocument::class, 'invoice_id'); + }); + } + + /** + * {@see EbillingDocument::getResourceName()} reads config under `ebilling-document`. + */ + private function registerEbillingDocumentConfigAlias(): void + { + $config = config('e-billing'); + + if (is_array($config)) { + config(['ebilling-document' => $config]); + } + } + + private function registerZugferdFilesystemDisk(): void + { + $configuredRoot = config('e-billing.zugferd.storage_root'); + $root = is_string($configuredRoot) && $configuredRoot !== '' + ? $configuredRoot + : storage_path('app/private/'.trim((string) config('mail-inbox.zugferd.path', 'zugferd'), '/')); + + config([ + 'filesystems.disks.zugferd' => [ + 'driver' => 'local', + 'root' => $root, + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + ]); + } +} diff --git a/packages/e-billing/src/Enums/EBillingAttachmentProcessingStatus.php b/packages/e-billing/src/Enums/EBillingAttachmentProcessingStatus.php new file mode 100644 index 0000000000..71ed300254 --- /dev/null +++ b/packages/e-billing/src/Enums/EBillingAttachmentProcessingStatus.php @@ -0,0 +1,84 @@ + __('e-billing::fields.gateway_status_xml_generating'), + self::XmlGenerationFailed => __('e-billing::fields.gateway_status_xml_generation_failed'), + self::XmlValidated => __('e-billing::fields.gateway_status_xml_validated'), + self::XmlValidationFailed => __('e-billing::fields.gateway_status_xml_validation_failed'), + self::KositError => __('e-billing::fields.gateway_status_kosit_error'), + self::ZugferdPdfGenerating => __('e-billing::fields.gateway_status_zugferd_pdf_generating'), + self::ZugferdPdfGenerated => __('e-billing::fields.gateway_status_zugferd_pdf_generated'), + self::ZugferdPdfFailed => __('e-billing::fields.gateway_status_zugferd_pdf_failed'), + self::IgnoredForeign => __('e-billing::fields.gateway_status_ignored_foreign'), + }; + } + + /** + * Filament badge color token. + */ + public function color(): string + { + return match ($this) { + self::XmlGenerating => 'info', + self::XmlGenerationFailed => 'danger', + self::XmlValidated => 'success', + self::XmlValidationFailed => 'danger', + self::KositError => 'warning', + self::ZugferdPdfGenerating => 'info', + self::ZugferdPdfGenerated => 'success', + self::ZugferdPdfFailed => 'danger', + self::IgnoredForeign => 'gray', + }; + } + + /** + * Terminal states: pipeline will not progress further for this attachment. + */ + public function isTerminal(): bool + { + return match ($this) { + self::XmlGenerating, + self::XmlValidated, + self::ZugferdPdfGenerating => false, + self::XmlGenerationFailed, + self::XmlValidationFailed, + self::KositError, + self::ZugferdPdfGenerated, + self::ZugferdPdfFailed, + self::IgnoredForeign => true, + }; + } + + /** + * Successful completion: ZUGFeRD PDF was produced for this attachment. + */ + public function isSuccessfulTerminal(): bool + { + return $this === self::ZugferdPdfGenerated; + } +} diff --git a/packages/e-billing/src/Enums/InvoiceOriginRule.php b/packages/e-billing/src/Enums/InvoiceOriginRule.php new file mode 100644 index 0000000000..924699f46c --- /dev/null +++ b/packages/e-billing/src/Enums/InvoiceOriginRule.php @@ -0,0 +1,13 @@ + + */ + public function allowedTransitions(): array + { + return match ($this) { + self::ParserCreated => [self::DbValidated, self::Validated, self::HumanConfirmed], + self::DbValidated => [self::HumanConfirmed, self::Validated], + self::HumanConfirmed => [self::Validated], + self::Validated => [], + }; + } + + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions(), true); + } + + public function label(): string + { + return match ($this) { + self::ParserCreated => __('e-billing::fields.status_parser_created'), + self::DbValidated => __('e-billing::fields.status_db_validated'), + self::HumanConfirmed => __('e-billing::fields.status_human_confirmed'), + self::Validated => __('e-billing::fields.status_validated'), + }; + } +} diff --git a/packages/e-billing/src/Events/InvoiceCreated.php b/packages/e-billing/src/Events/InvoiceCreated.php new file mode 100644 index 0000000000..eef797afb2 --- /dev/null +++ b/packages/e-billing/src/Events/InvoiceCreated.php @@ -0,0 +1,19 @@ + $errors + */ + public function __construct( + public int $inboxAttachmentId, + public array $errors, + ) {} +} diff --git a/packages/e-billing/src/Events/XmlValidationPassed.php b/packages/e-billing/src/Events/XmlValidationPassed.php new file mode 100644 index 0000000000..96e0780842 --- /dev/null +++ b/packages/e-billing/src/Events/XmlValidationPassed.php @@ -0,0 +1,12 @@ +guardAttachment($attachment); + + $disk = $attachment->storage_disk ?? (string) config('mail-inbox.attachments.disk', 'local'); + $path = $attachment->storage_path; + + $this->guardPath($path); + abort_unless(Storage::disk($disk)->exists($path), 404); + + return $this->streamPdf( + Storage::disk($disk)->get($path), + $this->downloadFilename($attachment, 'storage_path', 'pdf'), + ); + } + + /** + * Download the ZUGFeRD PDF. + */ + public function downloadZugferd(InboxAttachment $attachment): StreamedResponse + { + $document = $this->guardAttachmentWithDocument($attachment); + + $disk = $document->zugferd_storage_disk + ?? (string) config('e-billing.zugferd.storage_disk', 'zugferd'); + $path = $document->zugferd_storage_path; + + abort_unless(is_string($path) && $path !== '', 404); + $this->guardPath($path); + abort_unless(Storage::disk($disk)->exists($path), 404); + + return $this->streamedDownloadFromDisk( + $disk, + $path, + $this->artifactDownloadFilename($attachment, $document->zugferd_storage_path, 'pdf'), + ); + } + + /** + * Download the raw XML. + */ + public function downloadXml(InboxAttachment $attachment): StreamedResponse + { + $document = $this->guardAttachmentWithDocument($attachment); + + $disk = $document->zugferd_storage_disk + ?? (string) config('e-billing.zugferd.storage_disk', 'zugferd'); + $path = $document->xml_storage_path; + + abort_unless(is_string($path) && $path !== '', 404); + $this->guardPath($path); + abort_unless(Storage::disk($disk)->exists($path), 404); + + return $this->streamedDownloadFromDisk( + $disk, + $path, + $this->artifactDownloadFilename($attachment, $document->xml_storage_path, 'xml'), + ); + } + + private function streamedDownloadFromDisk(string $disk, string $path, string $filename): StreamedResponse + { + $filesystem = Storage::disk($disk); + + if (! $filesystem instanceof FilesystemAdapter) { + throw new \RuntimeException('Disk ['.$disk.'] does not use a Laravel filesystem adapter.'); + } + + return $filesystem->download($path, $filename); + } + + private function guardAttachmentWithDocument(InboxAttachment $attachment): EbillingDocument + { + $document = EbillingDocument::forSourceAttachment($attachment); + + abort_if( + $document === null + || $document->invoice_id === null, + 404, + ); + + return $document; + } + + private function guardAttachment(InboxAttachment $attachment): void + { + $this->guardAttachmentWithDocument($attachment); + } + + private function guardPath(?string $path): void + { + abort_if($path === null, 404); + abort_if(str_contains($path, '..'), 400); + } + + private function downloadFilename(InboxAttachment $attachment, string $storagePathColumn, string $extension): string + { + $path = $attachment->{$storagePathColumn}; + if ($path === null || ! is_string($path) || $path === '') { + return pathinfo($attachment->filename ?? 'document', PATHINFO_FILENAME).'.'.$extension; + } + + return basename($path); + } + + private function artifactDownloadFilename(InboxAttachment $attachment, ?string $storagePath, string $extension): string + { + if ($storagePath === null || $storagePath === '') { + return pathinfo($attachment->filename ?? 'document', PATHINFO_FILENAME).'.'.$extension; + } + + return basename($storagePath); + } + + private function streamPdf(string $content, string $filename): Response + { + return response($content, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$filename.'"', + 'X-Content-Type-Options' => 'nosniff', + 'Cache-Control' => 'private, max-age=0, must-revalidate', + ]); + } +} diff --git a/packages/e-billing/src/Jobs/FilterForeignInvoiceJob.php b/packages/e-billing/src/Jobs/FilterForeignInvoiceJob.php new file mode 100644 index 0000000000..add060e53b --- /dev/null +++ b/packages/e-billing/src/Jobs/FilterForeignInvoiceJob.php @@ -0,0 +1,409 @@ + 'AT', + 'BE' => 'BE', + 'BG' => 'BG', + 'CY' => 'CY', + 'CZ' => 'CZ', + 'DE' => 'DE', + 'DK' => 'DK', + 'EE' => 'EE', + 'EL' => 'GR', + 'ES' => 'ES', + 'FI' => 'FI', + 'FR' => 'FR', + 'HR' => 'HR', + 'HU' => 'HU', + 'IE' => 'IE', + 'IT' => 'IT', + 'LT' => 'LT', + 'LU' => 'LU', + 'LV' => 'LV', + 'MT' => 'MT', + 'NL' => 'NL', + 'PL' => 'PL', + 'PT' => 'PT', + 'RO' => 'RO', + 'SE' => 'SE', + 'SI' => 'SI', + 'SK' => 'SK', + ]; + + public int $tries = 3; + + public int $maxExceptions = 2; + + /** + * @var list + */ + public array $backoff = [60, 300]; + + public function __construct( + public int $inboxAttachmentId, + ) {} + + public function handle( + GraphMailService $graph, + ): void { + $this->setProgress(0); + + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + Log::warning('[EBilling] FilterForeignInvoiceJob: attachment not found', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + ]); + $this->setProgress(100); + + return; + } + + if (! $attachment->isPdf()) { + $this->setProgress(100); + + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + if ($document?->gateway_status === EBillingAttachmentProcessingStatus::IgnoredForeign) { + $this->setProgress(100); + + return; + } + + if ($attachment->processing_status !== InboxAttachmentProcessingStatus::Processing->value) { + $this->setProgress(100); + + return; + } + + $message = $attachment->message; + if ($message === null) { + Log::error('[EBilling] FilterForeignInvoiceJob: attachment has no message', [ + 'inbox_attachment_id' => $attachment->id, + ]); + $this->setProgress(100); + + return; + } + + $this->setProgress(10); + + $billData = $document?->bill_data; + + if (empty($billData) || ! is_array($billData)) { + Log::warning('FilterForeignInvoiceJob: bill_data missing on ebilling document', [ + 'attachment_id' => $attachment->id, + ]); + $this->dispatchGenerateXml($attachment); + $this->setProgress(100); + + return; + } + + if ($this->isEmptyParsedInvoiceArray($billData)) { + Log::warning('[EBilling] FilterForeignInvoiceJob: empty parsed invoice; continuing pipeline', [ + 'inbox_attachment_id' => $attachment->id, + ]); + $this->dispatchGenerateXml($attachment); + $this->setProgress(100); + + return; + } + + $this->setProgress(40); + + $classification = $this->classifyInvoiceOrigin($billData); + if (! $classification['is_foreign']) { + $this->dispatchGenerateXml($attachment); + $this->setProgress(100); + + return; + } + + $this->setProgress(60); + + $country = $classification['country']; + $matchedRule = $classification['matched_rule']->value; + + $externalId = $message->external_id; + if ($externalId === null || $externalId === '') { + throw new \RuntimeException('FilterForeignInvoiceJob: inbox message has no external_id; cannot move in Graph.'); + } + + $folderName = (string) config('e-billing.foreign_invoice.ignored_folder_name', 'Ignored'); + + $graph->moveMessageToFolderByName($externalId, $folderName, true); + + DB::transaction(function () use ($attachment, $document, $country, $matchedRule): void { + $ignoredReason = [ + 'country' => $country, + 'matched_rule' => $matchedRule, + 'classified_at' => now()->utc()->toIso8601String(), + ]; + + if ($document !== null) { + $document->ignored_reason = $ignoredReason; + $document->gateway_status = EBillingAttachmentProcessingStatus::IgnoredForeign; + $document->save(); + } + + $attachment->error_message = null; + $attachment->markAsSkipped(); + }); + + Log::info("Foreign invoice ignored: attachment=#{$attachment->id} country=".($country ?? 'null').' matched_rule='.$matchedRule); + + $this->maybeMarkInboxMessageProcessedIfAllPdfAttachmentsTerminal($message->fresh()); + + $this->setProgress(100); + } + + /** + * @param array $parsed + */ + private function isEmptyParsedInvoiceArray(array $parsed): bool + { + $addr = $parsed['customer_address'] ?? null; + + return ($parsed['invoice_number'] ?? '') === '' + && ($parsed['customer_name'] ?? '') === '' + && ($addr === null || $addr === [] || $addr === ''); + } + + /** + * @param array $billData + * @return array{is_foreign: bool, country: ?string, matched_rule: InvoiceOriginRule} + */ + private function classifyInvoiceOrigin(array $billData): array + { + $country = $this->resolveBillingCountry($billData); + $grossTotal = $this->toNullableFloat($billData['gross_total'] ?? null); + $taxAmount = $this->toNullableFloat($billData['vat_amount'] ?? null); + + if ($country !== null && $country !== 'DE') { + return ['is_foreign' => true, 'country' => $country, 'matched_rule' => InvoiceOriginRule::CountryDetected]; + } + + if ($country === 'DE') { + return ['is_foreign' => false, 'country' => $country, 'matched_rule' => InvoiceOriginRule::CountryDetectedDe]; + } + + if ($this->isNetOnly($grossTotal, $taxAmount)) { + return ['is_foreign' => true, 'country' => null, 'matched_rule' => InvoiceOriginRule::NetOnly]; + } + + return ['is_foreign' => false, 'country' => null, 'matched_rule' => InvoiceOriginRule::DefaultDe]; + } + + /** + * @param array $billData + */ + private function resolveBillingCountry(array $billData): ?string + { + $country = $this->normalizeCountry($billData['billing_country'] ?? null); + if ($country !== null) { + return $country; + } + + $customerAddress = $billData['customer_address'] ?? null; + if (! is_array($customerAddress)) { + return $this->countryFromVatId($billData['customer_vat_id'] ?? null); + } + + $country = $this->normalizeCountry($customerAddress['country'] ?? null); + if ($country !== null) { + return $country; + } + + return $this->countryFromVatId($billData['customer_vat_id'] ?? null); + } + + private function isNetOnly(?float $grossTotal, ?float $taxAmount): bool + { + $grossEmpty = $grossTotal === null || $grossTotal === 0.0; + $taxEmpty = $taxAmount === null || $taxAmount === 0.0; + + return $grossEmpty && $taxEmpty; + } + + private function normalizeCountry(mixed $country): ?string + { + if (! is_string($country) || trim($country) === '') { + return null; + } + + return strtoupper(trim($country)); + } + + private function countryFromVatId(mixed $vatId): ?string + { + if (! is_string($vatId)) { + return null; + } + + $normalized = strtoupper((string) preg_replace('/[^A-Z0-9]/i', '', $vatId)); + if (strlen($normalized) < 4) { + return null; + } + + $prefix = substr($normalized, 0, 2); + + return self::VAT_COUNTRY_PREFIXES[$prefix] ?? null; + } + + private function toNullableFloat(mixed $value): ?float + { + if (! is_numeric($value)) { + return null; + } + + return (float) $value; + } + + private function dispatchGenerateXml(InboxAttachment $attachment): void + { + $document = EbillingDocument::forSourceAttachment($attachment); + $billData = $document?->bill_data; + if (is_array($billData) && $billData !== [] && ! $this->hasExtractedInvoiceNumber($billData)) { + Log::warning('[EBilling] FilterForeignInvoiceJob: invoice_number (BT-1) missing; skipping XML generation', [ + 'inbox_attachment_id' => $attachment->id, + 'invoice_number' => $billData['invoice_number'] ?? null, + ]); + + return; + } + + if ($document === null) { + Log::warning('[EBilling] FilterForeignInvoiceJob: no ebilling document for xml_generating promotion', [ + 'inbox_attachment_id' => $attachment->id, + ]); + + return; + } + + $document->gateway_status = EBillingAttachmentProcessingStatus::XmlGenerating; + $document->save(); + + GenerateXmlJob::dispatch($attachment->id); + } + + /** + * @param array $billData + */ + private function hasExtractedInvoiceNumber(array $billData): bool + { + $invoiceNumber = $billData['invoice_number'] ?? ''; + + return is_string($invoiceNumber) && trim($invoiceNumber) !== ''; + } + + private function maybeMarkInboxMessageProcessedIfAllPdfAttachmentsTerminal(?InboxMessage $message): void + { + if ($message === null) { + return; + } + + $message->load('attachments'); + $pdfs = $message->pdfAttachments()->get(); + + if ($pdfs->isEmpty()) { + return; + } + + foreach ($pdfs as $pdf) { + if ($this->attachmentPipelineStillInFlight($pdf)) { + return; + } + } + + $hasFailure = $pdfs->contains(fn (InboxAttachment $a): bool => $this->attachmentIsPipelineFailed($a)); + + if ($hasFailure) { + $error = $message->error_message !== null && $message->error_message !== '' + ? $message->error_message + : 'One or more attachments failed processing'; + $message->markAsPartiallyFailed($error); + + return; + } + + $message->error_message = null; + $message->markAsProcessed(); + } + + private function attachmentPipelineStillInFlight(InboxAttachment $attachment): bool + { + if (in_array($attachment->processing_status, [ + InboxAttachmentProcessingStatus::New->value, + InboxAttachmentProcessingStatus::Processing->value, + ], true)) { + return true; + } + + $gatewayStatus = EbillingDocument::forSourceAttachment($attachment)?->gateway_status; + + return in_array($gatewayStatus, [ + EBillingAttachmentProcessingStatus::XmlGenerating, + EBillingAttachmentProcessingStatus::XmlValidated, + EBillingAttachmentProcessingStatus::ZugferdPdfGenerating, + ], true); + } + + private function attachmentIsPipelineFailed(InboxAttachment $attachment): bool + { + if ($attachment->processing_status === InboxAttachmentProcessingStatus::Failed->value) { + return true; + } + + return in_array(EbillingDocument::forSourceAttachment($attachment)?->gateway_status, [ + EBillingAttachmentProcessingStatus::XmlGenerationFailed, + EBillingAttachmentProcessingStatus::XmlValidationFailed, + EBillingAttachmentProcessingStatus::KositError, + EBillingAttachmentProcessingStatus::ZugferdPdfFailed, + ], true); + } + + public function failed(?Throwable $exception = null): void + { + Log::error('[EBilling] FilterForeignInvoiceJob failed', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + 'exception' => $exception, + ]); + } +} diff --git a/packages/e-billing/src/Jobs/GenerateXmlJob.php b/packages/e-billing/src/Jobs/GenerateXmlJob.php new file mode 100644 index 0000000000..8a80299f97 --- /dev/null +++ b/packages/e-billing/src/Jobs/GenerateXmlJob.php @@ -0,0 +1,171 @@ + + */ + public array $backoff = [60, 300]; + + public function __construct( + public int $inboxAttachmentId, + ) {} + + public function handle( + ZugferdConverter $zugferdConverter, + ParsedInvoiceMapper $parsedInvoiceMapper, + InvoiceFieldValidator $invoiceFieldValidator, + ): void { + $this->setProgress(0); + + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + Log::warning('[EBilling] GenerateXmlJob: attachment not found', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + ]); + $this->setProgress(100); + + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + $canRun = $attachment->isPdf() && ( + $attachment->processing_status === InboxAttachmentProcessingStatus::Processing->value + || $document?->gateway_status === EBillingAttachmentProcessingStatus::XmlGenerating + ); + + if (! $canRun) { + $this->setProgress(100); + + return; + } + + $this->setProgress(15); + $billData = $document?->bill_data; + if (is_array($billData) && $billData !== []) { + $dto = InvoiceDto::fromArray($billData); + } else { + Log::error('GenerateXmlJob: bill_data missing, cannot generate XML without parsed data', [ + 'attachment_id' => $attachment->id, + ]); + $this->setProgress(100); + + return; + } + + $this->setProgress(30); + + $invoice = $parsedInvoiceMapper->createFromDto($dto, $attachment); + + $this->setProgress(45); + + $xml = $zugferdConverter->convert(new ZugferdInvoiceAdapter($invoice)); + + $diskName = (string) config('e-billing.zugferd.storage_disk', 'zugferd'); + $relativeDir = $attachment->scope.'/'.EBillingArtifactNaming::invoiceDatePathSegment($invoice->invoice_date); + + $existingXmlPath = $document?->xml_storage_path; + if ( + is_string($existingXmlPath) + && $existingXmlPath !== '' + && Storage::disk($diskName)->exists($existingXmlPath) + ) { + $relativeXmlPath = $existingXmlPath; + } else { + $basename = EBillingArtifactNaming::uniqueBasenameFor($attachment, $diskName, $relativeDir); + $relativeXmlPath = $relativeDir.'/'.$basename.'.xml'; + } + + Storage::disk($diskName)->put($relativeXmlPath, $xml); + + $this->setProgress(70); + + $billDataArray = $dto->toArray(); + + if ($document !== null) { + $document->zugferd_storage_disk = null; + $document->zugferd_storage_path = null; + $document->xml_storage_path = $relativeXmlPath; + $document->bill_data = $billDataArray; + $document->save(); + } + + if ($document !== null) { + $document->refresh(); + $invoiceFieldValidator->validate($document); + } + + $this->setProgress(90); + + event(new XmlGenerated($attachment->id)); + + ValidateXmlJob::dispatch($attachment->id); + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + if ($document !== null) { + $document->gateway_status = EBillingAttachmentProcessingStatus::XmlGenerationFailed; + $document->save(); + } + + $attachment->markAsFailed($exception?->getMessage() ?? 'GenerateXmlJob failed'); + + try { + app(InboxMessagePipelineFinalizer::class)->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + } catch (Throwable $e) { + Log::error('[EBilling] GenerateXmlJob failed() finalizer error', [ + 'exception' => $e, + 'inbox_attachment_id' => $attachment->id, + ]); + } + } +} diff --git a/packages/e-billing/src/Jobs/MergeZugferdPdfJob.php b/packages/e-billing/src/Jobs/MergeZugferdPdfJob.php new file mode 100644 index 0000000000..957c910423 --- /dev/null +++ b/packages/e-billing/src/Jobs/MergeZugferdPdfJob.php @@ -0,0 +1,191 @@ + + */ + public array $backoff = [60, 300]; + + public function __construct( + public int $inboxAttachmentId, + ) {} + + public function handle( + ZugferdConverter $converter, + InboxMessagePipelineFinalizer $pipelineFinalizer, + ): void { + $this->setProgress(0); + + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + Log::warning('[EBilling] MergeZugferdPdfJob: attachment not found', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + ]); + $this->setProgress(100); + + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + if ($document?->gateway_status === EBillingAttachmentProcessingStatus::ZugferdPdfGenerated + && $document->zugferd_storage_path !== null) { + $this->setProgress(100); + + return; + } + + $validation = $document?->latestKositValidation(); + + if ($validation === null || $validation->passed !== true) { + Log::warning('[EBilling] MergeZugferdPdfJob: skipping merge — no passed KositValidation', [ + 'inbox_attachment_id' => $attachment->id, + 'kosit_validation_id' => $validation?->id, + 'validation_passed' => $validation?->passed, + 'processing_status' => $attachment->processing_status, + ]); + $this->setProgress(100); + + return; + } + + $allowedStatuses = [ + EBillingAttachmentProcessingStatus::XmlValidated, + EBillingAttachmentProcessingStatus::ZugferdPdfGenerating, + EBillingAttachmentProcessingStatus::ZugferdPdfFailed, + EBillingAttachmentProcessingStatus::ZugferdPdfGenerated, + ]; + + if (! in_array($document?->gateway_status, $allowedStatuses, true)) { + Log::notice('[EBilling] MergeZugferdPdfJob: unexpected gateway_status, skipping', [ + 'inbox_attachment_id' => $attachment->id, + 'gateway_status' => $document?->gateway_status?->value, + 'processing_status' => $attachment->processing_status, + ]); + $this->setProgress(100); + + return; + } + + $this->setProgress(15); + + if ($document !== null) { + $document->gateway_status = EBillingAttachmentProcessingStatus::ZugferdPdfGenerating; + $document->save(); + } + + $diskName = $document?->zugferd_storage_disk + ?? (string) config('e-billing.zugferd.storage_disk', 'zugferd'); + + $xmlRelative = $document?->xml_storage_path; + if ($xmlRelative === null || $xmlRelative === '') { + throw new InvalidArgumentException( + 'Ebilling document has no xml_storage_path; run GenerateXmlJob first.' + ); + } + + $xmlString = Storage::disk($diskName)->get($xmlRelative); + if (! is_string($xmlString) || $xmlString === '') { + throw new InvalidArgumentException( + 'XML file is missing or empty at path: '.$xmlRelative.' on disk '.$diskName + ); + } + + $invoiceData = $document?->bill_data; + if (! is_array($invoiceData) || $invoiceData === []) { + throw new InvalidArgumentException( + 'Ebilling document has no bill_data; run GenerateXmlJob first.' + ); + } + + $this->setProgress(35); + + $pdfBinary = $converter->mergePdfWithXml($attachment->fullPath(), $xmlString); + + $this->setProgress(65); + + $basename = pathinfo($xmlRelative, PATHINFO_FILENAME); + $dir = pathinfo($xmlRelative, PATHINFO_DIRNAME); + $relativePdfPath = $dir.'/'.$basename.'.pdf'; + + Storage::disk($diskName)->put($relativePdfPath, $pdfBinary); + + $this->setProgress(85); + + if ($document !== null) { + $document->zugferd_storage_disk = $diskName; + $document->zugferd_storage_path = $relativePdfPath; + $document->gateway_status = EBillingAttachmentProcessingStatus::ZugferdPdfGenerated; + $document->processed_at = now(); + $document->save(); + } + + $attachment->markAsProcessed(); + + event(new ZugferdPdfGenerated($attachment->id)); + + $pipelineFinalizer->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + if ($document !== null) { + $document->gateway_status = EBillingAttachmentProcessingStatus::ZugferdPdfFailed; + $document->save(); + } + + $attachment->markAsFailed($exception?->getMessage() ?? 'MergeZugferdPdfJob failed'); + + try { + app(InboxMessagePipelineFinalizer::class)->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + } catch (Throwable $e) { + Log::error('[EBilling] MergeZugferdPdfJob failed() finalizer error', [ + 'exception' => $e, + 'inbox_attachment_id' => $attachment->id, + ]); + } + } +} diff --git a/packages/e-billing/src/Jobs/StoreBillDataJob.php b/packages/e-billing/src/Jobs/StoreBillDataJob.php new file mode 100644 index 0000000000..b4ea768dea --- /dev/null +++ b/packages/e-billing/src/Jobs/StoreBillDataJob.php @@ -0,0 +1,86 @@ + + */ + public array $backoff = [60, 300]; + + public function __construct( + public int $inboxAttachmentId, + ) {} + + public function handle(): void + { + $this->setProgress(0); + + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + Log::warning('[EBilling] StoreBillDataJob: attachment not found', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + ]); + $this->setProgress(100); + + return; + } + + if (! $attachment->isPdf()) { + $this->setProgress(100); + + return; + } + + if ($attachment->processing_status !== InboxAttachmentProcessingStatus::Processing->value) { + $this->setProgress(100); + + return; + } + + $this->setProgress(20); + + $document = EbillingDocument::forSourceAttachment($attachment); + $billData = $document?->bill_data; + if (! is_array($billData) || $billData === []) { + Log::warning('[EBilling] StoreBillDataJob: bill_data missing or empty', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + 'attachment_id' => $attachment->id, + ]); + } + + $this->setProgress(80); + + FilterForeignInvoiceJob::dispatch($this->inboxAttachmentId); + + $this->setProgress(100); + } +} diff --git a/packages/e-billing/src/Jobs/ValidateXmlJob.php b/packages/e-billing/src/Jobs/ValidateXmlJob.php new file mode 100644 index 0000000000..cab706fdc5 --- /dev/null +++ b/packages/e-billing/src/Jobs/ValidateXmlJob.php @@ -0,0 +1,197 @@ + + */ + public array $backoff = [60, 300]; + + public function __construct( + public int $inboxAttachmentId, + ) {} + + public function handle( + KositService $kosit, + RecordKositValidation $recordKositValidation, + InboxMessagePipelineFinalizer $pipelineFinalizer, + ): void { + $this->setProgress(0); + + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + Log::warning('[EBilling] ValidateXmlJob: attachment not found', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + ]); + $this->setProgress(100); + + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + if ($document?->gateway_status === EBillingAttachmentProcessingStatus::XmlValidated) { + $zugferdPath = $document?->zugferd_storage_path; + if (is_string($zugferdPath) && $zugferdPath !== '') { + $this->setProgress(100); + $pipelineFinalizer->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + + return; + } + + MergeZugferdPdfJob::dispatch($this->inboxAttachmentId); + $this->setProgress(100); + $pipelineFinalizer->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + + return; + } + + $allowedStatuses = [ + EBillingAttachmentProcessingStatus::XmlGenerating, + EBillingAttachmentProcessingStatus::XmlValidationFailed, + EBillingAttachmentProcessingStatus::KositError, + ]; + + if (! in_array($document?->gateway_status, $allowedStatuses, true)) { + Log::notice('[EBilling] ValidateXmlJob: unexpected gateway_status, skipping', [ + 'inbox_attachment_id' => $attachment->id, + 'gateway_status' => $document?->gateway_status?->value, + 'processing_status' => $attachment->processing_status, + ]); + $this->setProgress(100); + + return; + } + + $xmlRelative = $document?->xml_storage_path; + if ($xmlRelative === null || $xmlRelative === '') { + throw new InvalidArgumentException( + 'Ebilling document has no xml_storage_path; run GenerateXmlJob first.' + ); + } + + $diskName = $document?->zugferd_storage_disk + ?? (string) config('e-billing.zugferd.storage_disk', 'zugferd'); + + $absoluteXmlPath = Storage::disk($diskName)->path($xmlRelative); + + $this->setProgress(30); + + $invoiceData = $document?->bill_data; + $invoiceDate = ''; + if (is_array($invoiceData) && is_string($invoiceData['invoice_date'] ?? null)) { + $invoiceDate = $invoiceData['invoice_date']; + } + + $dateSegment = EBillingArtifactNaming::invoiceDatePathSegment($invoiceDate); + $kositReportDirectory = KositOutputPath::resolve($dateSegment); + + $result = $kosit->validate($absoluteXmlPath, $kositReportDirectory); + + $this->setProgress(70); + + $errorStrings = $result->errors(); + + if ($result->passed()) { + DB::transaction(function () use ($recordKositValidation, $result, $document): void { + $validation = $recordKositValidation($result); + + if ($document !== null) { + $document->kositValidations()->attach($validation->id); + $document->gateway_status = EBillingAttachmentProcessingStatus::XmlValidated; + $document->save(); + } + }); + + event(new XmlValidationPassed($attachment->id)); + + MergeZugferdPdfJob::dispatch($this->inboxAttachmentId); + } else { + $failureMessage = $errorStrings !== [] ? implode('; ', $errorStrings) : 'XML validation failed'; + + DB::transaction(function () use ($recordKositValidation, $result, $attachment, $document, $failureMessage): void { + $validation = $recordKositValidation($result); + + if ($document !== null) { + $document->kositValidations()->attach($validation->id); + $document->gateway_status = EBillingAttachmentProcessingStatus::XmlValidationFailed; + $document->save(); + } + + $attachment->markAsFailed($failureMessage); + }); + + event(new XmlValidationFailed($attachment->id, array_values($errorStrings))); + } + + $this->setProgress(90); + + $pipelineFinalizer->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + return; + } + + $document = EbillingDocument::forSourceAttachment($attachment); + + if ($document !== null) { + $document->gateway_status = EBillingAttachmentProcessingStatus::KositError; + $document->save(); + } + + $attachment->markAsFailed($exception?->getMessage() ?? 'ValidateXmlJob failed'); + + try { + app(InboxMessagePipelineFinalizer::class)->finalizeAfterAttachmentPipelineStep($attachment->inbox_message_id); + } catch (Throwable $e) { + Log::error('[EBilling] ValidateXmlJob failed() finalizer error', [ + 'exception' => $e, + 'inbox_attachment_id' => $attachment->id, + ]); + } + } +} diff --git a/packages/e-billing/src/Listeners/ProcessInboxAttachmentListener.php b/packages/e-billing/src/Listeners/ProcessInboxAttachmentListener.php new file mode 100644 index 0000000000..18934f75e6 --- /dev/null +++ b/packages/e-billing/src/Listeners/ProcessInboxAttachmentListener.php @@ -0,0 +1,51 @@ +attachment->fresh(); + + if ($attachment === null || ! $attachment->isPdf() + || $attachment->processing_status !== InboxAttachmentProcessingStatus::Processing->value) { + return; + } + + if ($attachment->message === null) { + return; + } + + $this->resolveOrCreateEbillingDocument($attachment); + + StoreBillDataJob::dispatch($attachment->id); + } + + private function resolveOrCreateEbillingDocument(InboxAttachment $attachment): EbillingDocument + { + /** @var EbillingDocument $document */ + $document = EbillingDocument::query()->firstOrCreate( + [ + 'source_type' => $attachment->getMorphClass(), + 'source_id' => $attachment->getKey(), + ], + [ + 'scope' => $attachment->scope, + 'gateway_status' => null, + 'review_status' => InvoiceProcessingStatus::ParserCreated, + ], + ); + + return $document; + } +} diff --git a/packages/e-billing/src/Models/EbillingDocument.php b/packages/e-billing/src/Models/EbillingDocument.php new file mode 100644 index 0000000000..390d9f28ac --- /dev/null +++ b/packages/e-billing/src/Models/EbillingDocument.php @@ -0,0 +1,465 @@ +|null $bill_data + * @property array|null $ignored_reason + * @property EBillingAttachmentProcessingStatus|null $gateway_status + * @property InvoiceProcessingStatus|null $review_status + * @property array|null $field_validations + * @property string|null $invoice_id + * @property string|null $company_id + * @property int|null $validation_score + * @property string|null $scope + */ +class EbillingDocument extends BaseItemModel +{ + use HasMorphPivotRelations; + use HasUuids; + + protected $table = 'ebilling_documents'; + + protected $keyType = 'string'; + + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'source_type', + 'source_id', + 'bill_data', + 'xml_storage_path', + 'zugferd_storage_disk', + 'zugferd_storage_path', + 'ignored_reason', + 'gateway_status', + 'review_status', + 'validation_score', + 'field_validations', + 'processed_at', + 'error_message', + 'invoice_id', + 'company_id', + 'scope', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'bill_data' => 'array', + 'ignored_reason' => 'array', + 'gateway_status' => EBillingAttachmentProcessingStatus::class, + 'review_status' => InvoiceProcessingStatus::class, + 'field_validations' => 'array', + 'validation_score' => 'integer', + 'processed_at' => 'datetime', + ]; + } + + public static function getResourceName(): string + { + return 'ebilling-document'; + } + + public static function forSourceAttachment(InboxAttachment $attachment): ?self + { + return self::query() + ->where('source_type', $attachment->getMorphClass()) + ->where('source_id', (string) $attachment->getKey()) + ->first(); + } + + /** + * @return MorphTo + */ + public function source(): MorphTo + { + return $this->morphTo(); + } + + /** + * @return MorphToMany + */ + public function kositValidations(): MorphToMany + { + return $this->morphPivotRelation('kosit_validatables'); + } + + public function latestKositValidation(): ?KositValidation + { + if ($this->relationLoaded('kositValidations')) { + /** @var KositValidation|null $latest */ + $latest = $this->kositValidations + ->sortByDesc(fn (KositValidation $validation): string => ($validation->validated_at?->format('Y-m-d H:i:s.u') ?? '').':'.$validation->getKey()) + ->first(); + + return $latest; + } + + return $this->kositValidations()->orderByDesc('validated_at')->orderByDesc('id')->first(); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeWhereLatestKositValidationPassed(Builder $query, bool $passed): Builder + { + $morphClass = $query->getModel()->getMorphClass(); + $documentsTable = $query->getModel()->getTable(); + + return $query->whereExists(function ($exists) use ($morphClass, $documentsTable, $passed): void { + $exists->selectRaw('1') + ->from('kosit_validations as latest_kv') + ->join('kosit_validatables as latest_kvt', 'latest_kvt.kosit_validation_id', '=', 'latest_kv.id') + ->whereColumn('latest_kvt.validatable_id', "{$documentsTable}.id") + ->where('latest_kvt.validatable_type', $morphClass) + ->where('latest_kv.passed', $passed) + ->whereRaw( + "latest_kv.id = ( + SELECT kv2.id + FROM kosit_validatables kvt2 + INNER JOIN kosit_validations kv2 ON kv2.id = kvt2.kosit_validation_id + WHERE kvt2.validatable_type = ? + AND kvt2.validatable_id = {$documentsTable}.id + ORDER BY kv2.validated_at DESC, kv2.id DESC + LIMIT 1 + )", + [$morphClass], + ); + }); + } + + /** + * @return BelongsTo + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'invoice_id'); // Extend Invoice in your host app if needed + } + + /** + * @return BelongsTo + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * Line-level field validations are stored under {@see $field_validations}['lines'] keyed by line id. + * + * @return Collection + */ + public function fieldValidationItems(): Collection + { + return collect(); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeNeedsHumanReview(Builder $query): Builder + { + return $query + ->whereIn('review_status', [ + InvoiceProcessingStatus::ParserCreated->value, + InvoiceProcessingStatus::DbValidated->value, + ]) + ->where(function (Builder $outer): void { + $outer->where(function (Builder $documentQuery): void { + self::applyJsonHasProblematicFieldStatus($documentQuery, 'field_validations'); + }); + }); + } + + public function getValidationScoreAttribute(): ?int + { + $raw = $this->getAttributes()['validation_score'] ?? null; + + if ($raw !== null && $raw !== '') { + return (int) $raw; + } + + return $this->calculateValidationScore(); + } + + /** + * Computes the validation score from `field_validations` JSON on the document. + * Used to materialize {@see $validation_score} and as a fallback when the column is null. + */ + public function calculateValidationScore(): ?int + { + $invoiceFv = is_array($this->field_validations) ? $this->field_validations : []; + $linesFv = $invoiceFv['lines'] ?? null; + $hasLineFv = is_array($linesFv) && $linesFv !== []; + + if ($invoiceFv === [] && ! $hasLineFv) { + return null; + } + + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + + if (! is_array($invoiceFields)) { + $invoiceFields = []; + } + if (! is_array($lineFields)) { + $lineFields = []; + } + + $total = 0; + $valid = 0; + + foreach ($invoiceFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $total++; + $status = $this->readFieldStatus($this->field_validations, $field); + if ($this->statusCountsTowardValidationScore($status)) { + $valid++; + } + } + + if (is_array($linesFv)) { + foreach ($linesFv as $lineFieldValidations) { + if (! is_array($lineFieldValidations)) { + continue; + } + foreach ($lineFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $total++; + $status = $this->readFieldStatus($lineFieldValidations, $field); + if ($this->statusCountsTowardValidationScore($status)) { + $valid++; + } + } + } + } + + if ($total === 0) { + return null; + } + + return (int) round(($valid / $total) * 100); + } + + public function transitionTo(InvoiceProcessingStatus $newStatus): void + { + $current = $this->resolveReviewStatusEnum(); + + if (! $current->canTransitionTo($newStatus)) { + throw new InvalidArgumentException( + sprintf( + 'Cannot transition invoice processing status from %s to %s.', + $current->value, + $newStatus->value + ) + ); + } + + $this->review_status = $newStatus; + $this->save(); + } + + public function isFullyValidated(): bool + { + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + if (! is_array($invoiceFields)) { + return true; + } + + foreach ($invoiceFields as $field => $priority) { + if ($priority !== 'must') { + continue; + } + $status = $this->readFieldStatus($this->field_validations, (string) $field); + if (! $this->statusIsFullyValidated($status)) { + return false; + } + } + + return true; + } + + public function needsHumanReview(): bool + { + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + if (is_array($invoiceFields)) { + foreach ($invoiceFields as $field => $priority) { + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $status = $this->readFieldStatus($this->field_validations, (string) $field); + if (in_array($status, ['needs_review', 'missing'], true)) { + return true; + } + } + } + + $linesFv = is_array($this->field_validations) ? ($this->field_validations['lines'] ?? null) : null; + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + + if (is_array($linesFv) && is_array($lineFields)) { + foreach ($linesFv as $lineFieldValidations) { + if (! is_array($lineFieldValidations)) { + continue; + } + foreach ($lineFields as $field => $priority) { + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $status = $this->readFieldStatus($lineFieldValidations, (string) $field); + if (in_array($status, ['needs_review', 'missing'], true)) { + return true; + } + } + } + } + + return false; + } + + /** + * @return array{status: string, source?: string, matched_id?: string}|null + */ + public function getFieldValidation(string $fieldName): ?array + { + $all = $this->field_validations; + + if (! is_array($all) || ! isset($all[$fieldName]) || ! is_array($all[$fieldName])) { + return null; + } + + /** @var array{status: string, source?: string, matched_id?: string} $entry */ + $entry = $all[$fieldName]; + + return $entry; + } + + public function setFieldValidation(string $fieldName, string $status, ?string $source = null, ?string $matchedId = null): void + { + $all = is_array($this->field_validations) ? $this->field_validations : []; + $entry = ['status' => $status]; + if ($source !== null) { + $entry['source'] = $source; + } + if ($matchedId !== null) { + $entry['matched_id'] = $matchedId; + } + $all[$fieldName] = $entry; + $this->field_validations = $all; + $this->save(); + } + + /** + * @param array $parameters + */ + public function __call($method, $parameters): mixed + { + return $this->morphPivotCall($method, $parameters); + } + + private function resolveReviewStatusEnum(): InvoiceProcessingStatus + { + $value = $this->review_status; + if ($value instanceof InvoiceProcessingStatus) { + return $value; + } + + $raw = $this->getAttributes()['review_status'] ?? InvoiceProcessingStatus::ParserCreated->value; + + return InvoiceProcessingStatus::from((string) $raw); + } + + /** + * @param array|null $validations + */ + private function readFieldStatus(?array $validations, string $field): ?string + { + if (! is_array($validations) || ! isset($validations[$field]) || ! is_array($validations[$field])) { + return null; + } + + $status = $validations[$field]['status'] ?? null; + + return is_string($status) ? $status : null; + } + + private function statusIsFullyValidated(?string $status): bool + { + return in_array($status, ['validated', 'db_validated'], true); + } + + private static function applyJsonHasProblematicFieldStatus(Builder $query, string $column): void + { + $qualified = $query->qualifyColumn($column); + $connection = $query->getConnection(); + $driver = match (true) { + $connection instanceof MySqlConnection => 'mysql', + $connection instanceof SQLiteConnection => 'sqlite', + default => 'sqlite', + }; + + if ($driver === 'mysql') { + $query->whereRaw( + "({$qualified} IS NOT NULL AND (JSON_SEARCH({$qualified}, 'one', 'needs_review', NULL, '\$**.status') IS NOT NULL OR JSON_SEARCH({$qualified}, 'one', 'missing', NULL, '\$**.status') IS NOT NULL))" + ); + + return; + } + + $query->whereRaw( + "({$qualified} IS NOT NULL AND ({$qualified} LIKE ? OR {$qualified} LIKE ?))", + ['%"status":"needs_review"%', '%"status":"missing"%'] + ); + } + + private function statusCountsTowardValidationScore(?string $status): bool + { + return in_array($status, ['validated', 'db_validated', 'not_applicable', 'parsed'], true); + } +} diff --git a/packages/e-billing/src/Models/Invoice.php b/packages/e-billing/src/Models/Invoice.php new file mode 100644 index 0000000000..e3b35f0488 --- /dev/null +++ b/packages/e-billing/src/Models/Invoice.php @@ -0,0 +1,439 @@ +|null $field_validations + * @property int|null $validation_score + * @property string|null $country Billing-country projection derived only from the buyer/customer Address DTO. + */ +class Invoice extends Model +{ + use SoftDeletes; + + protected $table = 'invoices'; + + /** + * @var list + */ + protected $fillable = [ + 'inbox_attachment_id', + 'customer_id', + 'invoice_number', + 'invoice_date', + 'document_type', + 'due_date', + 'currency', + 'customer_number', + 'customer_name', + 'customer_address', + 'country', + 'customer_vat_id', + 'customer_reference', + 'order_number', + 'order_date', + 'delivery_address', + 'supplier_name', + 'supplier_vat_id', + 'supplier_tax_number', + 'supplier_address', + 'supplier_number', + 'supplier_phone', + 'supplier_email', + 'supplier_bank_accounts', + 'agent', + 'payment_terms', + 'pricing_basis', + 'shipping_method', + 'net_total', + 'vat_rate', + 'vat_amount', + 'gross_total', + 'discount_percent', + 'discount_amount', + 'shipping_cost', + 'minimum_quantity_surcharge', + 'freight_flat_rate', + 'packaging_cost', + 'notes', + 'processing_status', + 'field_validations', + 'validation_score', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'customer_address' => 'array', + // Billing-country projection: write from InvoiceFactory using customerAddress.country only. + 'country' => 'string', + 'delivery_address' => 'array', + 'supplier_address' => 'array', + 'supplier_bank_accounts' => 'array', + 'notes' => 'array', + 'field_validations' => 'array', + 'validation_score' => 'integer', + 'net_total' => 'decimal:2', + 'vat_amount' => 'decimal:2', + 'gross_total' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'shipping_cost' => 'decimal:2', + 'minimum_quantity_surcharge' => 'decimal:2', + 'freight_flat_rate' => 'decimal:2', + 'packaging_cost' => 'decimal:2', + 'vat_rate' => 'decimal:2', + 'discount_percent' => 'decimal:2', + 'processing_status' => InvoiceProcessingStatus::class, + ]; + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(InvoiceLine::class)->orderBy('position'); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return BelongsTo + */ + public function inboxAttachment(): BelongsTo + { + return $this->belongsTo(InboxAttachment::class, 'inbox_attachment_id'); + } + + /** + * Returns field_validations from all invoice lines. + * + * Uses eager-loaded lines if available (e.g., detail view) for performance. + * Falls back to a narrow query selecting only field_validations (e.g., list view). + * + * Note: The eager-loaded path may return stale data if lines were modified + * after loading. In the list view this is not an issue since lines are not + * eager-loaded. In the detail view, the data is loaded fresh per request. + * + * @return Collection + */ + public function fieldValidationItems(): Collection + { + if ($this->relationLoaded('lines')) { + return $this->lines; + } + + if (! $this->exists) { + return collect(); + } + + return InvoiceLine::query() + ->where('invoice_id', $this->id) + ->orderBy('position') + ->select(['id', 'invoice_id', 'position', 'field_validations']) + ->get(); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeNeedsHumanReview(Builder $query): Builder + { + return $query + ->whereIn('processing_status', [ + InvoiceProcessingStatus::ParserCreated->value, + InvoiceProcessingStatus::DbValidated->value, + ]) + ->where(function (Builder $outer): void { + $outer + ->where(function (Builder $invoiceQuery): void { + self::applyJsonHasProblematicFieldStatus($invoiceQuery, 'field_validations'); + }) + ->orWhereHas('lines', function (Builder $lines): void { + self::applyJsonHasProblematicFieldStatus($lines, 'field_validations'); + }); + }); + } + + public function getValidationScoreAttribute(): ?int + { + $raw = $this->getAttributes()['validation_score'] ?? null; + + if ($raw !== null && $raw !== '') { + return (int) $raw; + } + + return $this->calculateValidationScore(); + } + + /** + * Computes the validation score from `field_validations` JSON on the invoice and its lines. + * Used to materialize {@see $validation_score} and as a fallback when the column is null. + */ + public function calculateValidationScore(): ?int + { + $invoiceFv = is_array($this->field_validations) ? $this->field_validations : []; + $lines = $this->fieldValidationItems(); + $anyLineFv = $lines->contains(fn (InvoiceLine $line): bool => is_array($line->field_validations) && $line->field_validations !== []); + + if ($invoiceFv === [] && ! $anyLineFv) { + return null; + } + + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + + if (! is_array($invoiceFields)) { + $invoiceFields = []; + } + if (! is_array($lineFields)) { + $lineFields = []; + } + + $total = 0; + $valid = 0; + + foreach ($invoiceFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $total++; + $status = $this->readFieldStatus($this->field_validations, $field); + if ($this->statusCountsTowardValidationScore($status)) { + $valid++; + } + } + + foreach ($lines as $line) { + foreach ($lineFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $total++; + $status = $this->readFieldStatus($line->field_validations, $field); + if ($this->statusCountsTowardValidationScore($status)) { + $valid++; + } + } + } + + if ($total === 0) { + return null; + } + + return (int) round(($valid / $total) * 100); + } + + public function transitionTo(InvoiceProcessingStatus $newStatus): void + { + $current = $this->resolveProcessingStatusEnum(); + + if (! $current->canTransitionTo($newStatus)) { + throw new InvalidArgumentException( + sprintf( + 'Cannot transition invoice processing status from %s to %s.', + $current->value, + $newStatus->value + ) + ); + } + + $this->processing_status = $newStatus; + $this->save(); + } + + public function isFullyValidated(): bool + { + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + if (! is_array($invoiceFields)) { + return true; + } + + foreach ($invoiceFields as $field => $priority) { + if ($priority !== 'must') { + continue; + } + $status = $this->readFieldStatus($this->field_validations, (string) $field); + if (! $this->statusIsFullyValidated($status)) { + return false; + } + } + + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + if (! is_array($lineFields)) { + return true; + } + + foreach ($this->lines as $line) { + foreach ($lineFields as $field => $priority) { + if ($priority !== 'must') { + continue; + } + $status = $this->readFieldStatus($line->field_validations, (string) $field); + if (! $this->statusIsFullyValidated($status)) { + return false; + } + } + } + + return true; + } + + public function needsHumanReview(): bool + { + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + if (is_array($invoiceFields)) { + foreach ($invoiceFields as $field => $priority) { + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $status = $this->readFieldStatus($this->field_validations, (string) $field); + if (in_array($status, ['needs_review', 'missing'], true)) { + return true; + } + } + } + + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + if (! is_array($lineFields)) { + return false; + } + + foreach ($this->lines as $line) { + foreach ($lineFields as $field => $priority) { + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $status = $this->readFieldStatus($line->field_validations, (string) $field); + if (in_array($status, ['needs_review', 'missing'], true)) { + return true; + } + } + } + + return false; + } + + /** + * @return array{status: string, source?: string, matched_id?: int}|null + */ + public function getFieldValidation(string $fieldName): ?array + { + $all = $this->field_validations; + + if (! is_array($all) || ! isset($all[$fieldName]) || ! is_array($all[$fieldName])) { + return null; + } + + /** @var array{status: string, source?: string, matched_id?: int} $entry */ + $entry = $all[$fieldName]; + + return $entry; + } + + public function setFieldValidation(string $fieldName, string $status, ?string $source = null, ?int $matchedId = null): void + { + $all = is_array($this->field_validations) ? $this->field_validations : []; + $entry = ['status' => $status]; + if ($source !== null) { + $entry['source'] = $source; + } + if ($matchedId !== null) { + $entry['matched_id'] = $matchedId; + } + $all[$fieldName] = $entry; + $this->field_validations = $all; + $this->save(); + } + + private function resolveProcessingStatusEnum(): InvoiceProcessingStatus + { + $value = $this->processing_status; + if ($value instanceof InvoiceProcessingStatus) { + return $value; + } + + $raw = $this->getAttributes()['processing_status'] ?? InvoiceProcessingStatus::ParserCreated->value; + + return InvoiceProcessingStatus::from((string) $raw); + } + + /** + * @param array|null $validations + */ + private function readFieldStatus(?array $validations, string $field): ?string + { + if (! is_array($validations) || ! isset($validations[$field]) || ! is_array($validations[$field])) { + return null; + } + + $status = $validations[$field]['status'] ?? null; + + return is_string($status) ? $status : null; + } + + private function statusIsFullyValidated(?string $status): bool + { + return in_array($status, ['validated', 'db_validated'], true); + } + + private static function applyJsonHasProblematicFieldStatus(Builder $query, string $column): void + { + $qualified = $query->qualifyColumn($column); + $connection = $query->getConnection(); + $driver = match (true) { + $connection instanceof MySqlConnection => 'mysql', + $connection instanceof SQLiteConnection => 'sqlite', + default => 'sqlite', + }; + + if ($driver === 'mysql') { + $query->whereRaw( + "({$qualified} IS NOT NULL AND (JSON_SEARCH({$qualified}, 'one', 'needs_review', NULL, '\$**.status') IS NOT NULL OR JSON_SEARCH({$qualified}, 'one', 'missing', NULL, '\$**.status') IS NOT NULL))" + ); + + return; + } + + $query->whereRaw( + "({$qualified} IS NOT NULL AND ({$qualified} LIKE ? OR {$qualified} LIKE ?))", + ['%"status":"needs_review"%', '%"status":"missing"%'] + ); + } + + private function statusCountsTowardValidationScore(?string $status): bool + { + return in_array($status, ['validated', 'db_validated', 'not_applicable', 'parsed'], true); + } +} diff --git a/packages/e-billing/src/Models/InvoiceLine.php b/packages/e-billing/src/Models/InvoiceLine.php new file mode 100644 index 0000000000..b53ed4400a --- /dev/null +++ b/packages/e-billing/src/Models/InvoiceLine.php @@ -0,0 +1,118 @@ +|null $field_validations + */ +class InvoiceLine extends Model +{ + use SoftDeletes; + + protected $table = 'invoice_lines'; + + /** + * @var list + */ + protected $fillable = [ + 'invoice_id', + 'material_id', + 'position', + 'unit', + 'quantity', + 'description', + 'description_detail', + 'article_number', + 'material', + 'material_test_certificate', + 'material_test_certificate_price', + 'customs_tariff_number', + 'weight_kg_total', + 'weight_kg_net', + 'unit_price', + 'line_total', + 'surcharge_amount', + 'surcharge_description', + 'delivery_date', + 'delivery_note_number', + 'order_number', + 'order_date', + 'delivery_address', + 'field_validations', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'delivery_address' => 'array', + 'field_validations' => 'array', + 'quantity' => 'decimal:3', + 'weight_kg_total' => 'decimal:3', + 'weight_kg_net' => 'decimal:3', + 'unit_price' => 'decimal:2', + 'line_total' => 'decimal:2', + 'surcharge_amount' => 'decimal:2', + 'material_test_certificate_price' => 'decimal:2', + ]; + } + + /** + * @return BelongsTo + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + /** + * @return BelongsTo + */ + public function materialRecord(): BelongsTo + { + return $this->belongsTo(Material::class, 'material_id'); + } + + /** + * @return array{status: string, source?: string, matched_id?: int}|null + */ + public function getFieldValidation(string $fieldName): ?array + { + $all = $this->field_validations; + + if (! is_array($all) || ! isset($all[$fieldName]) || ! is_array($all[$fieldName])) { + return null; + } + + /** @var array{status: string, source?: string, matched_id?: int} $entry */ + $entry = $all[$fieldName]; + + return $entry; + } + + public function setFieldValidation(string $fieldName, string $status, ?string $source = null, ?int $matchedId = null): void + { + $all = is_array($this->field_validations) ? $this->field_validations : []; + $entry = ['status' => $status]; + if ($source !== null) { + $entry['source'] = $source; + } + if ($matchedId !== null) { + $entry['matched_id'] = $matchedId; + } + $all[$fieldName] = $entry; + $this->field_validations = $all; + $this->save(); + } +} diff --git a/packages/e-billing/src/Moox/Plugins/EBillingPlugin.php b/packages/e-billing/src/Moox/Plugins/EBillingPlugin.php new file mode 100644 index 0000000000..4431620dab --- /dev/null +++ b/packages/e-billing/src/Moox/Plugins/EBillingPlugin.php @@ -0,0 +1,31 @@ +resources([ + InvoiceResource::class, + ]); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(self::class); + } +} diff --git a/packages/e-billing/src/Plugins/EBillingPlugin.php b/packages/e-billing/src/Plugins/EBillingPlugin.php new file mode 100644 index 0000000000..bbf0d197ee --- /dev/null +++ b/packages/e-billing/src/Plugins/EBillingPlugin.php @@ -0,0 +1,31 @@ +resources([ + InvoiceResource::class, + ]); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(self::class); + } +} diff --git a/packages/e-billing/src/Resources/InvoiceResource.php b/packages/e-billing/src/Resources/InvoiceResource.php new file mode 100644 index 0000000000..f52658e5f2 --- /dev/null +++ b/packages/e-billing/src/Resources/InvoiceResource.php @@ -0,0 +1,598 @@ +query('tab'); + $activeTabQuery = request()->query('activeTab'); + + if (in_array(SoftDeletes::class, class_uses_recursive($model), true) + && ($tab === $deletedTabKey || $activeTabQuery === $deletedTabKey)) { + $query->withoutGlobalScope(SoftDeletingScope::class); + } + + return $query; + } + + public static function enableCreate(): bool + { + return false; + } + + public static function enableEdit(): bool + { + return false; + } + + public static function enableView(): bool + { + return true; + } + + public static function canView(Model $record): bool + { + return true; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit(Model $record): bool + { + return false; + } + + public static function canDelete(Model $record): bool + { + return true; + } + + protected static function modifyEloquentQuery(Builder $query): Builder + { + if (method_exists(self::class, 'addTaxonomyRelationsToQuery')) { + $query = self::addTaxonomyRelationsToQuery($query); + } + + return $query->with([ + 'ebillingDocument', + 'ebillingDocument.kositValidations' => fn ($query) => $query->orderByDesc('validated_at')->orderByDesc('id'), + ]); + } + + public static function form(Schema $schema): Schema + { + return $schema; + } + + public static function infolist(Schema $schema): Schema + { + return $schema; + } + + public static function table(Table $table): Table + { + return $table + ->columns(self::invoiceListTableColumns()) + ->recordUrl(fn (Invoice $record): string => self::getUrl('view', ['record' => $record])) + ->defaultSort('invoice_date', 'desc') + ->filters(self::invoiceListTableFilters()) + ->recordActions(self::invoiceListTableRecordActions()) + ->toolbarActions(self::getBulkActions()); + } + + /** + * @return array + */ + private static function invoiceListTableColumns(): array + { + return [ + TextColumn::make('invoice_number') + ->label(__('e-billing::fields.invoice_number_short')) + ->searchable() + ->sortable() + ->color('primary') + ->weight('medium') + ->toggleable(), + TextColumn::make('supplier_name') + ->label(__('e-billing::fields.supplier')) + ->getStateUsing(fn (Invoice $record): ?string => $record->seller?->name) + ->placeholder('—') + ->toggleable(), + TextColumn::make('buyer_name') + ->label(__('e-billing::fields.recipient')) + ->getStateUsing(fn (Invoice $record): ?string => $record->buyer?->name) + ->placeholder('—') + ->toggleable(), + TextColumn::make('country') + ->label(InvoiceFieldLabels::label('country')) + ->getStateUsing(fn (Invoice $record): ?string => $record->buyer?->address?->country_code) + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('invoice_date') + ->label(__('e-billing::fields.invoice_date')) + ->sortable() + ->toggleable() + ->formatStateUsing(function (?string $state): string { + if ($state === null || $state === '') { + return '—'; + } + try { + return Carbon::parse($state)->format('d.m.Y'); + } catch (\Throwable) { + return $state; + } + }), + TextColumn::make('gross_total') + ->label(__('e-billing::fields.gross_total_short')) + ->sortable() + ->alignment(Alignment::End) + ->toggleable() + ->formatStateUsing(function ($state, Invoice $record): string { + $num = is_numeric($state) ? (float) $state : 0.0; + $formatted = number_format($num, 2, ',', '.'); + $currency = is_string($record->currency) && $record->currency !== '' + ? $record->currency + : 'EUR'; + $suffix = $currency === 'EUR' ? ' €' : ' '.$currency; + + return $formatted.$suffix; + }), + IconColumn::make('kosit_status') + ->label(__('e-billing::fields.kosit')) + ->getStateUsing(fn (Invoice $record): ?bool => $record->ebillingDocument?->latestKositValidation()?->passed) + ->tooltip(function (Invoice $record): string { + $passed = $record->ebillingDocument?->latestKositValidation()?->passed; + + return match ($passed) { + true => __('e-billing::fields.tooltip_kosit_passed'), + false => __('e-billing::fields.tooltip_kosit_failed'), + default => __('e-billing::fields.tooltip_not_validated_yet'), + }; + }) + ->icon(function (?bool $state): Heroicon { + return match ($state) { + true => Heroicon::OutlinedCheckCircle, + false => Heroicon::OutlinedXCircle, + default => Heroicon::OutlinedMinusCircle, + }; + }) + ->color(function (?bool $state): string { + return match ($state) { + true => 'success', + false => 'danger', + default => 'gray', + }; + }) + ->toggleable(), + IconColumn::make('validation_status') + ->label(__('e-billing::fields.validation')) + ->getStateUsing(fn (Invoice $record): string => self::validationStatusKey($record)) + ->tooltip(function (Invoice $record): string { + return self::validationStatusTooltip($record); + }) + ->icon(function (string $state): Heroicon { + return match ($state) { + 'ok' => Heroicon::OutlinedCheckCircle, + 'warn' => Heroicon::OutlinedExclamationTriangle, + default => Heroicon::OutlinedXCircle, + }; + }) + ->color(function (string $state): string { + return match ($state) { + 'ok' => 'success', + 'warn' => 'warning', + default => 'danger', + }; + }) + ->toggleable(), + ViewColumn::make('validation_score') + ->label(__('e-billing::fields.score')) + ->tooltip(function (Invoice $record): string { + $score = $record->ebillingDocument?->validation_score; + + if ($score === null) { + return __('e-billing::fields.tooltip_not_validated_yet'); + } + + return __('e-billing::fields.tooltip_validation_score', ['score' => $score]); + }) + ->view('e-billing::components.validation-score-ring') + ->getStateUsing(fn (Invoice $record): ?int => $record->ebillingDocument?->validation_score) + ->toggleable(), + TextColumn::make('review_status') + ->label(__('e-billing::fields.status')) + ->badge() + ->getStateUsing(fn (Invoice $record): InvoiceProcessingStatus => self::resolveReviewStatus($record) ?? InvoiceProcessingStatus::ParserCreated) + ->formatStateUsing(function ($state): string { + $enum = $state instanceof InvoiceProcessingStatus + ? $state + : InvoiceProcessingStatus::tryFrom((string) $state) ?? InvoiceProcessingStatus::ParserCreated; + + return self::processingStatusLabel($enum); + }) + ->color(function ($state): string { + $enum = $state instanceof InvoiceProcessingStatus + ? $state + : InvoiceProcessingStatus::tryFrom((string) $state) ?? InvoiceProcessingStatus::ParserCreated; + + return self::processingStatusColor($enum); + }) + ->toggleable(), + TextColumn::make('created_at') + ->label(__('e-billing::fields.created_at')) + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]; + } + + /** + * @return array + */ + private static function invoiceListTableFilters(): array + { + return [ + SelectFilter::make('review_status') + ->label(__('e-billing::fields.status')) + ->options([ + InvoiceProcessingStatus::ParserCreated->value => __('e-billing::fields.status_parser_created'), + InvoiceProcessingStatus::DbValidated->value => __('e-billing::fields.status_db_validated'), + InvoiceProcessingStatus::HumanConfirmed->value => __('e-billing::fields.status_human_confirmed'), + InvoiceProcessingStatus::Validated->value => __('e-billing::fields.status_validated'), + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (blank($value)) { + return $query; + } + + return $query->whereHas( + 'ebillingDocument', + fn (Builder $documentQuery): Builder => $documentQuery->where('review_status', $value), + ); + }), + TernaryFilter::make('kosit_passed') + ->label(__('e-billing::fields.kosit_status')) + ->trueLabel(__('e-billing::fields.filter_kosit_passed')) + ->falseLabel(__('e-billing::fields.filter_kosit_failed')) + ->queries( + true: fn (Builder $query): Builder => $query->whereHas( + 'ebillingDocument', + fn (Builder $documentQuery): Builder => $documentQuery->whereLatestKositValidationPassed(true), + ), + false: fn (Builder $query): Builder => $query->whereHas( + 'ebillingDocument', + fn (Builder $documentQuery): Builder => $documentQuery->whereLatestKositValidationPassed(false), + ), + blank: fn (Builder $query): Builder => $query, + ), + TernaryFilter::make('needs_review') + ->label(__('e-billing::fields.filter_needs_review')) + ->trueLabel(__('e-billing::fields.filter_yes')) + ->falseLabel(__('e-billing::fields.filter_no')) + ->queries( + true: fn (Builder $query): Builder => $query->whereHas( + 'ebillingDocument', + fn (Builder $documentQuery): Builder => $documentQuery->needsHumanReview(), + ), + false: fn (Builder $query): Builder => $query->whereDoesntHave( + 'ebillingDocument', + fn (Builder $documentQuery): Builder => $documentQuery->needsHumanReview(), + ), + blank: fn (Builder $query): Builder => $query, + ), + Filter::make('invoice_date_range') + ->label(__('e-billing::fields.invoice_date')) + ->schema([ + DatePicker::make('von')->label(__('e-billing::fields.filter_from'))->native(false), + DatePicker::make('bis')->label(__('e-billing::fields.filter_until'))->native(false), + ]) + ->query(function (Builder $query, array $data): Builder { + $von = $data['von'] ?? null; + $bis = $data['bis'] ?? null; + + if (filled($von)) { + $query->whereDate('invoice_date', '>=', $von); + } + if (filled($bis)) { + $query->whereDate('invoice_date', '<=', $bis); + } + + return $query; + }), + ]; + } + + /** + * @return array + */ + private static function invoiceListTableRecordActions(): array + { + return [ + Action::make('open_detail') + ->label(__('e-billing::fields.action_details')) + ->icon(Heroicon::OutlinedEye) + ->url(fn (Invoice $record): string => self::getUrl('view', ['record' => $record])), + Action::make('kosit_report') + ->label(__('e-billing::fields.action_kosit_report')) + ->icon(Heroicon::OutlinedDocumentMagnifyingGlass) + ->url(function (Invoice $record): ?string { + $validation = $record->ebillingDocument?->latestKositValidation(); + if ($validation === null) { + return null; + } + + $htmlPath = $validation->report_html_path; + if (! is_string($htmlPath) || $htmlPath === '') { + return null; + } + + return route('kosit-validator.report.html', ['validation' => $validation->getKey()]); + }) + ->openUrlInNewTab() + ->visible(function (Invoice $record): bool { + $htmlPath = $record->ebillingDocument?->latestKositValidation()?->report_html_path; + + return is_string($htmlPath) && $htmlPath !== ''; + }), + ...array_filter([ + self::enableHardDelete() ? self::getHardDeleteTableAction() : null, + self::enableRestore() ? self::getRestoreTableAction() : null, + ]), + ]; + } + + public static function getBulkActions(): array + { + return [ + ...(self::enableRestore() ? [self::getRestoreBulkAction()] : []), + ...(self::enableDelete() ? [self::getDeleteBulkAction()] : []), + ...(self::enableHardDelete() ? [self::getHardDeleteBulkAction()] : []), + ]; + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListInvoices::route('/'), + 'view' => ViewInvoice::route('/{record}'), + ]; + } + + public static function shouldRegisterNavigation(): bool + { + return (bool) config('e-billing.resources.invoices.enabled', true); + } + + public static function getNavigationSort(): ?int + { + $sort = config('e-billing.resources.invoices.navigation_sort'); + + return is_int($sort) ? $sort : (is_numeric($sort) ? (int) $sort : null); + } + + public static function getNavigationBadge(): ?string + { + if (! config('e-billing.resources.invoices.navigation_count_badge', false)) { + return null; + } + + return (string) self::getModel()::query()->count(); + } + + public static function getNavigationBadgeColor(): ?string + { + return 'primary'; + } + + public static function getModelLabel(): string + { + return self::resolveConfigLabel((string) config('e-billing.resources.invoices.label', 'trans//e-billing::ebilling.invoice')); + } + + public static function getPluralModelLabel(): string + { + return self::resolveConfigLabel((string) config('e-billing.resources.invoices.plural_label', 'trans//e-billing::ebilling.invoices')); + } + + public static function getNavigationLabel(): string + { + return self::getPluralModelLabel(); + } + + public static function getNavigationGroup(): ?string + { + $group = config('e-billing.resources.invoices.navigation_group'); + + return is_string($group) && $group !== '' ? self::resolveConfigLabel($group) : null; + } + + public static function getNavigationIcon(): string|\BackedEnum|null + { + $icon = config('e-billing.resources.invoices.navigation_icon'); + + return is_string($icon) && $icon !== '' ? $icon : Heroicon::OutlinedDocumentText; + } + + private static function resolveConfigLabel(string $value): string + { + if (str_starts_with($value, 'trans//')) { + return __(substr($value, 8)); + } + + return $value; + } + + private static function processingStatusLabel(InvoiceProcessingStatus $state): string + { + return match ($state) { + InvoiceProcessingStatus::ParserCreated => __('e-billing::fields.status_parser_created'), + InvoiceProcessingStatus::DbValidated => __('e-billing::fields.status_db_validated_short'), + InvoiceProcessingStatus::HumanConfirmed => __('e-billing::fields.status_human_confirmed'), + InvoiceProcessingStatus::Validated => __('e-billing::fields.status_validated'), + }; + } + + private static function processingStatusColor(InvoiceProcessingStatus $state): string + { + return match ($state) { + InvoiceProcessingStatus::ParserCreated => 'gray', + InvoiceProcessingStatus::DbValidated => 'info', + InvoiceProcessingStatus::HumanConfirmed => 'warning', + InvoiceProcessingStatus::Validated => 'success', + }; + } + + private static function resolveReviewStatus(Invoice $record): ?InvoiceProcessingStatus + { + $document = $record->ebillingDocument; + + if ($document === null) { + return null; + } + + $status = $document->review_status; + + if ($status instanceof InvoiceProcessingStatus) { + return $status; + } + + $raw = $document->getAttributes()['review_status'] ?? null; + + if (! is_string($raw) || $raw === '') { + return null; + } + + return InvoiceProcessingStatus::tryFrom($raw); + } + + private static function validationStatusKey(Invoice $record): string + { + $document = $record->ebillingDocument; + + if ($document === null) { + return 'bad'; + } + + $status = self::resolveReviewStatus($record); + + if ($status === InvoiceProcessingStatus::Validated) { + return 'ok'; + } + if ($status === InvoiceProcessingStatus::HumanConfirmed) { + return 'ok'; + } + if ($document->needsHumanReview()) { + return 'warn'; + } + if ($status === InvoiceProcessingStatus::ParserCreated && $document->isFullyValidated()) { + return 'ok'; + } + if ($status === InvoiceProcessingStatus::ParserCreated) { + return 'bad'; + } + if ($status === InvoiceProcessingStatus::DbValidated) { + return 'ok'; + } + + return 'bad'; + } + + private static function validationStatusTooltip(Invoice $record): string + { + $document = $record->ebillingDocument; + + if ($document === null) { + return __('e-billing::fields.tooltip_please_review'); + } + + $status = self::resolveReviewStatus($record); + + if ($status === InvoiceProcessingStatus::Validated) { + return __('e-billing::fields.tooltip_all_fields_valid'); + } + if ($status === InvoiceProcessingStatus::HumanConfirmed) { + return __('e-billing::fields.status_human_confirmed'); + } + if ($document->needsHumanReview()) { + return __('e-billing::fields.tooltip_manual_review_required'); + } + if ($status === InvoiceProcessingStatus::ParserCreated && $document->isFullyValidated()) { + return __('e-billing::fields.tooltip_auto_validated'); + } + if ($status === InvoiceProcessingStatus::ParserCreated) { + return __('e-billing::fields.tooltip_validation_errors_present'); + } + if ($status === InvoiceProcessingStatus::DbValidated) { + return __('e-billing::fields.tooltip_reviewed_database'); + } + + return __('e-billing::fields.tooltip_please_review'); + } +} diff --git a/packages/e-billing/src/Resources/InvoiceResource/Pages/ListInvoices.php b/packages/e-billing/src/Resources/InvoiceResource/Pages/ListInvoices.php new file mode 100644 index 0000000000..13443109a4 --- /dev/null +++ b/packages/e-billing/src/Resources/InvoiceResource/Pages/ListInvoices.php @@ -0,0 +1,67 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('e-billing.tabs.invoices', Invoice::class); // Extend Invoice in your host app if needed + } + + protected function applyConditions($query, $conditions) + { + foreach ($conditions as $condition) { + $value = $condition['value']; + + if ($value instanceof \Closure) { + $value = $value(); + } + + if ($condition['field'] === 'deleted_at' && in_array(SoftDeletes::class, class_uses_recursive($query->getModel()))) { + $query = $query->withTrashed(); + } + + if ($condition['field'] === 'review_status' && $condition['operator'] === 'in') { + $query->whereHas( + 'ebillingDocument', + fn ($documentQuery) => $documentQuery->whereIn('review_status', (array) $value), + ); + + continue; + } + + if ($condition['operator'] === 'in') { + $query->whereIn($condition['field'], (array) $value); + } elseif ($condition['operator'] === 'not_in') { + $query->whereNotIn($condition['field'], (array) $value); + } else { + $query->where($condition['field'], $condition['operator'], $value); + } + } + + return $query; + } +} diff --git a/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php b/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php new file mode 100644 index 0000000000..2ecbe2748e --- /dev/null +++ b/packages/e-billing/src/Resources/InvoiceResource/Pages/ViewInvoice.php @@ -0,0 +1,119 @@ +record = $this->resolveRecord($record); + + $this->authorizeAccess(); + } + + /** + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeFill(array $data): array + { + unset( + $data['seller'], + $data['buyer'], + $data['delivery'], + $data['payment_means'], + ); + + return $data; + } + + #[Computed] + public function invoiceViewModel(): InvoiceViewModel + { + $record = $this->getRecord(); + assert($record instanceof Invoice); + + return new InvoiceViewModel($record, $record->ebillingDocument); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $record = $this->record; + assert($record instanceof Invoice); + + $document = $record->ebillingDocument; + $vm = new InvoiceViewModel($record, $document); + $attention = $vm->attentionFieldCount(); + + return [ + Action::make('confirm') + ->label($attention > 0 + ? __('e-billing::fields.action_confirm_with_attention', ['count' => $attention]) + : __('e-billing::fields.action_confirm')) + ->icon(Heroicon::OutlinedCheckCircle) + ->color('success') + ->requiresConfirmation() + ->modalHeading(__('e-billing::fields.action_confirm_modal_heading')) + ->modalDescription(__('e-billing::fields.action_confirm_modal_description')) + ->modalSubmitActionLabel(__('e-billing::fields.action_confirm_submit')) + ->visible(fn (): bool => $record instanceof Invoice + && $document?->review_status === InvoiceProcessingStatus::DbValidated) + ->action(function () use ($record): void { + if (! $record instanceof Invoice) { + return; + } + + $confirmed = app(ConfirmInvoiceAction::class)->execute($record); + + if ($confirmed) { + Notification::make() + ->title(__('e-billing::fields.notification_confirmed_title')) + ->body(__('e-billing::fields.notification_confirmed_body')) + ->success() + ->send(); + + $record->load('ebillingDocument'); + } else { + Notification::make() + ->title(__('e-billing::fields.notification_confirm_failed_title')) + ->body(__('e-billing::fields.notification_confirm_failed_body')) + ->warning() + ->send(); + } + }), + ]; + } + + protected function resolveRecord(int|string $key): Model + { + return self::getResource()::getEloquentQuery() + ->with(['lines', 'lines.allowanceCharges', 'allowanceCharges', 'ebillingDocument']) + ->whereKey($key) + ->firstOrFail(); + } +} diff --git a/packages/e-billing/src/Services/EBilling.php b/packages/e-billing/src/Services/EBilling.php new file mode 100644 index 0000000000..bf4950d910 --- /dev/null +++ b/packages/e-billing/src/Services/EBilling.php @@ -0,0 +1,90 @@ +pdfParser->parseWithLayout($pdfPath); + $invoice = $this->parser->parse($parsed->text); + $supplier = config('e-billing.supplier'); + $invoice->supplierName = $supplier['name'] ?? ''; + $invoice->supplierVatId = $supplier['vat_id'] ?? null; + $invoice->supplierTaxNumber = $supplier['tax_number'] ?? null; + $invoice->supplierAddress = Address::fromMixedWithParty($supplier['address'] ?? null, $supplier['name'] ?? ''); + $invoice->supplierPhone = $supplier['phone'] ?? null; + $invoice->supplierEmail = $supplier['email'] ?? null; + $invoice->supplierBankAccounts = $supplier['bank_accounts'] ?? []; + + return $invoice; + } + + /** + * PDF → Text → Invoice (with supplier from config) → ZUGFeRD XML string. Does not merge into PDF. + * + * @return array{invoice: Invoice, xml: string} + */ + public function generateInvoiceAndXmlFromPdf(string $pdfPath): array + { + $invoice = $this->parseInvoiceFromPdf($pdfPath); + $xml = $this->zugferdConverter->convert($invoice); + + return [ + 'invoice' => $invoice, + 'xml' => $xml, + ]; + } + + /** + * Full pipeline: PDF → Text → Invoice → ZUGFeRD XML → PDF/A-3 with embedded XML. + * + * @return array{invoice: Invoice, xml: string, zugferd_pdf: string} + */ + public function processFile(string $pdfPath): array + { + $generated = $this->generateInvoiceAndXmlFromPdf($pdfPath); + $invoice = $generated['invoice']; + $xml = $generated['xml']; + $zugferdPdfContent = $this->zugferdConverter->mergePdfWithXml($pdfPath, $xml); + + return [ + 'invoice' => $invoice, + 'xml' => $xml, + 'zugferd_pdf' => $zugferdPdfContent, + ]; + } + + /** + * Parse text content into an Invoice. + */ + public function parseContent(string $content): Invoice + { + return $this->parser->parse($content); + } + + /** + * Convert an Invoice to ZUGFeRD XML. + */ + public function convertToXml(Invoice $invoice): string + { + return $this->zugferdConverter->convert($invoice); + } +} diff --git a/packages/e-billing/src/Services/InboxMessagePipelineFinalizer.php b/packages/e-billing/src/Services/InboxMessagePipelineFinalizer.php new file mode 100644 index 0000000000..6fc937eefe --- /dev/null +++ b/packages/e-billing/src/Services/InboxMessagePipelineFinalizer.php @@ -0,0 +1,195 @@ +with('attachments')->find($inboxMessageId); + + if ($message === null) { + return; + } + + if ($message->processing_status !== InboxMessageProcessingStatus::PartiallyFailed->value + && in_array($message->processing_status, [ + InboxMessageProcessingStatus::Processed->value, + InboxMessageProcessingStatus::Failed->value, + ], true) + ) { + return; + } + + foreach ($message->attachments as $attachment) { + if ($attachment->processing_status === InboxAttachmentProcessingStatus::New->value && ! $attachment->is_pdf) { + $attachment->markAsSkipped(); + } + } + + $message->load('attachments'); + + if ($message->processing_status === InboxMessageProcessingStatus::PartiallyFailed->value + && $message->attachments->isEmpty() + ) { + $error = $message->error_message !== null && $message->error_message !== '' + ? $message->error_message + : 'Attachment storage failed'; + $message->markAsFailed($error); + $this->moveGraphMessage($message->external_id, false, $message->id); + + return; + } + + if ($message->attachments->contains(fn (InboxAttachment $a): bool => $this->attachmentIsInFlight($a))) { + return; + } + + $hasFailed = $message->attachments->contains( + fn (InboxAttachment $a): bool => $this->attachmentHasFailure($a) + ); + + $allPdfs = $message->pdfAttachments()->get(); + + if ($message->processing_status === InboxMessageProcessingStatus::PartiallyFailed->value) { + if ($hasFailed) { + $error = $message->error_message !== null && $message->error_message !== '' + ? $message->error_message + : 'One or more attachments failed processing'; + $message->markAsFailed($error); + $this->moveGraphMessage($message->external_id, false, $message->id); + } else { + $message->error_message = null; + $message->markAsProcessed(); + $this->moveGraphMessage($message->external_id, true, $message->id); + } + + return; + } + + if ($allPdfs->isEmpty()) { + $message->markAsProcessed(); + $this->moveGraphMessage($message->external_id, true, $message->id); + + return; + } + + if ($hasFailed) { + $message->markAsFailed('One or more attachments failed processing'); + $this->moveGraphMessage($message->external_id, false, $message->id); + + return; + } + + if ($allPdfs->every(fn (InboxAttachment $a): bool => $this->pdfPipelineComplete($a))) { + $message->markAsProcessed(); + $this->moveGraphMessage($message->external_id, true, $message->id); + } + } + + private function attachmentIsInFlight(InboxAttachment $attachment): bool + { + if (in_array($attachment->processing_status, [ + InboxAttachmentProcessingStatus::New->value, + InboxAttachmentProcessingStatus::Processing->value, + ], true)) { + return true; + } + + return in_array($this->gatewayStatus($attachment), [ + EBillingAttachmentProcessingStatus::XmlGenerating, + EBillingAttachmentProcessingStatus::XmlValidated, + EBillingAttachmentProcessingStatus::ZugferdPdfGenerating, + ], true); + } + + private function attachmentHasFailure(InboxAttachment $attachment): bool + { + if ($attachment->processing_status === InboxAttachmentProcessingStatus::Failed->value) { + return true; + } + + return in_array($this->gatewayStatus($attachment), [ + EBillingAttachmentProcessingStatus::XmlGenerationFailed, + EBillingAttachmentProcessingStatus::XmlValidationFailed, + EBillingAttachmentProcessingStatus::KositError, + EBillingAttachmentProcessingStatus::ZugferdPdfFailed, + ], true); + } + + /** + * True when this PDF attachment has left the async pipeline (mail-inbox terminal on attachment). + */ + private function pdfPipelineComplete(InboxAttachment $attachment): bool + { + return in_array($attachment->processing_status, [ + InboxAttachmentProcessingStatus::Processed->value, + InboxAttachmentProcessingStatus::Skipped->value, + ], true); + } + + private function gatewayStatus(InboxAttachment $attachment): ?EBillingAttachmentProcessingStatus + { + return EbillingDocument::forSourceAttachment($attachment)?->gateway_status; + } + + private function moveGraphMessage(?string $externalId, bool $success, ?int $inboxMessageId = null): void + { + if ($externalId === null || $externalId === '') { + return; + } + + $targetFolder = $success + ? (string) config('mail-inbox.processed_folder') + : (string) config('mail-inbox.failed_folder'); + + try { + if ($success) { + $folderId = $this->graphService->getOrCreateFolder((string) config('mail-inbox.processed_folder')); + $this->graphService->markMessageAsRead($externalId); + $this->graphService->moveMessageToFolder($externalId, $folderId); + } else { + $folderId = $this->graphService->getOrCreateFolder((string) config('mail-inbox.failed_folder')); + $this->graphService->moveMessageToFolder($externalId, $folderId); + } + } catch (GraphItemNotFoundException $e) { + Log::channel('mail-inbox')->warning('Finalizer move target message not found in Graph (likely already moved or listing phantom)', [ + 'external_id' => $externalId, + 'inbox_message_id' => $inboxMessageId, + 'target_folder' => $targetFolder, + ]); + } catch (Throwable $e) { + Log::error('[EBilling] Graph folder move failed after XML pipeline finalization', [ + 'exception' => $e, + 'external_id' => $externalId, + 'success_path' => $success, + ]); + } + } +} diff --git a/packages/e-billing/src/Services/InvoiceFactory.php b/packages/e-billing/src/Services/InvoiceFactory.php new file mode 100644 index 0000000000..23db338a66 --- /dev/null +++ b/packages/e-billing/src/Services/InvoiceFactory.php @@ -0,0 +1,335 @@ +findExistingInvoiceForAttachment($attachment); + + if ($existingInvoice !== null) { + $document = EbillingDocument::query() + ->where('source_type', $attachment->getMorphClass()) + ->where('source_id', (string) $attachment->getKey()) + ->first(); + + $reviewStatus = $document?->review_status; + if ($reviewStatus === InvoiceProcessingStatus::HumanConfirmed || $reviewStatus === InvoiceProcessingStatus::Validated) { + throw new RuntimeException( + "Cannot re-create Invoice #{$existingInvoice->id} for attachment #{$attachment->id}: " + ."document review status is '{$reviewStatus->value}'. " + .'Manual intervention required.' + ); + } + + $existingInvoice->lines()->each(function (HecoInvoiceLine $line): void { + $line->allowanceCharges()->delete(); + $line->delete(); + }); + $existingInvoice->allowanceCharges()->delete(); + $existingInvoice->delete(); + } + + $draft = $this->buildDraftFromDto($dto); + + return $this->invoiceBuilder->build($draft); + }); + + event(new InvoiceCreated($invoice)); + + return $invoice; + } + + private function findExistingInvoiceForAttachment(InboxAttachment $attachment): ?HecoInvoice + { + $document = EbillingDocument::query() + ->where('source_type', $attachment->getMorphClass()) + ->where('source_id', (string) $attachment->getKey()) + ->whereNotNull('invoice_id') + ->first(); + + if ($document === null || $document->invoice_id === null) { + return null; + } + + return HecoInvoice::query()->find($document->invoice_id); + } + + private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft + { + return new InvoiceDraft( + invoice_number: $dto->invoiceNumber, + invoice_date: $dto->invoiceDate, + document_type: $this->mapDocumentType($dto->documentType), + due_date: $dto->dueDate, + currency: $dto->currency, + customer_reference: $dto->customerReference, + order_number: $dto->orderNumber, + order_date: $dto->orderDate, + pricing_basis: $dto->pricingBasis, + net_total: $dto->netTotal, + vat_rate: $dto->vatRate, + vat_amount: $dto->vatAmount, + gross_total: $dto->grossTotal, + seller: $this->mapSeller($dto), + buyer: $this->mapBuyer($dto), + delivery: $this->mapInvoiceAddress($dto->deliveryAddress), + payment_means: null, + lines: array_map( + fn (InvoiceLineDto $lineDto): InvoiceLineDraft => $this->buildLineDraftFromDto($lineDto), + $dto->lines, + ), + headerCharges: $this->buildHeaderChargeDraftsFromDto($dto), + ); + } + + /** + * @return list + */ + private function buildHeaderChargeDraftsFromDto(InvoiceDto $dto): array + { + $charges = []; + + if ($this->isNonZeroAmount($dto->discountPercent)) { + $charges[] = new ChargeDraft( + is_charge: false, + amount: $this->isNonZeroAmount($dto->discountAmount) ? $dto->discountAmount : 0, + reason_code: '95', // UNCL 5189 Discount + percentage: $dto->discountPercent, + ); + } + + if ($this->isNonZeroAmount($dto->discountAmount) && ! $this->isNonZeroAmount($dto->discountPercent)) { + $charges[] = new ChargeDraft( + is_charge: false, + amount: $dto->discountAmount, + reason_code: '95', // UNCL 5189 Discount + ); + } + + if ($this->isNonZeroAmount($dto->shippingCost)) { + $charges[] = new ChargeDraft( + is_charge: true, + amount: $dto->shippingCost, + reason_code: 'FC', // UNCL 7161 Freight service + reason_text: 'Versandkosten', + ); + } + + if ($this->isNonZeroAmount($dto->freightFlatRate)) { + $charges[] = new ChargeDraft( + is_charge: true, + amount: $dto->freightFlatRate, + reason_code: 'FC', // UNCL 7161 Freight service + reason_text: 'Frachtpauschale', + ); + } + + if ($this->isNonZeroAmount($dto->packagingCost)) { + $charges[] = new ChargeDraft( + is_charge: true, + amount: $dto->packagingCost, + reason_code: 'PC', // UNCL 7161 Packing + reason_text: 'Verpackung', + ); + } + + if ($this->isNonZeroAmount($dto->minimumQuantitySurcharge)) { + $charges[] = new ChargeDraft( + is_charge: true, + amount: $dto->minimumQuantitySurcharge, + reason_text: 'Mindermengenzuschlag', + ); + } + + return $charges; + } + + private function buildLineDraftFromDto(InvoiceLineDto $dto): InvoiceLineDraft + { + $charges = []; + + if ($this->isNonZeroAmount($dto->surchargeAmount)) { + $charges[] = new ChargeDraft( + is_charge: true, + amount: $dto->surchargeAmount, + reason_text: $dto->surchargeDescription, + ); + } + + if ($this->isNonZeroAmount($dto->materialTestCertificatePrice)) { + $charges[] = new ChargeDraft( + is_charge: true, + amount: $dto->materialTestCertificatePrice, + reason_text: 'Werkszeugnis', + ); + } + + return new InvoiceLineDraft( + position: $dto->position, + unit: $dto->unit, + quantity: $dto->quantity, + description: $dto->description, + description_detail: $dto->descriptionDetail, + article_number: $dto->articleNumber, + customs_tariff_number: $dto->customsTariffNumber, + unit_price: $dto->unitPrice, + line_total: $dto->lineTotal, + delivery_date: $dto->deliveryDate, + delivery_note_number: $dto->deliveryNoteNumber, + order_number: $dto->orderNumber, + order_date: $dto->orderDate, + delivery: $this->mapInvoiceAddress($dto->deliveryAddress), + charges: $charges, + extra: [ + 'material' => $dto->material, + 'material_test_certificate' => $dto->materialTestCertificate, + 'weight_kg_total' => $dto->weightKgTotal !== null ? (string) $dto->weightKgTotal : null, + 'weight_kg_net' => $dto->weightKgNet !== null ? (string) $dto->weightKgNet : null, + ], + ); + } + + /** + * UNTDID 1001 (BT-3): Rechnung→380, Gutschrift→381; other values default to 380 until validator slice marks review. + */ + private function mapDocumentType(string $documentType): string + { + return match (mb_strtolower(trim($documentType))) { + 'rechnung' => '380', + 'gutschrift' => '381', + default => '380', + }; + } + + private function mapSeller(InvoiceDto $dto): ?InvoiceParty + { + return $this->mapInvoiceParty( + name: $dto->supplierName, + vatId: $dto->supplierVatId, + taxNumber: $dto->supplierTaxNumber, + address: $dto->supplierAddress, + contact: $this->mapSellerContact($dto), + ); + } + + private function mapBuyer(InvoiceDto $dto): ?InvoiceParty + { + return $this->mapInvoiceParty( + name: $dto->customerName, + vatId: $dto->customerVatId, + taxNumber: null, + address: $dto->customerAddress, + contact: null, + ); + } + + private function mapSellerContact(InvoiceDto $dto): ?InvoiceContact + { + $hasAgent = $dto->agent !== null && trim($dto->agent) !== ''; + $hasPhone = $dto->supplierPhone !== null && trim($dto->supplierPhone) !== ''; + $hasEmail = $dto->supplierEmail !== null && trim($dto->supplierEmail) !== ''; + + if (! $hasAgent && ! $hasPhone && ! $hasEmail) { + return null; + } + + return new InvoiceContact( + name: $hasAgent ? trim($dto->agent) : '', + phone: $dto->supplierPhone, + email: $dto->supplierEmail, + ); + } + + private function mapInvoiceParty( + string $name, + ?string $vatId, + ?string $taxNumber, + ?Address $address, + ?InvoiceContact $contact, + ): ?InvoiceParty { + $trimmedName = trim($name); + if ($trimmedName === '') { + return null; + } + + $invoiceAddress = $this->mapInvoiceAddress($address); + if ($invoiceAddress === null) { + return null; + } + + return new InvoiceParty( + name: $trimmedName, + vat_id: $vatId, + tax_number: $taxNumber, + address: $invoiceAddress, + contact: $contact, + ); + } + + private function mapInvoiceAddress(?Address $address): ?InvoiceAddress + { + if ($address === null) { + return null; + } + + $countryCode = $address->country !== null ? strtoupper(trim($address->country)) : ''; + if ($countryCode === '') { + return null; + } + + $line1 = trim((string) ($address->street ?? '')); + if ($line1 === '' && $address->company !== null) { + $line1 = trim($address->company); + } + + $line2 = $address->addressLine2; + if ($address->addressLine3 !== null && trim($address->addressLine3) !== '') { + $line3 = trim($address->addressLine3); + $line2 = $line2 !== null && trim($line2) !== '' + ? trim($line2)."\n".$line3 + : $line3; + } + + return new InvoiceAddress( + line1: $line1, + line2: $line2 !== null && trim($line2) !== '' ? trim($line2) : null, + city: trim((string) ($address->city ?? '')), + postal_code: trim((string) ($address->zip ?? '')), + subdivision: null, + country_code: $countryCode, + ); + } + + private function isNonZeroAmount(?float $amount): bool + { + return $amount !== null && (float) $amount !== 0.0; + } +} diff --git a/packages/e-billing/src/Services/InvoiceFieldValidator.php b/packages/e-billing/src/Services/InvoiceFieldValidator.php new file mode 100644 index 0000000000..4912966ec4 --- /dev/null +++ b/packages/e-billing/src/Services/InvoiceFieldValidator.php @@ -0,0 +1,562 @@ + + */ + private const INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE = [ + 'customer_number', // matchable when company identifier field lands later + 'payment_terms', + 'shipping_method', + ]; + + /** + * Populate field_validations on the document (invoice-level + lines sub-structure), + * match buyer name to {@see Company}, and set company_id when unambiguous. + * + * Does NOT change review_status or fire events. + */ + public function fillFieldValidations(EbillingDocument $document): void + { + $this->guardAgainstRevalidation($document); + + $invoice = $this->resolveInvoice($document); + + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + + if (! is_array($invoiceFields)) { + $invoiceFields = []; + } + if (! is_array($lineFields)) { + $lineFields = []; + } + + $invoice->loadMissing(['allowanceCharges', 'lines.allowanceCharges']); + + $matchedCompany = $this->resolveCompanyMatch($invoice); + + $invoiceValidations = []; + foreach ($invoiceFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + $invoiceValidations[$field] = $this->validateInvoiceField( + $invoice, + $field, + $priority, + $matchedCompany, + ); + } + + $lineValidations = []; + foreach ($invoice->lines as $line) { + $lineValidations[(string) $line->getKey()] = $this->validateInvoiceLine($line, $lineFields); + } + + $invoiceValidations['lines'] = $lineValidations; + + $document->field_validations = $invoiceValidations; + $document->company_id = $matchedCompany?->id; + $document->validation_score = $document->calculateValidationScore(); + $document->save(); + } + + /** + * Full validation flow: populates field_validations, advances review_status, fires {@see InvoiceValidationCompleted}. + */ + public function validate(EbillingDocument $document): void + { + $this->fillFieldValidations($document); + + $invoiceFields = config('e-billing.field_validation.invoice_fields', []); + $lineFields = config('e-billing.field_validation.invoice_line_fields', []); + + if (! is_array($invoiceFields)) { + $invoiceFields = []; + } + if (! is_array($lineFields)) { + $lineFields = []; + } + + $allMustAndShouldClean = $this->allMustAndShouldFieldsAreClean($document, $invoiceFields, $lineFields); + + if ($allMustAndShouldClean) { + $document->transitionTo(InvoiceProcessingStatus::Validated); + } else { + $document->transitionTo(InvoiceProcessingStatus::DbValidated); + } + + $document->refresh(); + + event(new InvoiceValidationCompleted( + document: $document, + needsHumanReview: $document->needsHumanReview(), + )); + } + + private function guardAgainstRevalidation(EbillingDocument $document): void + { + $status = $document->review_status; + if ($status instanceof InvoiceProcessingStatus) { + $current = $status; + } else { + $raw = $document->getAttributes()['review_status'] ?? InvoiceProcessingStatus::ParserCreated->value; + $current = InvoiceProcessingStatus::from((string) $raw); + } + + if (in_array($current, [InvoiceProcessingStatus::HumanConfirmed, InvoiceProcessingStatus::Validated], true)) { + throw new RuntimeException( + "Cannot re-validate EbillingDocument #{$document->id}: review_status is '{$current->value}'. " + .'Reset to an earlier status before re-validating.' + ); + } + } + + private function resolveInvoice(EbillingDocument $document): Invoice + { + $invoice = $document->invoice; + + if (! $invoice instanceof Invoice) { + throw new RuntimeException( + "Cannot validate EbillingDocument #{$document->id}: no linked invoice." + ); + } + + return $invoice; + } + + private function resolveCompanyMatch(Invoice $invoice): ?Company + { + $name = $this->normalizeNameForCompanyMatch((string) ($invoice->buyer?->name ?? '')); + + if ($name === '') { + return null; + } + + $matches = Company::query() + ->where('company_type', 'customer') + ->where('is_active', true) + ->whereRaw('LOWER(TRIM(name)) = ?', [$name]) + ->get(); + + return $matches->count() === 1 ? $matches->first() : null; + } + + /** + * @param array $invoiceFields + * @param array $lineFields + */ + private function allMustAndShouldFieldsAreClean( + EbillingDocument $document, + array $invoiceFields, + array $lineFields, + ): bool { + $cleanStatuses = ['validated', 'db_validated', 'not_applicable']; + + $validations = is_array($document->field_validations) ? $document->field_validations : []; + + foreach ($invoiceFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $status = $this->readNestedFieldStatus($validations, $field); + if (! in_array($status, $cleanStatuses, true)) { + return false; + } + } + + $linesValidations = $validations['lines'] ?? null; + if (! is_array($linesValidations)) { + return true; + } + + foreach ($linesValidations as $lineFieldsValidations) { + if (! is_array($lineFieldsValidations)) { + continue; + } + foreach ($lineFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + if (! in_array($priority, ['must', 'should'], true)) { + continue; + } + $status = $this->readNestedFieldStatus($lineFieldsValidations, $field); + if (! in_array($status, $cleanStatuses, true)) { + return false; + } + } + } + + return true; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateInvoiceField( + Invoice $invoice, + string $field, + string $priority, + ?Company $matchedCompany, + ): array { + return match ($field) { + 'customer_number' => $this->validateCustomerNumberField($priority), + 'customer_name' => $this->validateCustomerNameField($invoice, $priority, $matchedCompany), + 'customer_vat_id' => $this->validateCustomerVatField($invoice, $priority, $matchedCompany), + 'shipping_cost', 'packaging_cost', 'minimum_quantity_surcharge', 'freight_flat_rate', + 'discount_amount', 'discount_percent' => $this->validateHeaderChargeField($invoice, $field, $priority), + default => $this->validateGenericInvoiceField($invoice, $field, $priority), + }; + } + + /** + * customer_number has no persisted source until a company identifier field exists on the invoice. + * + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateCustomerNumberField(string $priority): array + { + return $this->entryForEmptyField('customer_number', $priority, false); + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateCustomerNameField(Invoice $invoice, string $priority, ?Company $matchedCompany): array + { + $raw = $invoice->buyer?->name; + if ($this->isScalarEmpty($raw)) { + return $this->entryForEmptyField('customer_name', $priority, false); + } + + if ($matchedCompany !== null) { + if ($this->stringsLooselyMatch($raw, $matchedCompany->name)) { + return [ + 'status' => 'db_validated', + 'source' => 'auto', + 'matched_id' => $matchedCompany->id, + ]; + } + + return ['status' => 'needs_review', 'source' => 'auto', 'matched_id' => $matchedCompany->id]; + } + + return ['status' => 'parsed']; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateCustomerVatField(Invoice $invoice, string $priority, ?Company $matchedCompany): array + { + $raw = $invoice->buyer?->vat_id; + if ($this->isScalarEmpty($raw)) { + return $this->entryForEmptyField('customer_vat_id', $priority, false); + } + + if ($matchedCompany !== null) { + $expected = $matchedCompany->vat_number; + if ($this->isScalarEmpty($expected)) { + return ['status' => 'parsed']; + } + + if ($this->normalizeVat($raw) === $this->normalizeVat($expected)) { + return [ + 'status' => 'validated', + 'source' => 'auto', + 'matched_id' => $matchedCompany->id, + ]; + } + + return ['status' => 'needs_review', 'source' => 'auto', 'matched_id' => $matchedCompany->id]; + } + + return ['status' => 'parsed']; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateHeaderChargeField(Invoice $invoice, string $field, string $priority): array + { + if ($field === 'discount_percent') { + if (HeaderChargeResolver::hasDiscountPercentSignal($invoice->allowanceCharges)) { + return ['status' => 'parsed']; + } + + return $this->entryForEmptyField($field, $priority, false); + } + + if (HeaderChargeResolver::hasMatchingCharge($invoice->allowanceCharges, $field)) { + return ['status' => 'parsed']; + } + + return $this->entryForEmptyField($field, $priority, false); + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateGenericInvoiceField(Invoice $invoice, string $field, string $priority): array + { + if (in_array($field, self::INVOICE_FIELDS_WITHOUT_PERSISTED_SOURCE, true)) { + return $this->entryForEmptyField($field, $priority, false); + } + + $value = $this->getInvoiceFieldValue($invoice, $field); + + if ($this->isInvoiceFieldValueEmpty($field, $value)) { + return $this->entryForEmptyField($field, $priority, false); + } + + return ['status' => 'parsed']; + } + + private function getInvoiceFieldValue(Invoice $invoice, string $field): mixed + { + return match ($field) { + 'customer_name' => $invoice->buyer?->name, + 'customer_vat_id' => $invoice->buyer?->vat_id, + 'customer_address' => $invoice->buyer?->address, + 'country' => $invoice->buyer?->address?->country_code, + 'supplier_name' => $invoice->seller?->name, + 'supplier_vat_id' => $invoice->seller?->vat_id, + 'supplier_tax_number' => $invoice->seller?->tax_number, + 'supplier_address' => $invoice->seller?->address, + 'agent' => $invoice->seller?->contact?->name, + 'supplier_bank_accounts' => $invoice->payment_means?->bank_accounts ?? [], + 'delivery_address' => $invoice->delivery, + default => $invoice->getAttribute($field), + }; + } + + /** + * @param array $lineFields + * @return array + */ + private function validateInvoiceLine(InvoiceLine $line, array $lineFields): array + { + $line->loadMissing('allowanceCharges'); + + $validations = []; + + foreach ($lineFields as $field => $priority) { + if (! is_string($field) || ! is_string($priority)) { + continue; + } + + $validations[$field] = $this->validateInvoiceLineField($line, $field, $priority); + } + + return $validations; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateInvoiceLineField(InvoiceLine $line, string $field, string $priority): array + { + return match ($field) { + 'surcharge_amount', 'surcharge_description' => $this->validateLineSurchargeField($line, $field, $priority), + 'material_test_certificate_price' => $this->validateLineMaterialTestCertificatePriceField($line, $priority), + default => $this->validateGenericInvoiceLineField($line, $field, $priority), + }; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateLineSurchargeField(InvoiceLine $line, string $field, string $priority): array + { + if (LineAllowanceChargeResolver::hasSurchargeCharge($line->allowanceCharges)) { + return ['status' => 'parsed']; + } + + return $this->entryForEmptyField($field, $priority, true); + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateLineMaterialTestCertificatePriceField(InvoiceLine $line, string $priority): array + { + if (LineAllowanceChargeResolver::hasMaterialTestCertificateCharge($line->allowanceCharges, $line)) { + return ['status' => 'parsed']; + } + + return $this->entryForEmptyField('material_test_certificate_price', $priority, true); + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function validateGenericInvoiceLineField(InvoiceLine $line, string $field, string $priority): array + { + $value = match ($field) { + 'delivery_address' => $line->delivery, + default => $line->getAttribute($field), + }; + + if ($field === 'delivery_address') { + if ($this->isEn16931AddressEmpty($value instanceof Address ? $value : null)) { + return $this->entryForEmptyField($field, $priority, true); + } + + return ['status' => 'parsed']; + } + + if ($this->isScalarEmpty($value)) { + return $this->entryForEmptyField($field, $priority, true); + } + + return ['status' => 'parsed']; + } + + private function isInvoiceFieldValueEmpty(string $field, mixed $value): bool + { + return match ($field) { + 'customer_address', 'delivery_address', 'supplier_address' => $this->isEn16931AddressEmpty( + $value instanceof Address ? $value : null + ), + 'supplier_bank_accounts' => ! is_array($value) || $value === [], + default => $this->isScalarEmpty($value), + }; + } + + /** + * @return array{status: string, source?: string, matched_id?: string} + */ + private function entryForEmptyField(string $field, string $priority, bool $isInvoiceLine): array + { + if ($priority === 'could') { + return ['status' => 'not_applicable']; + } + + if ($priority === 'must') { + return ['status' => 'missing']; + } + + $key = $isInvoiceLine ? 'invoice_line_contextual_should' : 'invoice_contextual_should'; + $list = config("e-billing.field_validation.{$key}", []); + + if (! is_array($list)) { + return ['status' => 'not_applicable']; + } + + if (in_array($field, $list, true)) { + return ['status' => 'missing']; + } + + return ['status' => 'not_applicable']; + } + + private function isEn16931AddressEmpty(?Address $address): bool + { + if ($address === null) { + return true; + } + + foreach (['line1', 'city', 'postal_code', 'country_code'] as $key) { + $value = match ($key) { + 'line1' => $address->line1, + 'city' => $address->city, + 'postal_code' => $address->postal_code, + 'country_code' => $address->country_code, + default => '', + }; + + if (! $this->isScalarEmpty($value)) { + return false; + } + } + + return true; + } + + /** + * @param array $validations + */ + private function readNestedFieldStatus(array $validations, string $field): ?string + { + if (! isset($validations[$field]) || ! is_array($validations[$field])) { + return null; + } + + $status = $validations[$field]['status'] ?? null; + + return is_string($status) ? $status : null; + } + + private function stringsLooselyMatch(mixed $a, mixed $b): bool + { + $left = $this->normalizeString(is_string($a) ? $a : (is_scalar($a) ? (string) $a : '')); + $right = $this->normalizeString(is_string($b) ? $b : (is_scalar($b) ? (string) $b : '')); + + if ($left === '' && $right === '') { + return true; + } + + return strcasecmp($left, $right) === 0; + } + + private function normalizeNameForCompanyMatch(string $value): string + { + return mb_strtolower($this->normalizeString($value)); + } + + private function normalizeString(?string $value): string + { + if ($value === null) { + return ''; + } + + $trimmed = trim(preg_replace('/\s+/u', ' ', $value) ?? ''); + + return $trimmed; + } + + private function normalizeVat(?string $value): string + { + return strtoupper(preg_replace('/\s+/', '', (string) $value) ?? ''); + } + + private function isScalarEmpty(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (is_string($value)) { + return $this->normalizeString($value) === ''; + } + + if (is_numeric($value)) { + return false; + } + + return $value === ''; + } +} diff --git a/packages/e-billing/src/Services/ParsedInvoiceMapper.php b/packages/e-billing/src/Services/ParsedInvoiceMapper.php new file mode 100644 index 0000000000..e33269aa41 --- /dev/null +++ b/packages/e-billing/src/Services/ParsedInvoiceMapper.php @@ -0,0 +1,330 @@ +findExistingInvoiceForAttachment($attachment); + + if ($existingInvoice !== null) { + $document = EbillingDocument::query() + ->where('source_type', $attachment->getMorphClass()) + ->where('source_id', (string) $attachment->getKey()) + ->first(); + + $reviewStatus = $document?->review_status; + if ($reviewStatus === InvoiceProcessingStatus::HumanConfirmed || $reviewStatus === InvoiceProcessingStatus::Validated) { + throw new RuntimeException( + "Cannot re-create Invoice #{$existingInvoice->id} for attachment #{$attachment->id}: " + ."document review status is '{$reviewStatus->value}'. " + .'Manual intervention required.' + ); + } + + $existingInvoice->lines()->each(function (InvoiceLine $line): void { // Extend InvoiceLine in your host app if needed + $line->allowanceCharges()->delete(); + $line->delete(); + }); + $existingInvoice->allowanceCharges()->delete(); + $existingInvoice->delete(); + } + + $draft = $this->buildDraftFromDto($dto); + + return $this->invoiceBuilder->build($draft); + }); + + $this->linkDocumentToInvoice($attachment, $invoice); + + event(new InvoiceCreated($invoice)); + + return $invoice; + } + + private function linkDocumentToInvoice(InboxAttachment $attachment, Invoice $invoice): void // Extend Invoice in your host app if needed + { + EbillingDocument::query() + ->where('source_type', $attachment->getMorphClass()) + ->where('source_id', (string) $attachment->getKey()) + ->update(['invoice_id' => $invoice->id]); + } + + private function findExistingInvoiceForAttachment(InboxAttachment $attachment): ?Invoice // Extend Invoice in your host app if needed + { + $document = EbillingDocument::query() + ->where('source_type', $attachment->getMorphClass()) + ->where('source_id', (string) $attachment->getKey()) + ->whereNotNull('invoice_id') + ->first(); + + if ($document === null || $document->invoice_id === null) { + return null; + } + + return Invoice::query()->find($document->invoice_id); + } + + private function buildDraftFromDto(InvoiceDto $dto): InvoiceDraft + { + return new InvoiceDraft( + invoice_number: $dto->invoiceNumber, + invoice_date: $dto->invoiceDate, + document_type: $this->mapDocumentType($dto->documentType), + due_date: $dto->dueDate, + currency: $dto->currency, + customer_reference: $dto->customerReference, + order_number: $dto->orderNumber, + order_date: $dto->orderDate, + pricing_basis: $dto->pricingBasis, + net_total: $dto->netTotal, + vat_rate: $dto->vatRate, + vat_amount: $dto->vatAmount, + gross_total: $dto->grossTotal, + seller: $this->mapSeller($dto), + buyer: $this->mapBuyer($dto), + delivery: $this->mapEn16931Address($dto->deliveryAddress), + payment_means: $this->mapPaymentMeans($dto), + lines: array_map( + fn (InvoiceLineDto $lineDto): InvoiceLineDraft => $this->buildLineDraftFromDto($lineDto), + $dto->lines, + ), + headerCharges: $this->buildHeaderChargeDraftsFromDto($dto), + ); + } + + /** + * @return list + */ + private function buildHeaderChargeDraftsFromDto(InvoiceDto $dto): array + { + $charges = []; + + foreach ($dto->allowanceCharges as $item) { + if (! $this->isNonZeroAmount($item->amount)) { + continue; + } + + $charges[] = new ChargeDraft( + is_charge: $item->isCharge, + amount: $item->amount, + reason_code: $item->reasonCode, + reason_text: $item->reasonText, + percentage: $item->percentage, + ); + } + + return $charges; + } + + private function buildLineDraftFromDto(InvoiceLineDto $dto): InvoiceLineDraft + { + $charges = []; + + foreach ($dto->allowanceCharges as $item) { + if (! $this->isNonZeroAmount($item->amount)) { + continue; + } + + $charges[] = new ChargeDraft( + is_charge: $item->isCharge, + amount: $item->amount, + reason_code: $item->reasonCode, + reason_text: $item->reasonText, + percentage: $item->percentage, + ); + } + + return new InvoiceLineDraft( + position: $dto->position, + unit: $dto->unit, + quantity: $dto->quantity, + description: $dto->description, + description_detail: $dto->descriptionDetail, + article_number: $dto->articleNumber, + customs_tariff_number: $dto->customsTariffNumber, + unit_price: $dto->unitPrice, + line_total: $dto->lineTotal, + delivery_date: $dto->deliveryDate, + delivery_note_number: $dto->deliveryNoteNumber, + order_number: $dto->orderNumber, + order_date: $dto->orderDate, + delivery: $this->mapEn16931Address($dto->deliveryAddress), + charges: $charges, + extra: [ + 'material' => $dto->material, + 'material_test_certificate' => $dto->materialTestCertificate, + 'weight_kg_total' => $dto->weightKgTotal !== null ? (string) $dto->weightKgTotal : null, + 'weight_kg_net' => $dto->weightKgNet !== null ? (string) $dto->weightKgNet : null, + ], + ); + } + + /** + * UNTDID 1001 (BT-3): Rechnung→380, Gutschrift→381; other values default to 380 until validator slice marks review. + */ + private function mapDocumentType(string $documentType): string + { + return match (mb_strtolower(trim($documentType))) { + 'rechnung' => '380', + 'gutschrift' => '381', + default => '380', + }; + } + + private function mapSeller(InvoiceDto $dto): ?Party + { + return $this->mapEn16931Party( + name: $dto->supplierName, + vatId: $dto->supplierVatId, + taxNumber: $dto->supplierTaxNumber, + address: $dto->supplierAddress, + contact: $this->mapSellerContact($dto), + ); + } + + private function mapBuyer(InvoiceDto $dto): ?Party + { + return $this->mapEn16931Party( + name: $dto->customerName, + vatId: $dto->customerVatId, + taxNumber: null, + address: $dto->customerAddress, + contact: null, + ); + } + + private function mapPaymentMeans(InvoiceDto $dto): ?PaymentMeans + { + if ($dto->bankAccounts === []) { + return null; + } + + $bankAccounts = []; + + foreach ($dto->bankAccounts as $account) { + $bankAccounts[] = new En16931BankAccount( + iban: $account->iban, + bic: $account->bic, + bank_name: $account->bankName, + account_holder: $account->accountHolder, + ); + } + + return new PaymentMeans( + payment_means_code: '58', + bank_accounts: $bankAccounts, + ); + } + + private function mapSellerContact(InvoiceDto $dto): ?Contact + { + $hasAgent = $dto->agent !== null && trim($dto->agent) !== ''; + $hasPhone = $dto->supplierPhone !== null && trim($dto->supplierPhone) !== ''; + $hasEmail = $dto->supplierEmail !== null && trim($dto->supplierEmail) !== ''; + + if (! $hasAgent && ! $hasPhone && ! $hasEmail) { + return null; + } + + return new Contact( + name: $hasAgent ? trim($dto->agent) : '', + phone: $dto->supplierPhone, + email: $dto->supplierEmail, + ); + } + + private function mapEn16931Party( + string $name, + ?string $vatId, + ?string $taxNumber, + ?Address $address, + ?Contact $contact, + ): ?Party { + $trimmedName = trim($name); + if ($trimmedName === '') { + return null; + } + + $en16931Address = $this->mapEn16931Address($address); + if ($en16931Address === null) { + return null; + } + + return new Party( + name: $trimmedName, + vat_id: $vatId, + tax_number: $taxNumber, + address: $en16931Address, + contact: $contact, + ); + } + + private function mapEn16931Address(?Address $address): ?En16931Address + { + if ($address === null) { + return null; + } + + $countryCode = $address->country !== null ? strtoupper(trim($address->country)) : ''; + if ($countryCode === '') { + return null; + } + + $line1 = trim((string) ($address->street ?? '')); + if ($line1 === '' && $address->company !== null) { + $line1 = trim($address->company); + } + + $line2 = $address->addressLine2; + if ($address->addressLine3 !== null && trim($address->addressLine3) !== '') { + $line3 = trim($address->addressLine3); + $line2 = $line2 !== null && trim($line2) !== '' + ? trim($line2)."\n".$line3 + : $line3; + } + + return new En16931Address( + line1: $line1, + line2: $line2 !== null && trim($line2) !== '' ? trim($line2) : null, + city: trim((string) ($address->city ?? '')), + postal_code: trim((string) ($address->zip ?? '')), + subdivision: null, + country_code: $countryCode, + ); + } + + private function isNonZeroAmount(?float $amount): bool + { + return $amount !== null && (float) $amount !== 0.0; + } +} diff --git a/packages/e-billing/src/Support/BillDataAllowanceChargeMapper.php b/packages/e-billing/src/Support/BillDataAllowanceChargeMapper.php new file mode 100644 index 0000000000..e46f6703f9 --- /dev/null +++ b/packages/e-billing/src/Support/BillDataAllowanceChargeMapper.php @@ -0,0 +1,84 @@ + + */ + public static function fromHeaderScalars( + ?float $shippingCost, + ?float $packagingCost, + ?float $minimumQuantitySurcharge, + ?float $freightFlatRate, + ?float $discountAmount, + ?float $discountPercent, + ): array { + $items = []; + + if ($shippingCost !== null && $shippingCost > 0) { + $items[] = new AllowanceCharge(isCharge: true, amount: $shippingCost, reasonText: 'Versand'); + } + + if ($packagingCost !== null && $packagingCost > 0) { + $items[] = new AllowanceCharge(isCharge: true, amount: $packagingCost, reasonText: 'Verpackung'); + } + + if ($minimumQuantitySurcharge !== null && $minimumQuantitySurcharge > 0) { + $items[] = new AllowanceCharge(isCharge: true, amount: $minimumQuantitySurcharge, reasonText: 'Mindermengenzuschlag'); + } + + if ($freightFlatRate !== null && $freightFlatRate > 0) { + $items[] = new AllowanceCharge(isCharge: true, amount: $freightFlatRate, reasonText: 'Frachtkostenpauschale'); + } + + if ($discountAmount !== null && $discountAmount > 0) { + $reasonText = $discountPercent + ? sprintf('%.0f %% vom Warenwert', $discountPercent) + : 'Rabatt'; + + $items[] = new AllowanceCharge(isCharge: false, amount: $discountAmount, reasonText: $reasonText); + } + + return $items; + } + + /** + * @return list + */ + public static function fromLineScalars( + ?float $surchargeAmount, + ?string $surchargeDescription, + ?float $materialTestCertificatePrice, + ?string $materialTestCertificate, + ): array { + $items = []; + + if ($surchargeAmount !== null && $surchargeAmount > 0) { + $items[] = new AllowanceCharge( + isCharge: true, + amount: $surchargeAmount, + reasonText: $surchargeDescription ?? 'Legierungszuschlag', + ); + } + + if ($materialTestCertificatePrice !== null && $materialTestCertificatePrice > 0) { + $items[] = new AllowanceCharge( + isCharge: true, + amount: $materialTestCertificatePrice, + reasonText: $materialTestCertificate ?? 'Materialprüfzeugnis', + ); + } + + return $items; + } +} diff --git a/packages/e-billing/src/Support/EBillingArtifactNaming.php b/packages/e-billing/src/Support/EBillingArtifactNaming.php new file mode 100644 index 0000000000..1e486e356c --- /dev/null +++ b/packages/e-billing/src/Support/EBillingArtifactNaming.php @@ -0,0 +1,106 @@ +filename. + */ + public static function basenameFor(InboxAttachment $attachment): string + { + $candidate = pathinfo($attachment->filename ?? '', PATHINFO_FILENAME); + + $candidate = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $candidate); + $candidate = is_string($candidate) ? $candidate : ''; + + $candidate = preg_replace('/_+/', '_', $candidate); + $candidate = is_string($candidate) ? $candidate : ''; + $candidate = trim($candidate, '. _'); + + if ($candidate === '') { + throw new RuntimeException(sprintf( + 'Cannot derive storage basename from attachment filename: %s', + $attachment->filename ?? '(null)', + )); + } + + return $candidate; + } + + /** + * Returns a basename that does not collide with any existing .xml or .pdf + * in {$disk}/{$directory}. Synchronized across both extensions: if either + * .xml or .pdf exists for a candidate, the candidate is skipped and the + * next numeric suffix is tried. + * + * Call this exactly once per attachment, in GenerateXmlJob, when no + * existing storage path is set on the attachment yet. Subsequent jobs + * (MergeZugferdPdfJob, etc.) derive their basename from the attachment's + * already-stored xml_storage_path, not by calling this again. + */ + public static function uniqueBasenameFor( + InboxAttachment $attachment, + string $disk, + string $directory, + ): string { + $base = self::basenameFor($attachment); + $candidate = $base; + $counter = 2; + $storage = Storage::disk($disk); + + while ( + $storage->exists($directory.'/'.$candidate.'.xml') + || $storage->exists($directory.'/'.$candidate.'.pdf') + ) { + $candidate = $base.'_'.$counter; + $counter++; + } + + return $candidate; + } + + /** + * Invoice-date directory segment `{Y}/{m}/{d}` (e.g. `2026/03/15`). + * Uses invoice date when provided; falls back to `now()` when missing or unparseable. + */ + public static function invoiceDatePathSegment(DateTimeInterface|string|null $invoiceDate = null): string + { + if ($invoiceDate === null) { + return now()->format('Y/m/d'); + } + + if (is_string($invoiceDate) && trim($invoiceDate) === '') { + return now()->format('Y/m/d'); + } + + if ($invoiceDate instanceof DateTimeInterface) { + return Carbon::instance($invoiceDate)->format('Y/m/d'); + } + + try { + return Carbon::parse($invoiceDate)->format('Y/m/d'); + } catch (Throwable) { + return now()->format('Y/m/d'); + } + } +} diff --git a/packages/e-billing/src/Support/EbillingModelCasts.php b/packages/e-billing/src/Support/EbillingModelCasts.php new file mode 100644 index 0000000000..5a5cbe3649 --- /dev/null +++ b/packages/e-billing/src/Support/EbillingModelCasts.php @@ -0,0 +1,32 @@ + + */ + public static function definitions(): array + { + return [ + 'bill_data' => 'array', + 'ignored_reason' => 'array', + 'kosit_validation_id' => 'integer', + ]; + } + + public static function mergeInto(Model $model): void + { + $model->mergeCasts(self::definitions()); + } +} diff --git a/packages/e-billing/src/Support/HeaderChargeResolver.php b/packages/e-billing/src/Support/HeaderChargeResolver.php new file mode 100644 index 0000000000..89225b518b --- /dev/null +++ b/packages/e-billing/src/Support/HeaderChargeResolver.php @@ -0,0 +1,160 @@ + + */ + public const FIELD_SPECS = [ + 'shipping_cost' => ['reason_text' => 'Versand', 'is_charge' => true], + 'packaging_cost' => ['reason_text' => 'Verpackung', 'is_charge' => true], + 'minimum_quantity_surcharge' => ['reason_text' => 'Mindermengenzuschlag', 'is_charge' => true], + 'freight_flat_rate' => ['reason_text' => 'Frachtkostenpauschale', 'is_charge' => true], + 'discount_amount' => ['reason_code' => '95', 'is_charge' => false], + 'discount_percent' => ['reason_code' => '95', 'is_charge' => false], + ]; + + /** + * @return array{reason_text?: string, reason_code?: string, is_charge?: bool}|null + */ + public static function spec(string $field): ?array + { + return self::FIELD_SPECS[$field] ?? null; + } + + public static function hasMatchingCharge(iterable $allowanceCharges, string $field): bool + { + $spec = self::spec($field); + + if ($spec === null) { + return false; + } + + if ($field === 'discount_percent') { + return self::hasDiscountPercentSignal($allowanceCharges); + } + + return self::firstMatchingCharge($allowanceCharges, $spec, $field === 'discount_amount') !== null; + } + + public static function resolveAmount(iterable $allowanceCharges, string $field): ?float + { + if ($field === 'discount_percent') { + return self::resolveDiscountPercent($allowanceCharges); + } + + $spec = self::spec($field); + + if ($spec === null) { + return null; + } + + $charge = self::firstMatchingCharge($allowanceCharges, $spec, $field === 'discount_amount'); + + if ($charge === null || $charge->amount === null || $charge->amount === '') { + return null; + } + + return (float) $charge->amount; + } + + public static function hasDiscountPercentSignal(iterable $allowanceCharges): bool + { + return self::resolveDiscountPercent($allowanceCharges) !== null; + } + + public static function resolveDiscountPercent(iterable $allowanceCharges): ?float + { + foreach ($allowanceCharges as $charge) { + if (! $charge instanceof InvoiceAllowanceCharge || $charge->is_charge) { + continue; + } + + if ($charge->percentage !== null && $charge->percentage !== '' && (float) $charge->percentage > 0) { + return (float) $charge->percentage; + } + + $reasonText = (string) ($charge->reason_text ?? ''); + if (str_contains($reasonText, '%')) { + if (preg_match('/(\d+(?:[.,]\d+)?)\s*%/u', $reasonText, $matches) === 1) { + return (float) str_replace(',', '.', $matches[1]); + } + + return 0.0; + } + } + + return null; + } + + /** + * @param array{reason_text?: string, reason_code?: string, is_charge?: bool} $spec + */ + public static function matches( + InvoiceAllowanceCharge $charge, + array $spec, + bool $allowDiscountTextFallback, + ): bool { + if (isset($spec['is_charge']) && (bool) $charge->is_charge !== $spec['is_charge']) { + return false; + } + + $reasonCode = $charge->reason_code; + if (isset($spec['reason_code']) && $spec['reason_code'] !== null && $spec['reason_code'] !== '') { + if ($reasonCode !== null && $reasonCode !== '' && $reasonCode === $spec['reason_code']) { + return true; + } + } + + if (isset($spec['reason_text']) && $spec['reason_text'] !== '') { + $expected = self::normalizeString($spec['reason_text']); + $actual = self::normalizeString($charge->reason_text); + + if ($expected !== '' && strcasecmp($expected, $actual) === 0) { + return true; + } + } + + if ($allowDiscountTextFallback && ! $charge->is_charge) { + $text = self::normalizeString($charge->reason_text); + + if ($text !== '' && (strcasecmp($text, 'Rabatt') === 0 || str_contains($text, '%'))) { + return true; + } + } + + return false; + } + + /** + * @param array{reason_text?: string, reason_code?: string, is_charge?: bool} $spec + */ + private static function firstMatchingCharge( + iterable $allowanceCharges, + array $spec, + bool $allowDiscountTextFallback, + ): ?InvoiceAllowanceCharge { + foreach ($allowanceCharges as $charge) { + if ($charge instanceof InvoiceAllowanceCharge && self::matches($charge, $spec, $allowDiscountTextFallback)) { + return $charge; + } + } + + return null; + } + + private static function normalizeString(?string $value): string + { + if ($value === null) { + return ''; + } + + return trim(preg_replace('/\s+/u', ' ', $value) ?? ''); + } +} diff --git a/packages/e-billing/src/Support/InvoiceFieldLabels.php b/packages/e-billing/src/Support/InvoiceFieldLabels.php new file mode 100644 index 0000000000..f00e524134 --- /dev/null +++ b/packages/e-billing/src/Support/InvoiceFieldLabels.php @@ -0,0 +1,207 @@ + __('e-billing::fields.invoice_number'), + 'invoice_date' => __('e-billing::fields.invoice_date'), + 'document_type' => __('e-billing::fields.document_type'), + 'due_date' => __('e-billing::fields.due_date'), + 'currency' => __('e-billing::fields.currency'), + 'customer_number' => __('e-billing::fields.customer_number'), + 'customer_name' => __('e-billing::fields.customer_name'), + 'customer_address' => __('e-billing::fields.customer_address'), + 'country' => __('e-billing::fields.country'), + 'customer_vat_id' => __('e-billing::fields.customer_vat_id'), + 'customer_reference' => __('e-billing::fields.customer_reference'), + 'order_number' => __('e-billing::fields.order_number'), + 'order_date' => __('e-billing::fields.order_date'), + 'delivery_address' => __('e-billing::fields.delivery_address'), + 'supplier_name' => __('e-billing::fields.supplier_name'), + 'supplier_vat_id' => __('e-billing::fields.supplier_vat_id'), + 'supplier_tax_number' => __('e-billing::fields.tax_number'), + 'supplier_address' => __('e-billing::fields.supplier_address'), + 'supplier_number' => __('e-billing::fields.supplier_number'), + 'supplier_phone' => __('e-billing::fields.supplier_phone'), + 'supplier_email' => __('e-billing::fields.supplier_email'), + 'supplier_bank_accounts' => __('e-billing::fields.bank_accounts'), + 'agent' => __('e-billing::fields.agent'), + 'payment_terms' => __('e-billing::fields.payment_terms'), + 'pricing_basis' => __('e-billing::fields.pricing_basis'), + 'shipping_method' => __('e-billing::fields.shipping_method'), + 'net_total' => __('e-billing::fields.net_total'), + 'vat_rate' => __('e-billing::fields.vat_rate'), + 'vat_amount' => __('e-billing::fields.vat_amount'), + 'gross_total' => __('e-billing::fields.gross_total'), + 'discount_percent' => __('e-billing::fields.discount_percent'), + 'discount_amount' => __('e-billing::fields.discount_amount'), + 'shipping_cost' => __('e-billing::fields.shipping_cost'), + 'minimum_quantity_surcharge' => __('e-billing::fields.minimum_quantity_surcharge'), + 'freight_flat_rate' => __('e-billing::fields.freight_flat_rate'), + 'packaging_cost' => __('e-billing::fields.packaging_cost'), + 'notes' => __('e-billing::fields.notes'), + 'position' => __('e-billing::fields.position'), + 'description' => __('e-billing::fields.description'), + 'description_detail' => __('e-billing::fields.description_detail'), + 'quantity' => __('e-billing::fields.quantity'), + 'unit' => __('e-billing::fields.unit'), + 'unit_price' => __('e-billing::fields.unit_price'), + 'line_total' => __('e-billing::fields.line_total'), + 'article_number' => __('e-billing::fields.article_number'), + 'material' => __('e-billing::fields.material'), + 'material_number' => __('e-billing::fields.material_number'), + 'material_test_certificate' => __('e-billing::fields.material_test_certificate'), + 'material_test_certificate_price' => __('e-billing::fields.material_test_certificate_price'), + 'customs_tariff_number' => __('e-billing::fields.customs_tariff_number'), + 'weight_kg_total' => __('e-billing::fields.weight_kg_total'), + 'weight_kg_net' => __('e-billing::fields.weight_kg_net'), + 'weight' => __('e-billing::fields.weight'), + 'weight_unit' => __('e-billing::fields.weight_unit'), + 'surcharge_amount' => __('e-billing::fields.surcharge_amount'), + 'surcharge_description' => __('e-billing::fields.surcharge_description'), + 'surcharge_rate' => __('e-billing::fields.surcharge_rate'), + 'delivery_date' => __('e-billing::fields.delivery_date'), + 'delivery_note_number' => __('e-billing::fields.delivery_note_number'), + 'seller_address' => __('e-billing::fields.seller_address'), + 'seller_tax_id' => __('e-billing::fields.seller_tax_id'), + 'seller_phone' => __('e-billing::fields.seller_phone'), + 'seller_email' => __('e-billing::fields.seller_email'), + 'seller_bank_iban' => __('e-billing::fields.seller_bank_iban'), + 'seller_bank_bic' => __('e-billing::fields.seller_bank_bic'), + 'seller_bank_name' => __('e-billing::fields.seller_bank_name'), + 'buyer_address' => __('e-billing::fields.buyer_address'), + 'buyer_tax_id' => __('e-billing::fields.buyer_tax_id'), + default => Str::headline(str_replace('_', ' ', $fieldName)), + }; + } + + public static function getValidationStatusLabel(string $status): string + { + return match ($status) { + 'valid', 'validated' => __('e-billing::fields.validation_status_valid'), + 'db_validated' => __('e-billing::fields.validation_status_db_validated'), + 'parsed' => __('e-billing::fields.validation_status_parsed'), + 'not_applicable' => __('e-billing::fields.validation_status_not_applicable'), + 'needs_review' => __('e-billing::fields.validation_status_needs_review'), + 'invalid' => __('e-billing::fields.validation_status_invalid'), + 'missing' => __('e-billing::fields.validation_status_missing'), + 'unmatched' => __('e-billing::fields.validation_status_unmatched'), + default => $status, + }; + } + + public static function getValidationMessage(string $status, string $priority): string + { + return match (true) { + in_array($status, ['valid', 'validated', 'db_validated', 'parsed', 'not_applicable'], true) => __('e-billing::fields.validation_message_ok'), + $status === 'missing' && $priority === 'must' => __('e-billing::fields.validation_message_required_missing'), + $status === 'missing' && $priority === 'should' => __('e-billing::fields.validation_message_recommended_missing'), + $status === 'missing' => __('e-billing::fields.validation_message_field_missing'), + $status === 'unmatched' && $priority === 'must' => __('e-billing::fields.validation_message_required_not_in_master'), + $status === 'unmatched' => __('e-billing::fields.validation_message_not_in_master'), + $status === 'needs_review' && $priority === 'must' => __('e-billing::fields.validation_message_required_review'), + $status === 'needs_review' => __('e-billing::fields.validation_message_review_deviation'), + $status === 'invalid' && $priority === 'must' => __('e-billing::fields.validation_message_required_invalid'), + $status === 'invalid' => __('e-billing::fields.validation_message_invalid_value'), + default => __('e-billing::fields.validation_message_please_review'), + }; + } + + public static function label(string $fieldName): string + { + return self::get($fieldName); + } + + public static function btNumber(string $field, ?string $context = null): ?string + { + // NOTE: $context === 'invoice_line' is used by Filament ViewModels for line-level BT hints + if ($context === 'invoice_line' && $field === 'order_number') { + return 'BT-132'; + } + + return match ($field) { + 'invoice_number' => 'BT-1', + 'invoice_date' => 'BT-2', + 'document_type' => 'BT-3', + 'currency' => 'BT-5', + 'due_date' => 'BT-9', + 'customer_reference' => 'BT-10', + 'order_number' => 'BT-13', + 'payment_terms' => 'BT-20', + 'supplier_name' => 'BT-27', + 'supplier_vat_id' => 'BT-31', + 'supplier_tax_number' => 'BT-32', + 'supplier_address' => 'BG-5', + 'supplier_bank_accounts' => 'BG-17', + 'customer_name' => 'BT-44', + 'customer_vat_id' => 'BT-48', + 'customer_address' => 'BG-8', + // Parsed from supplier address block + 'country' => 'BT-55', + 'delivery_address' => 'BG-15', + 'net_total' => 'BT-109', + 'vat_amount' => 'BT-110', + 'gross_total' => 'BT-112', + 'minimum_quantity_surcharge' => 'BG-22 / BT-99', + 'freight_flat_rate' => 'BG-22 / BT-99', + 'vat_rate' => 'BT-119', + 'quantity' => 'BT-129', + 'unit' => 'BT-130', + 'line_total' => 'BT-131', + 'delivery_date' => 'BT-134', + 'unit_price' => 'BT-146', + 'description' => 'BT-153', + 'article_number' => 'BT-155', + 'customs_tariff_number' => 'BT-158', + 'delivery_note_number' => 'BT-16', + default => null, + }; + } + + public static function hint(string $field, string $status): ?string + { + if ($status === 'missing') { + return match ($field) { + 'invoice_number' => __('e-billing::fields.hint_missing_invoice_number'), + 'customer_number' => __('e-billing::fields.hint_missing_customer_number'), + 'customer_name' => __('e-billing::fields.hint_missing_customer_name'), + 'net_total' => __('e-billing::fields.hint_missing_net_total'), + 'vat_rate' => __('e-billing::fields.hint_missing_vat_rate'), + 'gross_total' => __('e-billing::fields.hint_missing_gross_total'), + 'invoice_date' => __('e-billing::fields.hint_missing_invoice_date'), + 'currency' => __('e-billing::fields.hint_missing_currency'), + 'supplier_name' => __('e-billing::fields.hint_missing_supplier_name'), + 'minimum_quantity_surcharge' => __('e-billing::fields.hint_missing_minimum_quantity_surcharge'), + 'freight_flat_rate' => __('e-billing::fields.hint_missing_freight_flat_rate'), + 'quantity' => __('e-billing::fields.hint_missing_quantity'), + 'description' => __('e-billing::fields.hint_missing_description'), + default => __('e-billing::fields.hint_missing_default'), + }; + } + + if ($status === 'needs_review') { + return match ($field) { + 'customer_number' => __('e-billing::fields.hint_review_customer_number'), + 'customer_name' => __('e-billing::fields.hint_review_customer_name'), + 'article_number' => __('e-billing::fields.hint_review_article_number'), + 'material' => __('e-billing::fields.hint_review_material'), + 'customer_vat_id' => __('e-billing::fields.hint_review_customer_vat_id'), + 'supplier_vat_id' => __('e-billing::fields.hint_review_supplier_vat_id'), + 'unit_price' => __('e-billing::fields.hint_review_unit_price'), + 'minimum_quantity_surcharge' => __('e-billing::fields.hint_review_minimum_quantity_surcharge'), + 'freight_flat_rate' => __('e-billing::fields.hint_review_freight_flat_rate'), + default => __('e-billing::fields.hint_review_default'), + }; + } + + return null; + } +} diff --git a/packages/e-billing/src/Support/LineAllowanceChargeResolver.php b/packages/e-billing/src/Support/LineAllowanceChargeResolver.php new file mode 100644 index 0000000000..cdca83c821 --- /dev/null +++ b/packages/e-billing/src/Support/LineAllowanceChargeResolver.php @@ -0,0 +1,112 @@ +amount === null || $charge->amount === '') { + return null; + } + + return (float) $charge->amount; + } + + public static function resolveSurchargeDescription(iterable $allowanceCharges): ?string + { + $charge = self::findSurchargeCharge($allowanceCharges); + + if ($charge === null) { + return null; + } + + $text = trim((string) ($charge->reason_text ?? '')); + + return $text === '' ? null : $text; + } + + public static function resolveMaterialTestCertificatePrice(iterable $allowanceCharges, ?InvoiceLine $line = null): ?float + { + $charge = self::findMaterialTestCertificateCharge($allowanceCharges, $line); + + if ($charge === null || $charge->amount === null || $charge->amount === '') { + return null; + } + + return (float) $charge->amount; + } + + public static function hasSurchargeCharge(iterable $allowanceCharges): bool + { + return self::findSurchargeCharge($allowanceCharges) !== null; + } + + public static function hasMaterialTestCertificateCharge(iterable $allowanceCharges, ?InvoiceLine $line = null): bool + { + return self::findMaterialTestCertificateCharge($allowanceCharges, $line) !== null; + } + + public static function findSurchargeCharge(iterable $allowanceCharges): ?InvoiceAllowanceCharge + { + foreach ($allowanceCharges as $charge) { + if (! $charge instanceof InvoiceAllowanceCharge || ! $charge->is_charge) { + continue; + } + + if (self::isMaterialTestCertificateCharge($charge)) { + continue; + } + + return $charge; + } + + return null; + } + + public static function findMaterialTestCertificateCharge( + iterable $allowanceCharges, + ?InvoiceLine $line = null, + ): ?InvoiceAllowanceCharge { + foreach ($allowanceCharges as $charge) { + if ($charge instanceof InvoiceAllowanceCharge && self::isMaterialTestCertificateCharge($charge, $line)) { + return $charge; + } + } + + return null; + } + + public static function isMaterialTestCertificateCharge( + InvoiceAllowanceCharge $charge, + ?InvoiceLine $line = null, + ): bool { + $label = self::normalizeString($charge->reason_text); + $lineModel = $line ?? ($charge->chargeable instanceof InvoiceLine ? $charge->chargeable : null); + $certificate = self::normalizeString($lineModel !== null + ? (string) ($lineModel->material_test_certificate ?? '') + : ''); + + if ($certificate !== '' && strcasecmp($label, $certificate) === 0) { + return true; + } + + return strcasecmp($label, 'Materialprüfzeugnis') === 0; + } + + private static function normalizeString(?string $value): string + { + if ($value === null) { + return ''; + } + + return trim(preg_replace('/\s+/u', ' ', $value) ?? ''); + } +} diff --git a/packages/e-billing/src/Traits/HasEbillingFields.php b/packages/e-billing/src/Traits/HasEbillingFields.php new file mode 100644 index 0000000000..c9da89199a --- /dev/null +++ b/packages/e-billing/src/Traits/HasEbillingFields.php @@ -0,0 +1,15 @@ +validation['status'] ?? null; + + return is_string($status) && $status !== '' ? $status : 'parsed'; + } + + public function badgeColor(): string + { + return match ($this->status()) { + 'validated', 'db_validated' => 'green', + 'parsed' => 'blue', + 'needs_review' => 'yellow', + 'missing' => 'red', + 'not_applicable' => 'gray', + default => 'gray', + }; + } + + public function badgeLabel(): string + { + return match ($this->status()) { + 'validated' => __('e-billing::fields.validation_badge_validated'), + 'db_validated' => __('e-billing::fields.validation_badge_db_validated'), + 'parsed' => __('e-billing::fields.validation_badge_parsed'), + 'needs_review' => __('e-billing::fields.validation_badge_needs_review'), + 'missing' => __('e-billing::fields.validation_badge_missing'), + 'not_applicable' => __('e-billing::fields.validation_badge_not_applicable'), + default => __('e-billing::fields.validation_badge_unknown'), + }; + } + + public function badgeIcon(): string + { + return match ($this->status()) { + 'validated', 'db_validated' => 'heroicon-o-check-circle', + 'parsed' => 'heroicon-o-document-text', + 'needs_review' => 'heroicon-o-exclamation-triangle', + 'missing' => 'heroicon-o-x-circle', + 'not_applicable' => 'heroicon-o-minus-circle', + default => 'heroicon-o-question-mark-circle', + }; + } +} diff --git a/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php b/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php new file mode 100644 index 0000000000..00ca5ea1c2 --- /dev/null +++ b/packages/e-billing/src/ViewModels/InvoiceLineViewModel.php @@ -0,0 +1,162 @@ + $lineValidations + */ + public function __construct( + private InvoiceLine $line, // Extend InvoiceLine in your host app if needed + private array $lineValidations = [], + ) { + $this->line->loadMissing('allowanceCharges'); + } + + public function position(): ?string + { + $p = $this->line->position; + + return is_numeric($p) ? (string) $p : (is_string($p) ? $p : null); + } + + /** + * @return list + */ + public function fields(): array + { + $names = [ + 'position', 'description', 'description_detail', + 'quantity', 'unit', 'unit_price', 'line_total', + 'article_number', 'material', 'customs_tariff_number', + 'delivery_date', 'delivery_note_number', + 'order_number', 'order_date', 'delivery_address', + 'weight_kg_total', 'weight_kg_net', + 'surcharge_amount', 'surcharge_description', + 'material_test_certificate', 'material_test_certificate_price', + ]; + + return array_map(fn (string $name): FieldViewData => $this->buildField($name), $names); + } + + /** + * @return list + */ + public function relevantFields(): array + { + return array_values(array_filter( + $this->fields(), + fn (FieldViewData $f): bool => $f->value !== null && $f->value !== '' + || in_array($f->status(), ['missing', 'needs_review'], true) + )); + } + + private function buildField(string $name): FieldViewData + { + $entry = $this->lineValidations[$name] ?? null; + $validation = is_array($entry) ? $entry : null; + $status = is_array($validation) && isset($validation['status']) && is_string($validation['status']) + ? $validation['status'] + : ''; + + return new FieldViewData( + field: $name, + label: InvoiceFieldLabels::label($name), + btNumber: InvoiceFieldLabels::btNumber($name, 'invoice_line'), + value: $this->formatValue($name), + validation: $validation, + hint: InvoiceFieldLabels::hint($name, $status), + ); + } + + private function formatValue(string $field): mixed + { + $value = $this->resolveFieldValue($field); + + if ($value === null || $value === '') { + return null; + } + + if ($field === 'delivery_address') { + return is_string($value) ? $value : null; + } + + if (in_array($field, ['unit_price', 'line_total', 'surcharge_amount', 'material_test_certificate_price'], true) && is_numeric($value)) { + return number_format((float) $value, 2, ',', '.'); + } + + if (in_array($field, ['quantity', 'weight_kg_total', 'weight_kg_net'], true) && is_numeric($value)) { + return number_format((float) $value, 3, ',', '.'); + } + + if (in_array($field, ['delivery_date', 'order_date'], true) && is_string($value) && $value !== '') { + try { + return Carbon::parse($value)->format('d.m.Y'); + } catch (\Throwable) { + return $value; + } + } + + return is_scalar($value) ? $value : null; + } + + private function resolveFieldValue(string $field): mixed + { + return match ($field) { + 'surcharge_amount' => LineAllowanceChargeResolver::resolveSurchargeAmount($this->line->allowanceCharges), + 'surcharge_description' => LineAllowanceChargeResolver::resolveSurchargeDescription($this->line->allowanceCharges), + 'material_test_certificate_price' => LineAllowanceChargeResolver::resolveMaterialTestCertificatePrice( + $this->line->allowanceCharges, + $this->line, + ), + 'delivery_address' => $this->formatEn16931Address($this->line->delivery), + default => $this->line->getAttribute($field), + }; + } + + private function formatEn16931Address(?Address $address): ?string + { + if ($address === null) { + return null; + } + + $lines = []; + + if ($address->line2 !== null && trim($address->line2) !== '') { + foreach (preg_split("/\r\n|\r|\n/", $address->line2) ?: [] as $segment) { + $segment = trim((string) $segment); + if ($segment !== '') { + $lines[] = $segment; + } + } + } + + if (trim($address->line1) !== '') { + $lines[] = trim($address->line1); + } + + $postalCity = trim($address->postal_code.' '.$address->city); + if ($postalCity !== '') { + $lines[] = $postalCity; + } + + if (trim($address->country_code) !== '') { + $lines[] = trim($address->country_code); + } + + if ($lines === []) { + return null; + } + + return implode("\n", $lines); + } +} diff --git a/packages/e-billing/src/ViewModels/InvoiceViewModel.php b/packages/e-billing/src/ViewModels/InvoiceViewModel.php new file mode 100644 index 0000000000..6175c6d65b --- /dev/null +++ b/packages/e-billing/src/ViewModels/InvoiceViewModel.php @@ -0,0 +1,377 @@ + + */ + private const FIELDS_WITHOUT_PERSISTED_SOURCE = [ + 'customer_number', + 'payment_terms', + 'shipping_method', + ]; + + public function __construct( + private Invoice $invoice, // Extend Invoice in your host app if needed + private ?EbillingDocument $document = null, + ) { + $this->invoice->loadMissing([ + 'allowanceCharges', + 'lines.allowanceCharges', + ]); + } + + /** + * @return array}> + */ + public function groupedFields(): array + { + return [ + 'document' => [ + 'title' => __('e-billing::fields.section_document_data'), + 'subtitle' => 'BG-1', + 'fields' => $this->buildFields([ + 'invoice_number', 'invoice_date', 'document_type', + 'due_date', 'currency', 'order_number', 'order_date', + 'customer_reference', 'payment_terms', + ]), + ], + 'supplier' => [ + 'title' => __('e-billing::fields.section_seller_supplier'), + 'subtitle' => 'BG-4', + 'fields' => $this->buildFields([ + 'supplier_name', 'supplier_vat_id', 'supplier_tax_number', + 'supplier_address', 'supplier_bank_accounts', + ]), + ], + 'buyer' => [ + 'title' => __('e-billing::fields.section_buyer_customer'), + 'subtitle' => 'BG-7', + 'fields' => $this->buildFields([ + 'customer_number', 'customer_name', + 'customer_vat_id', 'customer_address', + ]), + ], + 'delivery' => [ + 'title' => __('e-billing::fields.section_delivery'), + 'subtitle' => 'BG-13', + 'fields' => $this->buildFields([ + 'delivery_address', 'shipping_method', 'agent', 'pricing_basis', + ]), + ], + 'totals' => [ + 'title' => __('e-billing::fields.section_amounts'), + 'subtitle' => 'BG-22', + 'fields' => $this->buildFields([ + 'net_total', 'vat_rate', 'vat_amount', 'gross_total', + 'discount_percent', 'discount_amount', + 'shipping_cost', 'freight_flat_rate', 'packaging_cost', 'minimum_quantity_surcharge', + ]), + ], + ]; + } + + /** + * @return list + */ + public function lines(): array + { + $lineValidationsRoot = is_array($this->document?->field_validations) + ? ($this->document->field_validations['lines'] ?? null) + : null; + $lineValidationsRoot = is_array($lineValidationsRoot) ? $lineValidationsRoot : []; + + return $this->invoice->lines + ->map(function ($line) use ($lineValidationsRoot): InvoiceLineViewModel { + $lineKey = (string) $line->getKey(); + $validations = is_array($lineValidationsRoot[$lineKey] ?? null) + ? $lineValidationsRoot[$lineKey] + : []; + + return new InvoiceLineViewModel($line, $validations); + }) + ->all(); + } + + /** + * @return list + */ + public function notes(): array + { + return []; + } + + /** + * @return array{color: string, text: string} + */ + public function statusBanner(): array + { + $status = $this->document?->review_status; + if (! $status instanceof InvoiceProcessingStatus) { + $raw = $this->document?->getAttributes()['review_status'] ?? null; + $status = is_string($raw) && $raw !== '' + ? InvoiceProcessingStatus::tryFrom($raw) + : null; + } + + if ($status === null) { + return [ + 'color' => 'default', + 'text' => '', + ]; + } + + return match ($status) { + InvoiceProcessingStatus::ParserCreated => [ + 'color' => 'red', + 'text' => __('e-billing::fields.banner_incomplete'), + ], + InvoiceProcessingStatus::DbValidated => [ + 'color' => 'yellow', + 'text' => __('e-billing::fields.banner_db_validated'), + ], + InvoiceProcessingStatus::HumanConfirmed => [ + 'color' => 'blue', + 'text' => __('e-billing::fields.banner_human_confirmed'), + ], + InvoiceProcessingStatus::Validated => [ + 'color' => 'green', + 'text' => __('e-billing::fields.banner_validated'), + ], + }; + } + + public function validationScore(): ?int + { + return $this->document?->validation_score; + } + + /** + * Count of fields (invoice + line items) with status "needs review" or "missing". + */ + public function attentionFieldCount(): int + { + $n = 0; + $invoiceFv = is_array($this->document?->field_validations) ? $this->document->field_validations : []; + + foreach ($invoiceFv as $field => $entry) { + if ($field === 'lines' || ! is_array($entry)) { + continue; + } + $st = $entry['status'] ?? ''; + if (is_string($st) && in_array($st, ['needs_review', 'missing'], true)) { + $n++; + } + } + + $linesFv = $invoiceFv['lines'] ?? null; + if (is_array($linesFv)) { + foreach ($linesFv as $lineFieldValidations) { + if (! is_array($lineFieldValidations)) { + continue; + } + foreach ($lineFieldValidations as $entry) { + if (! is_array($entry)) { + continue; + } + $st = $entry['status'] ?? ''; + if (is_string($st) && in_array($st, ['needs_review', 'missing'], true)) { + $n++; + } + } + } + } + + return $n; + } + + public function formatValue(string $field): mixed + { + $value = $this->resolveFieldValue($field); + + if ($value === null || $value === '') { + return null; + } + + if (in_array($field, ['customer_address', 'supplier_address', 'delivery_address'], true)) { + return is_string($value) ? $value : null; + } + + if ($field === 'supplier_bank_accounts') { + if (! is_array($value)) { + return null; + } + + $formatted = $this->formatBankAccounts($value); + + return $formatted === [] ? null : $formatted; + } + + if (in_array($field, [ + 'net_total', 'vat_amount', 'gross_total', 'discount_amount', + 'shipping_cost', 'freight_flat_rate', 'packaging_cost', 'minimum_quantity_surcharge', + ], true) && is_numeric($value)) { + return number_format((float) $value, 2, ',', '.'); + } + + if ($field === 'vat_rate' && is_numeric($value)) { + return number_format((float) $value, 2, ',', '.').' %'; + } + + if ($field === 'discount_percent' && is_numeric($value)) { + return number_format((float) $value, 2, ',', '.').' %'; + } + + if (in_array($field, ['invoice_date', 'due_date', 'order_date'], true) && is_string($value) && $value !== '') { + try { + return Carbon::parse($value)->format('d.m.Y'); + } catch (\Throwable) { + return $value; + } + } + + return is_scalar($value) ? $value : null; + } + + private function resolveFieldValue(string $field): mixed + { + if (in_array($field, self::FIELDS_WITHOUT_PERSISTED_SOURCE, true)) { + return null; + } + + if (array_key_exists($field, HeaderChargeResolver::FIELD_SPECS)) { + return HeaderChargeResolver::resolveAmount($this->invoice->allowanceCharges, $field); + } + + return match ($field) { + 'customer_name' => $this->invoice->buyer?->name, + 'customer_vat_id' => $this->invoice->buyer?->vat_id, + 'customer_address' => $this->formatEn16931Address( + $this->invoice->buyer?->address, + $this->invoice->buyer?->name, + ), + 'country' => $this->invoice->buyer?->address?->country_code, + 'supplier_name' => $this->invoice->seller?->name, + 'supplier_vat_id' => $this->invoice->seller?->vat_id, + 'supplier_tax_number' => $this->invoice->seller?->tax_number, + 'supplier_address' => $this->formatEn16931Address( + $this->invoice->seller?->address, + $this->invoice->seller?->name, + ), + 'agent' => $this->invoice->seller?->contact?->name, + 'supplier_bank_accounts' => $this->invoice->payment_means?->bank_accounts ?? [], + 'delivery_address' => $this->formatEn16931Address($this->invoice->delivery, null), + default => $this->invoice->getAttribute($field), + }; + } + + /** + * Display order: party name (when provided), line2 segments (newline-split), line1, postal_code + city, country_code. + */ + private function formatEn16931Address(?Address $address, ?string $partyName): ?string + { + if ($address === null) { + return null; + } + + $lines = []; + + if ($partyName !== null && trim($partyName) !== '') { + $lines[] = trim($partyName); + } + + if ($address->line2 !== null && trim($address->line2) !== '') { + foreach (preg_split("/\r\n|\r|\n/", $address->line2) ?: [] as $segment) { + $segment = trim((string) $segment); + if ($segment !== '') { + $lines[] = $segment; + } + } + } + + if (trim($address->line1) !== '') { + $lines[] = trim($address->line1); + } + + $postalCity = trim($address->postal_code.' '.$address->city); + if ($postalCity !== '') { + $lines[] = $postalCity; + } + + if (trim($address->country_code) !== '') { + $lines[] = trim($address->country_code); + } + + if ($lines === []) { + return null; + } + + return implode("\n", $lines); + } + + /** + * @param list>|array> $accounts + * @return list + */ + private function formatBankAccounts(array $accounts): array + { + return array_values(array_map(static function (mixed $acc): array { + if ($acc instanceof BankAccount) { + return [ + 'iban' => $acc->iban !== '' ? $acc->iban : null, + 'bic' => $acc->bic, + 'bank_name' => $acc->bank_name, + ]; + } + + if (! is_array($acc)) { + return ['iban' => null, 'bic' => null, 'bank_name' => null]; + } + + return [ + 'iban' => isset($acc['iban']) && is_string($acc['iban']) && $acc['iban'] !== '' ? $acc['iban'] : null, + 'bic' => isset($acc['bic']) && is_string($acc['bic']) ? $acc['bic'] : null, + 'bank_name' => isset($acc['bank_name']) && is_string($acc['bank_name']) ? $acc['bank_name'] : null, + ]; + }, $accounts)); + } + + /** + * @param list $fieldNames + * @return list + */ + private function buildFields(array $fieldNames): array + { + $validations = is_array($this->document?->field_validations) ? $this->document->field_validations : []; + + return array_map(function (string $name) use ($validations): FieldViewData { + $entry = $validations[$name] ?? null; + $validation = is_array($entry) ? $entry : null; + $status = is_array($validation) && isset($validation['status']) && is_string($validation['status']) + ? $validation['status'] + : ''; + + return new FieldViewData( + field: $name, + label: InvoiceFieldLabels::label($name), + btNumber: InvoiceFieldLabels::btNumber($name), + value: $this->formatValue($name), + validation: $validation, + hint: InvoiceFieldLabels::hint($name, $status), + ); + }, $fieldNames); + } +} diff --git a/packages/invoice/.github/FUNDING.yml b/packages/invoice/.github/FUNDING.yml new file mode 100644 index 0000000000..0446fa42d2 --- /dev/null +++ b/packages/invoice/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [mooxphp] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/packages/invoice/.gitignore b/packages/invoice/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/invoice/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/invoice/CHANGELOG.md b/packages/invoice/CHANGELOG.md new file mode 100644 index 0000000000..d806cfe835 --- /dev/null +++ b/packages/invoice/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +We currently don't track changes in this package. Please refer to the [Moox Monorepo](https://github.com/mooxphp/moox) for the latest changes. diff --git a/packages/invoice/LICENSE.md b/packages/invoice/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/invoice/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/invoice/README.md b/packages/invoice/README.md new file mode 100644 index 0000000000..0862a7bd62 --- /dev/null +++ b/packages/invoice/README.md @@ -0,0 +1,285 @@ +![Moox Invoice](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox Invoice + +Structured e-invoice entity for Laravel (ZUGFeRD/XRechnung-ready), with header/line allowances and charges. + +## Features + +- EN 16931-ready structured invoice entity (header, lines, allowances/charges) +- JSON party snapshots on the invoice (`seller`, `buyer`, `delivery`, `payment_means`) cast to typed value objects +- Line-level delivery address snapshot (`delivery` JSON on `InvoiceLine`) +- `InvoiceBuilder` with readonly draft objects for transactional persistence +- Morph-linked `InvoiceAllowanceCharge` rows on invoice header and individual lines +- UUID primary keys and soft deletes on `Invoice` and `InvoiceLine` +- Host apps can swap model classes via config + +This package is a pure entity layer — no Filament UI or admin screens. Billing workflows and the Filament resource live in [`moox/e-billing`](../e-billing). + +## Requirements + +See [Requirements](https://github.com/mooxphp/moox/blob/main/docs/Requirements.md). + +## Installation + +```bash +composer require moox/invoice +php artisan moox:install +``` + +Curious what the install command does? See [Installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md). + +## Usage + +Build a draft, then persist it with `InvoiceBuilder::build()` in a single database transaction: + +```php +use Moox\Invoice\Support\ChargeDraft; +use Moox\Invoice\Support\En16931\Party; +use Moox\Invoice\Support\InvoiceBuilder; +use Moox\Invoice\Support\InvoiceDraft; +use Moox\Invoice\Support\InvoiceLineDraft; + +$seller = Party::fromArray([ + 'name' => 'Seller GmbH', + 'vat_id' => 'DE123456789', + 'address' => [ + 'line1' => 'Hauptstr. 1', + 'city' => 'Berlin', + 'postal_code' => '10115', + 'country_code' => 'DE', + ], +]); + +$draft = new InvoiceDraft( + invoice_number: 'INV-2026-001', + invoice_date: '2026-06-01', + document_type: '380', + due_date: '2026-06-15', + currency: 'EUR', + customer_reference: null, + order_number: null, + order_date: null, + pricing_basis: null, + net_total: 100.0, + vat_rate: 19.0, + vat_amount: 19.0, + gross_total: 119.0, + seller: $seller, + buyer: null, + delivery: null, + payment_means: null, + lines: [ + new InvoiceLineDraft( + position: 1, + unit: 'Stück', + quantity: 1.0, + description: 'Widget A', + description_detail: null, + article_number: 'ART-1', + customs_tariff_number: null, + unit_price: 100.0, + line_total: 100.0, + delivery_date: null, + delivery_note_number: null, + order_number: null, + order_date: null, + delivery: null, + charges: [], + ), + ], + headerCharges: [ + new ChargeDraft( + is_charge: false, + amount: 10.0, + reason_text: 'Header discount', + ), + ], +); + +$invoice = (new InvoiceBuilder)->build($draft); +``` + +`InvoiceBuilder` creates the invoice, its lines, and all header/line allowance-charge rows. If the caller already has an active database transaction, it participates in that transaction instead of opening a nested one. + +## EN 16931 value objects + +Structured party, address, contact, and payment-means data for EN 16931 (ZUGFeRD/XRechnung) lives under `Moox\Invoice\Support\En16931\*` (e.g. `Party`, `Address`, `PaymentMeans`). The sub-namespace keeps short class names while preserving the standard as context; other standards can be added alongside later (e.g. a future `Peppol\` namespace). + +All value objects are `readonly` and expose `fromArray(array $data): self` and `toArray(): array` for serialization. + +### `Address` + +```php +public function __construct( + public string $line1, + public ?string $line2, + public string $city, + public string $postal_code, + public ?string $subdivision, + public string $country_code, +) +``` + +### `Contact` + +```php +public function __construct( + public string $name, + public ?string $phone, + public ?string $email, +) +``` + +### `Party` + +```php +public function __construct( + public string $name, + public ?string $vat_id, + public ?string $tax_number, + public Address $address, + public ?Contact $contact, +) +``` + +### `BankAccount` + +```php +public function __construct( + public string $iban, + public ?string $bic, + public ?string $bank_name, + public ?string $account_holder, +) +``` + +### `PaymentMeans` + +```php +public function __construct( + public ?string $payment_means_code, + public array $bank_accounts, // list +) +``` + +Also exposes `bankAccounts(): array` returning the bank-account list. + +JSON columns on the models are cast via `PartyCast`, `AddressCast`, and `PaymentMeansCast` in `Support\En16931\Casts\`. + +## The Invoice Model + +The `Invoice` model (`Moox\Invoice\Models\Invoice`) stores the invoice header. It extends `BaseItemModel`, uses UUID primary keys (`HasUuids`), and soft deletes. + +### Attributes + +- `id` (uuid) - Primary key +- `invoice_number` (string, indexed) - Invoice identifier +- `invoice_date` (string) - Issue date +- `document_type` (string) - EN 16931 document type code (e.g. `380` for invoice) +- `due_date` (string, nullable) - Payment due date +- `currency` (string, default: `EUR`) - ISO 4217 currency code +- `customer_reference` (string, nullable) - Buyer reference +- `order_number` (string, nullable) - Associated order number +- `order_date` (string, nullable) - Associated order date +- `pricing_basis` (string, nullable) - Incoterms / pricing basis (serialized as note in e-billing / ZUGFeRD layer) +- `seller` (json, nullable) - Seller party snapshot; cast to `Party` via `PartyCast` +- `buyer` (json, nullable) - Buyer party snapshot; cast to `Party` via `PartyCast` +- `delivery` (json, nullable) - Delivery location; cast to `Address` via `AddressCast` +- `payment_means` (json, nullable) - Payment instructions; cast to `PaymentMeans` via `PaymentMeansCast` +- `net_total` (decimal 12,2, default: `0`) - Sum of line net amounts +- `vat_rate` (decimal 5,2, default: `19.00`) - VAT rate percentage +- `vat_amount` (decimal 12,2, default: `0`) - VAT amount +- `gross_total` (decimal 12,2, default: `0`) - Gross total including VAT +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp +- `deleted_at` (datetime, nullable) - Soft-delete timestamp +- `scope` (string, nullable, indexed) - Moox scope column from `BaseItemModel` + +### Relationships + +- `lines()` - `HasMany` `InvoiceLine` +- `allowanceCharges()` - `MorphMany` `InvoiceAllowanceCharge` (`chargeable`) + +## The InvoiceLine Model + +The `InvoiceLine` model (`Moox\Invoice\Models\InvoiceLine`) stores a single invoice line item. It extends `BaseItemModel`, uses UUID primary keys (`HasUuids`), and soft deletes. + +### Attributes + +- `id` (uuid) - Primary key +- `invoice_id` (foreignUuid) - Parent invoice (`cascadeOnDelete`) +- `position` (integer, default: `0`) - Line position / sequence +- `unit` (string) - Unit of measure +- `quantity` (decimal 12,3, default: `0`) - Quantity +- `description` (text, nullable) - Short line description +- `description_detail` (text, nullable) - Extended line description +- `article_number` (string, nullable) - Seller article / SKU +- `customs_tariff_number` (string, nullable) - Customs tariff number +- `unit_price` (decimal 12,2, default: `0`) - Net unit price +- `line_total` (decimal 12,2, default: `0`) - Net line total +- `delivery` (json, nullable) - Line delivery address; cast to `Address` via `AddressCast` +- `delivery_date` (string, nullable) - Delivery date +- `delivery_note_number` (string, nullable) - Delivery note reference +- `order_number` (string, nullable) - Line order number +- `order_date` (string, nullable) - Line order date +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp +- `deleted_at` (datetime, nullable) - Soft-delete timestamp +- `scope` (string, nullable, indexed) - Moox scope column from `BaseItemModel` + +### Relationships + +- `invoice()` - `BelongsTo` `Invoice` +- `allowanceCharges()` - `MorphMany` `InvoiceAllowanceCharge` (`chargeable`) + +## The InvoiceAllowanceCharge Model + +The `InvoiceAllowanceCharge` model (`Moox\Invoice\Models\InvoiceAllowanceCharge`) stores a document-level or line-level allowance or charge. It extends plain `Illuminate\Database\Eloquent\Model` with an auto-incrementing bigInteger primary key — appropriate for a child morph row rather than a top-level entity. No UUIDs, no soft deletes. + +### Attributes + +- `id` (bigInteger) - Auto-increment primary key +- `chargeable_type` (uuid morph) - Parent model class (`Invoice` or `InvoiceLine`) +- `chargeable_id` (uuid morph) - Parent model primary key +- `is_charge` (boolean) - `true` for a charge, `false` for an allowance (discount) +- `amount` (decimal 12,2) - Allowance/charge amount +- `reason_code` (string, nullable) - EN 16931 reason code +- `reason_text` (string, nullable) - Human-readable reason +- `base_amount` (decimal 12,2, nullable) - Base amount for percentage calculation +- `percentage` (decimal 5,2, nullable) - Percentage rate +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp +- `scope` (string, nullable, indexed) - Moox scope column + +### Relationships + +- `chargeable()` - `MorphTo` parent (`Invoice` or `InvoiceLine`) + +## Configuration + +Published config: `config/invoice.php`. + +| Key | Default class | +|-----|---------------| +| `models.invoice` | `Moox\Invoice\Models\Invoice` | +| `models.invoice_line` | `Moox\Invoice\Models\InvoiceLine` | +| `models.invoice_allowance_charge` | `Moox\Invoice\Models\InvoiceAllowanceCharge` | + +Override these entries to swap in custom model implementations; `InvoiceModels` and `InvoiceBuilder` resolve classes from this map. + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security + +Please review [our security policy](https://github.com/mooxphp/moox/security/policy) on how to report security vulnerabilities. + +## Credits + +Thanks to so many [people for their contributions](https://github.com/mooxphp/moox#contributors) to this package. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/invoice/SECURITY.md b/packages/invoice/SECURITY.md new file mode 100644 index 0000000000..1a6d79c863 --- /dev/null +++ b/packages/invoice/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `Moox Invoice` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use Github issues, to give us enough time to review and fix the issue, before others can use it, to do stupid things. diff --git a/packages/invoice/banner.jpg b/packages/invoice/banner.jpg new file mode 100644 index 0000000000..d889a2b55d Binary files /dev/null and b/packages/invoice/banner.jpg differ diff --git a/packages/invoice/composer.json b/packages/invoice/composer.json new file mode 100644 index 0000000000..11db25b9c6 --- /dev/null +++ b/packages/invoice/composer.json @@ -0,0 +1,64 @@ +{ + "name": "moox/invoice", + "description": "Structured e-invoice entity for Laravel (ZUGFeRD/XRechnung-ready), with header/line allowances and charges", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "Moox package", + "Laravel package" + ], + "homepage": "https://moox.org/docs/invoice", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/core": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\Invoice\\": "src", + "Moox\\Invoice\\Database\\Factories\\": "database/factories" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\Invoice\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\Invoice\\InvoiceServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main", + "pestphp/pest": "^4.7", + "pestphp/pest-plugin-livewire": "^4.0" + }, + "scripts": { + "test": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/Unit tests/Feature" + ], + "test:arch": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml --bootstrap=tests/bootstrap-arch.php tests/ArchTest.php" + ] + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/invoice/config/invoice.php b/packages/invoice/config/invoice.php new file mode 100644 index 0000000000..283fffc321 --- /dev/null +++ b/packages/invoice/config/invoice.php @@ -0,0 +1,28 @@ + [ + 'invoice' => Invoice::class, + 'invoice_line' => InvoiceLine::class, + 'invoice_allowance_charge' => InvoiceAllowanceCharge::class, + ], +]; diff --git a/packages/invoice/database/factories/InvoiceAllowanceChargeFactory.php b/packages/invoice/database/factories/InvoiceAllowanceChargeFactory.php new file mode 100644 index 0000000000..142278d7b5 --- /dev/null +++ b/packages/invoice/database/factories/InvoiceAllowanceChargeFactory.php @@ -0,0 +1,81 @@ + + */ +class InvoiceAllowanceChargeFactory extends Factory +{ + private const ALLOWANCE_REASON_CODES = ['95', '60', '100']; + + private const CHARGE_REASON_CODES = ['FC', 'PC', 'AA']; + + /** + * @return array + */ + public function definition(): array + { + $isCharge = fake()->boolean(); + + $invoiceClass = InvoiceModels::invoice(); + + return [ + 'chargeable_type' => $invoiceClass, + 'chargeable_id' => $invoiceClass::factory(), + 'is_charge' => $isCharge, + 'amount' => fake()->randomFloat(2, 1, 500), + 'reason_code' => $isCharge + ? fake()->randomElement(self::CHARGE_REASON_CODES) + : fake()->randomElement(self::ALLOWANCE_REASON_CODES), + 'reason_text' => fake()->optional()->sentence(), + 'base_amount' => fake()->optional()->randomFloat(2, 100, 5000), + 'percentage' => fake()->optional()->randomFloat(2, 1, 25), + ]; + } + + public function allowance(): static + { + return $this->state(fn (): array => [ + 'is_charge' => false, + 'reason_code' => '95', // UNCL 5189 Discount + ]); + } + + public function charge(): static + { + return $this->state(fn (): array => [ + 'is_charge' => true, + 'reason_code' => 'FC', // UNCL 7161 Freight service + ]); + } + + public function forInvoice(Invoice $invoice): static + { + return $this->state(fn (): array => [ + 'chargeable_type' => $invoice->getMorphClass(), + 'chargeable_id' => $invoice->getKey(), + ]); + } + + public function forLine(InvoiceLine $line): static + { + return $this->state(fn (): array => [ + 'chargeable_type' => $line->getMorphClass(), + 'chargeable_id' => $line->getKey(), + ]); + } + + public function modelName(): string + { + return InvoiceModels::invoiceAllowanceCharge(); + } +} diff --git a/packages/invoice/database/factories/InvoiceFactory.php b/packages/invoice/database/factories/InvoiceFactory.php new file mode 100644 index 0000000000..c75c4380aa --- /dev/null +++ b/packages/invoice/database/factories/InvoiceFactory.php @@ -0,0 +1,82 @@ + + */ +class InvoiceFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $net = fake()->randomFloat(2, 100, 10_000); + $vatRate = 19.00; + $vatAmount = round($net * ($vatRate / 100), 2); + + return [ + 'invoice_number' => fake()->unique()->numerify('INV-#####'), + 'invoice_date' => fake()->date('Y-m-d'), + 'document_type' => '380', + 'due_date' => fake()->optional()->date('Y-m-d'), + 'currency' => 'EUR', + 'customer_reference' => fake()->optional()->bothify('REF-####'), + 'order_number' => fake()->optional()->bothify('PO-####'), + 'order_date' => fake()->optional()->date('Y-m-d'), + 'pricing_basis' => null, + 'seller' => $this->sampleParty('Seller'), + 'buyer' => $this->sampleParty('Buyer'), + 'delivery' => $this->sampleAddress(), + 'net_total' => $net, + 'vat_rate' => $vatRate, + 'vat_amount' => $vatAmount, + 'gross_total' => $net + $vatAmount, + ]; + } + + /** + * @return array + */ + private function sampleParty(string $label): array + { + return [ + 'name' => fake()->company().' '.$label, + 'vat_id' => fake()->optional()->bothify('DE#########'), + 'tax_number' => fake()->optional()->numerify('########'), + 'address' => $this->sampleAddress(), + 'contact' => [ + 'name' => fake()->name(), + 'phone' => fake()->optional()->phoneNumber(), + 'email' => fake()->optional()->companyEmail(), + ], + ]; + } + + /** + * @return array + */ + private function sampleAddress(): array + { + return [ + 'line1' => fake()->streetAddress(), + 'line2' => null, + 'city' => fake()->city(), + 'postal_code' => fake()->postcode(), + 'subdivision' => null, + 'country_code' => 'DE', + ]; + } + + public function modelName(): string + { + return InvoiceModels::invoice(); + } +} diff --git a/packages/invoice/database/factories/InvoiceLineFactory.php b/packages/invoice/database/factories/InvoiceLineFactory.php new file mode 100644 index 0000000000..d223a17033 --- /dev/null +++ b/packages/invoice/database/factories/InvoiceLineFactory.php @@ -0,0 +1,48 @@ + + */ +class InvoiceLineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $quantity = fake()->randomFloat(3, 1, 10); + $unitPrice = fake()->randomFloat(2, 10, 500); + $lineTotal = round($quantity * $unitPrice, 2); + + return [ + 'invoice_id' => InvoiceModels::invoice()::factory(), + 'position' => fake()->numberBetween(1, 20), + 'unit' => 'Stück', + 'quantity' => $quantity, + 'description' => fake()->sentence(3), + 'description_detail' => fake()->optional()->paragraph(), + 'article_number' => fake()->optional()->bothify('ART-####'), + 'customs_tariff_number' => fake()->optional()->numerify('########'), + 'unit_price' => $unitPrice, + 'line_total' => $lineTotal, + 'delivery' => null, + 'delivery_date' => fake()->optional()->date('Y-m-d'), + 'delivery_note_number' => fake()->optional()->bothify('DN-####'), + 'order_number' => fake()->optional()->bothify('PO-####'), + 'order_date' => fake()->optional()->date('Y-m-d'), + ]; + } + + public function modelName(): string + { + return InvoiceModels::invoiceLine(); + } +} diff --git a/packages/invoice/database/migrations/create_invoice_allowance_charges_table.php.stub b/packages/invoice/database/migrations/create_invoice_allowance_charges_table.php.stub new file mode 100644 index 0000000000..f90df6d471 --- /dev/null +++ b/packages/invoice/database/migrations/create_invoice_allowance_charges_table.php.stub @@ -0,0 +1,31 @@ +id(); + $table->uuidMorphs('chargeable'); + $table->boolean('is_charge'); + $table->decimal('amount', 12, 2); + $table->string('reason_code')->nullable(); + $table->string('reason_text')->nullable(); + $table->decimal('base_amount', 12, 2)->nullable(); + $table->decimal('percentage', 5, 2)->nullable(); + $table->timestamps(); + $table->string('scope')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_allowance_charges'); + } +}; diff --git a/packages/invoice/database/migrations/create_invoice_lines_table.php.stub b/packages/invoice/database/migrations/create_invoice_lines_table.php.stub new file mode 100644 index 0000000000..9df63a72e9 --- /dev/null +++ b/packages/invoice/database/migrations/create_invoice_lines_table.php.stub @@ -0,0 +1,40 @@ +uuid('id')->primary(); + $table->foreignUuid('invoice_id')->constrained('invoices')->cascadeOnDelete(); + $table->integer('position')->default(0); + $table->string('unit'); + $table->decimal('quantity', 12, 3)->default(0); + $table->text('description')->nullable(); + $table->text('description_detail')->nullable(); + $table->string('article_number')->nullable(); + $table->string('customs_tariff_number')->nullable(); + $table->decimal('unit_price', 12, 2)->default(0); + $table->decimal('line_total', 12, 2)->default(0); + $table->json('delivery')->nullable(); + $table->string('delivery_date')->nullable(); + $table->string('delivery_note_number')->nullable(); + $table->string('order_number')->nullable(); + $table->string('order_date')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->string('scope')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_lines'); + } +}; diff --git a/packages/invoice/database/migrations/create_invoices_table.php.stub b/packages/invoice/database/migrations/create_invoices_table.php.stub new file mode 100644 index 0000000000..bfda88244e --- /dev/null +++ b/packages/invoice/database/migrations/create_invoices_table.php.stub @@ -0,0 +1,43 @@ +uuid('id')->primary(); + $table->string('invoice_number')->index(); + $table->string('invoice_date'); + $table->string('document_type'); + $table->string('due_date')->nullable(); + $table->string('currency')->default('EUR'); + $table->string('customer_reference')->nullable(); + $table->string('order_number')->nullable(); + $table->string('order_date')->nullable(); + // Incoterms — no native EN element; serialized as note in e-billing / ZUGFeRD layer. + $table->string('pricing_basis')->nullable(); + $table->json('seller')->nullable(); + $table->json('buyer')->nullable(); + $table->json('delivery')->nullable(); + $table->json('payment_means')->nullable(); + $table->decimal('net_total', 12, 2)->default(0); + $table->decimal('vat_rate', 5, 2)->default(19.00); + $table->decimal('vat_amount', 12, 2)->default(0); + $table->decimal('gross_total', 12, 2)->default(0); + $table->timestamps(); + $table->softDeletes(); + $table->string('scope')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + } +}; diff --git a/packages/invoice/screenshot/main.jpg b/packages/invoice/screenshot/main.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/invoice/screenshot/main.jpg differ diff --git a/packages/invoice/src/Exceptions/IncompleteInvoiceDataException.php b/packages/invoice/src/Exceptions/IncompleteInvoiceDataException.php new file mode 100644 index 0000000000..6e973e649e --- /dev/null +++ b/packages/invoice/src/Exceptions/IncompleteInvoiceDataException.php @@ -0,0 +1,9 @@ +name('invoice') + ->hasConfigFile() + ->hasViews() + ->hasTranslations() + ->hasMigrations([ + 'create_invoices_table', + 'create_invoice_lines_table', + 'create_invoice_allowance_charges_table', + ]) + ->hasCommands(); + + $this->getMooxPackage() + ->title('Moox Invoice') + ->released(true) + ->stability('dev') + ->category('development') + ->usedFor([ + 'representing structured invoices with lines, allowances and charges', + ]); + } +} diff --git a/packages/invoice/src/Models/Invoice.php b/packages/invoice/src/Models/Invoice.php new file mode 100644 index 0000000000..8f0a7f6ce9 --- /dev/null +++ b/packages/invoice/src/Models/Invoice.php @@ -0,0 +1,102 @@ + */ + use HasFactory; + + use HasUuids; + use SoftDeletes; + + protected $keyType = 'string'; + + public $incrementing = false; + + protected $fillable = [ + 'invoice_number', + 'invoice_date', + 'document_type', + 'due_date', + 'currency', + 'customer_reference', + 'order_number', + 'order_date', + 'pricing_basis', + 'seller', + 'buyer', + 'delivery', + 'payment_means', + 'net_total', + 'vat_rate', + 'vat_amount', + 'gross_total', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'seller' => PartyCast::class, + 'buyer' => PartyCast::class, + 'delivery' => AddressCast::class, + 'payment_means' => PaymentMeansCast::class, + 'net_total' => 'decimal:2', + 'vat_rate' => 'decimal:2', + 'vat_amount' => 'decimal:2', + 'gross_total' => 'decimal:2', + ]; + } + + public static function getResourceName(): string + { + return 'invoice'; + } + + public static function newFactory(): InvoiceFactory + { + return InvoiceFactory::new(); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(InvoiceModels::invoiceLine()); + } + + /** + * @return MorphMany + */ + public function allowanceCharges(): MorphMany + { + return $this->morphMany(InvoiceModels::invoiceAllowanceCharge(), 'chargeable'); + } +} diff --git a/packages/invoice/src/Models/InvoiceAllowanceCharge.php b/packages/invoice/src/Models/InvoiceAllowanceCharge.php new file mode 100644 index 0000000000..32bf9017b6 --- /dev/null +++ b/packages/invoice/src/Models/InvoiceAllowanceCharge.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + protected $fillable = [ + 'is_charge', + 'amount', + 'reason_code', + 'reason_text', + 'base_amount', + 'percentage', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_charge' => 'boolean', + 'amount' => 'decimal:2', + 'base_amount' => 'decimal:2', + 'percentage' => 'decimal:2', + ]; + } + + public static function newFactory(): InvoiceAllowanceChargeFactory + { + return InvoiceAllowanceChargeFactory::new(); + } + + /** + * @return MorphTo + */ + public function chargeable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/packages/invoice/src/Models/InvoiceLine.php b/packages/invoice/src/Models/InvoiceLine.php new file mode 100644 index 0000000000..1fcd4554b9 --- /dev/null +++ b/packages/invoice/src/Models/InvoiceLine.php @@ -0,0 +1,90 @@ + */ + use HasFactory; + + use HasUuids; + use SoftDeletes; + + protected $keyType = 'string'; + + public $incrementing = false; + + protected $fillable = [ + 'invoice_id', + 'position', + 'unit', + 'quantity', + 'description', + 'description_detail', + 'article_number', + 'customs_tariff_number', + 'unit_price', + 'line_total', + 'delivery', + 'delivery_date', + 'delivery_note_number', + 'order_number', + 'order_date', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'delivery' => AddressCast::class, + 'quantity' => 'decimal:3', + 'unit_price' => 'decimal:2', + 'line_total' => 'decimal:2', + 'position' => 'integer', + ]; + } + + public static function getResourceName(): string + { + return 'invoice_line'; + } + + public static function newFactory(): InvoiceLineFactory + { + return InvoiceLineFactory::new(); + } + + /** + * @return BelongsTo + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(InvoiceModels::invoice()); + } + + /** + * @return MorphMany + */ + public function allowanceCharges(): MorphMany + { + return $this->morphMany(InvoiceModels::invoiceAllowanceCharge(), 'chargeable'); + } +} diff --git a/packages/invoice/src/Support/ChargeDraft.php b/packages/invoice/src/Support/ChargeDraft.php new file mode 100644 index 0000000000..d4c2adaab7 --- /dev/null +++ b/packages/invoice/src/Support/ChargeDraft.php @@ -0,0 +1,46 @@ + + */ + public function toCreateAttributes(): array + { + $attributes = [ + 'is_charge' => $this->is_charge, + 'amount' => $this->amount, + ]; + + if ($this->reason_code !== null) { + $attributes['reason_code'] = $this->reason_code; + } + + if ($this->reason_text !== null) { + $attributes['reason_text'] = $this->reason_text; + } + + if ($this->base_amount !== null) { + $attributes['base_amount'] = $this->base_amount; + } + + if ($this->percentage !== null) { + $attributes['percentage'] = $this->percentage; + } + + return $attributes; + } +} diff --git a/packages/invoice/src/Support/En16931/Address.php b/packages/invoice/src/Support/En16931/Address.php new file mode 100644 index 0000000000..fe7a610eee --- /dev/null +++ b/packages/invoice/src/Support/En16931/Address.php @@ -0,0 +1,55 @@ + $data + */ + public static function fromArray(array $data): self + { + $countryCode = trim((string) ($data['country_code'] ?? '')); + + if ($countryCode === '') { + throw new IncompleteInvoiceDataException('EN 16931 address requires country_code.'); + } + + return new self( + line1: (string) ($data['line1'] ?? ''), + line2: isset($data['line2']) ? (string) $data['line2'] : null, + city: (string) ($data['city'] ?? ''), + postal_code: (string) ($data['postal_code'] ?? ''), + subdivision: isset($data['subdivision']) ? (string) $data['subdivision'] : null, + country_code: $countryCode, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'line1' => $this->line1, + 'line2' => $this->line2, + 'city' => $this->city, + 'postal_code' => $this->postal_code, + 'subdivision' => $this->subdivision, + 'country_code' => $this->country_code, + ]; + } +} diff --git a/packages/invoice/src/Support/En16931/BankAccount.php b/packages/invoice/src/Support/En16931/BankAccount.php new file mode 100644 index 0000000000..55d7c39133 --- /dev/null +++ b/packages/invoice/src/Support/En16931/BankAccount.php @@ -0,0 +1,41 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + iban: (string) ($data['iban'] ?? ''), + bic: isset($data['bic']) ? (string) $data['bic'] : null, + bank_name: isset($data['bank_name']) ? (string) $data['bank_name'] : null, + account_holder: isset($data['account_holder']) ? (string) $data['account_holder'] : null, + ); + } + + /** + * @return array{iban: string, bic: ?string, bank_name: ?string, account_holder: ?string} + */ + public function toArray(): array + { + return [ + 'iban' => $this->iban, + 'bic' => $this->bic, + 'bank_name' => $this->bank_name, + 'account_holder' => $this->account_holder, + ]; + } +} diff --git a/packages/invoice/src/Support/En16931/Casts/AddressCast.php b/packages/invoice/src/Support/En16931/Casts/AddressCast.php new file mode 100644 index 0000000000..4a8102a768 --- /dev/null +++ b/packages/invoice/src/Support/En16931/Casts/AddressCast.php @@ -0,0 +1,51 @@ +|Address|null> + */ +class AddressCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Address + { + if ($value === null) { + return null; + } + + if ($value instanceof Address) { + return $value; + } + + $decoded = is_string($value) ? json_decode($value, true) : $value; + + if (! is_array($decoded)) { + return null; + } + + return Address::fromArray($decoded); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof Address) { + return json_encode($value->toArray(), JSON_THROW_ON_ERROR); + } + + if (is_array($value)) { + return json_encode(Address::fromArray($value)->toArray(), JSON_THROW_ON_ERROR); + } + + return null; + } +} diff --git a/packages/invoice/src/Support/En16931/Casts/PartyCast.php b/packages/invoice/src/Support/En16931/Casts/PartyCast.php new file mode 100644 index 0000000000..cdb26afbb6 --- /dev/null +++ b/packages/invoice/src/Support/En16931/Casts/PartyCast.php @@ -0,0 +1,51 @@ +|Party|null> + */ +class PartyCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Party + { + if ($value === null) { + return null; + } + + if ($value instanceof Party) { + return $value; + } + + $decoded = is_string($value) ? json_decode($value, true) : $value; + + if (! is_array($decoded)) { + return null; + } + + return Party::fromArray($decoded); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof Party) { + return json_encode($value->toArray(), JSON_THROW_ON_ERROR); + } + + if (is_array($value)) { + return json_encode(Party::fromArray($value)->toArray(), JSON_THROW_ON_ERROR); + } + + return null; + } +} diff --git a/packages/invoice/src/Support/En16931/Casts/PaymentMeansCast.php b/packages/invoice/src/Support/En16931/Casts/PaymentMeansCast.php new file mode 100644 index 0000000000..aa05a8eb83 --- /dev/null +++ b/packages/invoice/src/Support/En16931/Casts/PaymentMeansCast.php @@ -0,0 +1,51 @@ +|PaymentMeans|null> + */ +class PaymentMeansCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?PaymentMeans + { + if ($value === null) { + return null; + } + + if ($value instanceof PaymentMeans) { + return $value; + } + + $decoded = is_string($value) ? json_decode($value, true) : $value; + + if (! is_array($decoded)) { + return null; + } + + return PaymentMeans::fromArray($decoded); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof PaymentMeans) { + return json_encode($value->toArray(), JSON_THROW_ON_ERROR); + } + + if (is_array($value)) { + return json_encode(PaymentMeans::fromArray($value)->toArray(), JSON_THROW_ON_ERROR); + } + + return null; + } +} diff --git a/packages/invoice/src/Support/En16931/Contact.php b/packages/invoice/src/Support/En16931/Contact.php new file mode 100644 index 0000000000..d193ab0c05 --- /dev/null +++ b/packages/invoice/src/Support/En16931/Contact.php @@ -0,0 +1,38 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + name: (string) ($data['name'] ?? ''), + phone: isset($data['phone']) ? (string) $data['phone'] : null, + email: isset($data['email']) ? (string) $data['email'] : null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'phone' => $this->phone, + 'email' => $this->email, + ]; + } +} diff --git a/packages/invoice/src/Support/En16931/Party.php b/packages/invoice/src/Support/En16931/Party.php new file mode 100644 index 0000000000..07ac568c6e --- /dev/null +++ b/packages/invoice/src/Support/En16931/Party.php @@ -0,0 +1,62 @@ + $data + */ + public static function fromArray(array $data): self + { + $name = trim((string) ($data['name'] ?? '')); + + if ($name === '') { + throw new IncompleteInvoiceDataException('EN 16931 party requires name.'); + } + + if (! isset($data['address']) || ! is_array($data['address'])) { + throw new IncompleteInvoiceDataException('EN 16931 party requires address.'); + } + + $contact = null; + + if (isset($data['contact']) && is_array($data['contact'])) { + $contact = Contact::fromArray($data['contact']); + } + + return new self( + name: $name, + vat_id: isset($data['vat_id']) ? (string) $data['vat_id'] : null, + tax_number: isset($data['tax_number']) ? (string) $data['tax_number'] : null, + address: Address::fromArray($data['address']), + contact: $contact, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'vat_id' => $this->vat_id, + 'tax_number' => $this->tax_number, + 'address' => $this->address->toArray(), + 'contact' => $this->contact?->toArray(), + ]; + } +} diff --git a/packages/invoice/src/Support/En16931/PaymentMeans.php b/packages/invoice/src/Support/En16931/PaymentMeans.php new file mode 100644 index 0000000000..0d2db34b1e --- /dev/null +++ b/packages/invoice/src/Support/En16931/PaymentMeans.php @@ -0,0 +1,61 @@ + $bank_accounts + */ + public function __construct( + public ?string $payment_means_code, + public array $bank_accounts, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $bankAccounts = []; + + foreach ($data['bank_accounts'] ?? [] as $row) { + if (is_array($row)) { + $bankAccounts[] = BankAccount::fromArray($row); + } + } + + $paymentMeansCode = isset($data['payment_means_code']) + ? (string) $data['payment_means_code'] + : null; + + return new self( + payment_means_code: $paymentMeansCode, + bank_accounts: $bankAccounts, + ); + } + + /** + * @return list + */ + public function bankAccounts(): array + { + return $this->bank_accounts; + } + + /** + * @return array{payment_means_code: ?string, bank_accounts: list} + */ + public function toArray(): array + { + return [ + 'payment_means_code' => $this->payment_means_code, + 'bank_accounts' => array_map( + fn (BankAccount $account): array => $account->toArray(), + $this->bank_accounts, + ), + ]; + } +} diff --git a/packages/invoice/src/Support/InvoiceBuilder.php b/packages/invoice/src/Support/InvoiceBuilder.php new file mode 100644 index 0000000000..e5ff6fc81e --- /dev/null +++ b/packages/invoice/src/Support/InvoiceBuilder.php @@ -0,0 +1,102 @@ + $this->persist($draft); + + if (DB::transactionLevel() > 0) { + return $persist(); + } + + return DB::transaction($persist); + } + + private function persist(InvoiceDraft $draft): Invoice + { + $invoiceClass = InvoiceModels::invoice(); + + /** @var Invoice $invoice */ + $invoice = new $invoiceClass; + $invoice->invoice_number = $draft->invoice_number; + $invoice->invoice_date = $draft->invoice_date; + $invoice->document_type = $draft->document_type; + $invoice->due_date = $draft->due_date; + $invoice->currency = $draft->currency; + $invoice->customer_reference = $draft->customer_reference; + $invoice->order_number = $draft->order_number; + $invoice->order_date = $draft->order_date; + $invoice->pricing_basis = $draft->pricing_basis; + $invoice->seller = $draft->seller; + $invoice->buyer = $draft->buyer; + $invoice->delivery = $draft->delivery; + $invoice->payment_means = $draft->payment_means; + $invoice->net_total = $draft->net_total; + $invoice->vat_rate = $draft->vat_rate; + $invoice->vat_amount = $draft->vat_amount; + $invoice->gross_total = $draft->gross_total; + $invoice->save(); + + foreach ($draft->headerCharges as $chargeDraft) { + $invoice->allowanceCharges()->create($chargeDraft->toCreateAttributes()); + } + + foreach ($draft->lines as $lineDraft) { + $this->persistLine($invoice, $lineDraft); + } + + $invoice->load(['lines.allowanceCharges', 'allowanceCharges']); + + return $invoice; + } + + private function persistLine(Invoice $invoice, InvoiceLineDraft $lineDraft): void + { + $lineClass = InvoiceModels::invoiceLine(); + + $line = new $lineClass; + $line->invoice_id = $invoice->id; + $line->position = $lineDraft->position; + $line->unit = $lineDraft->unit; + $line->quantity = (string) $lineDraft->quantity; + $line->description = $lineDraft->description !== '' && $lineDraft->description !== null + ? $lineDraft->description + : null; + $line->description_detail = $lineDraft->description_detail; + $line->article_number = $lineDraft->article_number; + $line->customs_tariff_number = $lineDraft->customs_tariff_number; + $line->unit_price = (string) $lineDraft->unit_price; + $line->line_total = (string) $lineDraft->line_total; + $line->delivery_date = $lineDraft->delivery_date; + $line->delivery_note_number = $lineDraft->delivery_note_number; + $line->order_number = $lineDraft->order_number; + $line->order_date = $lineDraft->order_date; + $line->delivery = $lineDraft->delivery; + + $fillable = $line->getFillable(); + + foreach ($lineDraft->extra as $key => $value) { + if (in_array($key, $fillable, true)) { + $line->{$key} = $value; + } + } + + $line->save(); + + foreach ($lineDraft->charges as $chargeDraft) { + $line->allowanceCharges()->create($chargeDraft->toCreateAttributes()); + } + } +} diff --git a/packages/invoice/src/Support/InvoiceDraft.php b/packages/invoice/src/Support/InvoiceDraft.php new file mode 100644 index 0000000000..5de90d7dd4 --- /dev/null +++ b/packages/invoice/src/Support/InvoiceDraft.php @@ -0,0 +1,38 @@ + $lines + * @param list $headerCharges + */ + public function __construct( + public string $invoice_number, + public string $invoice_date, + public string $document_type, + public ?string $due_date, + public string $currency, + public ?string $customer_reference, + public ?string $order_number, + public ?string $order_date, + public ?string $pricing_basis, + public float $net_total, + public float $vat_rate, + public float $vat_amount, + public float $gross_total, + public ?Party $seller, + public ?Party $buyer, + public ?Address $delivery, + public ?PaymentMeans $payment_means, + public array $lines, + public array $headerCharges, + ) {} +} diff --git a/packages/invoice/src/Support/InvoiceLineDraft.php b/packages/invoice/src/Support/InvoiceLineDraft.php new file mode 100644 index 0000000000..1753a97b8b --- /dev/null +++ b/packages/invoice/src/Support/InvoiceLineDraft.php @@ -0,0 +1,33 @@ + $charges + * @param array $extra Additional attributes applied when present on the line model fillable (e.g. supplier-specific extension columns). + */ + public function __construct( + public int $position, + public string $unit, + public float $quantity, + public ?string $description, + public ?string $description_detail, + public ?string $article_number, + public ?string $customs_tariff_number, + public float $unit_price, + public float $line_total, + public ?string $delivery_date, + public ?string $delivery_note_number, + public ?string $order_number, + public ?string $order_date, + public ?Address $delivery, + public array $charges = [], + public array $extra = [], + ) {} +} diff --git a/packages/invoice/src/Support/InvoiceModels.php b/packages/invoice/src/Support/InvoiceModels.php new file mode 100644 index 0000000000..72409e9454 --- /dev/null +++ b/packages/invoice/src/Support/InvoiceModels.php @@ -0,0 +1,61 @@ + + */ + public static function invoice(): string + { + return self::resolve('invoice.models.invoice', Invoice::class); + } + + /** + * @return class-string + */ + public static function invoiceLine(): string + { + return self::resolve('invoice.models.invoice_line', InvoiceLine::class); + } + + /** + * @return class-string + */ + public static function invoiceAllowanceCharge(): string + { + return self::resolve('invoice.models.invoice_allowance_charge', InvoiceAllowanceCharge::class); + } + + /** + * @param class-string $fallback + * @return class-string + */ + private static function resolve(string $configKey, string $fallback): string + { + $configured = function_exists('app') && app()->bound('config') + ? app('config')->get($configKey) + : null; + + $class = is_string($configured) && $configured !== '' ? $configured : $fallback; + + if (! class_exists($class)) { + throw new InvalidArgumentException("Configured class for [{$configKey}] does not exist: {$class}"); + } + + if (! is_a($class, Model::class, true)) { + throw new InvalidArgumentException("Configured class for [{$configKey}] must extend ".Model::class.": {$class}"); + } + + return $class; + } +} diff --git a/packages/kosit-validator/.gitignore b/packages/kosit-validator/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/kosit-validator/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/kosit-validator/CHANGELOG.md b/packages/kosit-validator/CHANGELOG.md new file mode 100644 index 0000000000..d806cfe835 --- /dev/null +++ b/packages/kosit-validator/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +We currently don't track changes in this package. Please refer to the [Moox Monorepo](https://github.com/mooxphp/moox) for the latest changes. diff --git a/packages/kosit-validator/LICENSE.md b/packages/kosit-validator/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/kosit-validator/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/kosit-validator/README.md b/packages/kosit-validator/README.md new file mode 100644 index 0000000000..8a202e595a --- /dev/null +++ b/packages/kosit-validator/README.md @@ -0,0 +1,320 @@ +![Moox KositValidator](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox KositValidator + +KoSIT Validator CLI wrapper for ZUGFeRD / XRechnung XML validation against EN 16931. The package runs the official KoSIT standalone JAR with XRechnung configuration, persists audit rows on `kosit_validations`, optionally links validations to domain owners via `kosit_validatables`, and provides a read-only Filament admin UI. + +## Features + + + +- KoSIT standalone JAR and XRechnung configuration install (`kosit:install`, `--force`) +- Health checks (`kosit:doctor`) and CLI validation (`kosit:validate`) +- Programmatic validation via `KositService` and structured `KositResult` (SVRL / KoSIT message parsing) +- `RecordKositValidation` action for audit persistence +- Morph pivot `kosit_validatables` (UUID owners) for optional multi-owner / history linking +- Read-only Filament resource: list tabs, ternary/filters, report iframe, downloads +- `MorphPivotRelationRegistry` integration so owner packages can attach validations from their UI +- Moox-standard layout: config, two stub migrations, `resources/lang` (EN/DE) + + + +## Responsibility Boundaries + +- `moox/kosit-validator` owns KoSIT installation, XML validation, report output paths, and validation audit persistence. +- `moox/e-billing` is the typical orchestrator: `ValidateXmlJob` calls `KositService`, records results via `RecordKositValidation`, and links them to the `EbillingDocument` through `kositValidations()->attach()` on the `kosit_validatables` morph pivot — not via a `kosit_validation_id` column on attachments or documents. +- `moox/zugferd` produces XML that this package validates; validation does not live in `moox/zugferd`. +- Owner packages (`EbillingDocument`, etc.) are external; register allowed types under `kosit-validator.relations.kosit_validatables.owner_types` to enable Filament pivot management. + +## Requirements + +| Requirement | Purpose | +|-------------|---------| +| `moox/core` | Base model, Filament resource, Moox installer, morph pivot registry | +| Java runtime | Executes the KoSIT validator JAR (`KOSIT_JAVA_BINARY`, default `java`) | + +See [Requirements](https://github.com/mooxphp/moox/blob/main/docs/Requirements.md). + +## Installation + +```bash +composer require moox/kosit-validator +php artisan moox:install +``` + +Install KoSIT artefacts (JAR + XRechnung configuration): + +```bash +php artisan kosit:install +``` + +Verify Java, JAR, scenarios, and report directory writability: + +```bash +php artisan kosit:doctor +``` + +Curious what the install command does? See [Installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md). + +Optionally publish configuration: + +```bash +php artisan vendor:publish --tag=kosit-validator-config +``` + +## Registering with Filament + +```php +use Moox\KositValidator\Plugins\KositValidatorPlugin; + +$panel->plugins([ + KositValidatorPlugin::make(), +]); +``` + +`KositValidatorPlugin` registers `KositValidationResource` (slug `kosit-validations`). Create, edit, and delete are disabled on the resource. + +## Screenshot + +![Moox KositValidator](https://github.com/mooxphp/moox/raw/main/art/screenshots/record.jpg) + +## Usage + +### Environment variables + +| Variable | Config key | Purpose | +|----------|------------|---------| +| `KOSIT_BASE_PATH` | `base_path` | Root for JAR and XRechnung config (default `storage/app/private/kosit`) | +| `KOSIT_VALIDATOR_VERSION` | `validator.version` | JAR version label for install | +| `KOSIT_VALIDATOR_URL` | `validator.download_url` | Standalone JAR download URL | +| `KOSIT_XRECHNUNG_VERSION` | `xrechnung.version` | Config bundle version | +| `KOSIT_XRECHNUNG_RELEASE_DATE` | `xrechnung.release_date` | Config bundle release date | +| `KOSIT_XRECHNUNG_URL` | `xrechnung.download_url` | XRechnung configuration zip URL | +| `KOSIT_JAVA_BINARY` | `java_binary` | Java executable (default `java`) | +| `KOSIT_OUTPUT_PATH` | `output.path` | Report output directory | +| `KOSIT_REPORT_PATH` | `output.path` (legacy) | Fallback when `KOSIT_OUTPUT_PATH` is unset | + +`KositOutputPath::resolve(?string $subdirectory)` reads `output.path`, creates the directory with mode `0775`, and optionally appends a subdirectory (e-billing uses a date segment per run). + +### CLI validation + +```bash +php artisan kosit:validate /absolute/path/to/invoice.xml +``` + +Requires a prior `kosit:install`. Always persists via `RecordKositValidation` and prints the validation ID. Exit code `0` on pass, `1` on failure or missing install/file. + +### Programmatic validation + +```php +use Moox\KositValidator\Services\KositService; +use Moox\KositValidator\Actions\RecordKositValidation; +use Moox\KositValidator\Support\KositOutputPath; + +$reportDir = KositOutputPath::resolve('2026-06-03'); +$result = app(KositService::class)->validate('/path/to/invoice.xml', $reportDir); +$validation = app(RecordKositValidation::class)($result); +``` + +`KositService::validate()` runs `{java_binary} -jar … -s scenarios.xml -r repository -o {reportDir} -h {xmlPath}` and returns `KositResult` with expected `{basename}-report.xml` / `.html` paths when files exist. + +### E-billing integration + +In `moox/e-billing`, `ValidateXmlJob`: + +1. Resolves `KositOutputPath::resolve($dateSegment)` +2. Calls `KositService::validate($xmlPath, $reportDir)` +3. Persists via `RecordKositValidation` +4. Links the validation to the `EbillingDocument` via `kositValidations()->attach()` on the `kosit_validatables` morph pivot (no `kosit_validation_id` column on inbox attachments or documents) +5. Dispatches `MergeZugferdPdfJob` on success + +`RecordKositValidation` does **not** create `kosit_validatables` rows. For morph history, configure `owner_types` and link from the owner model (`morphPivotRelation('kosit_validatables')`) or e-billing's `morph_relations.kosit_validatables`. + +## Database schema + +Two migrations are registered by `KositValidatorServiceProvider`. + +### `kosit_validations` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `input_path` | string(1024) nullable | Validated XML path | +| `passed` | boolean | `true` when KoSIT exit code is `0` | +| `errors` | json nullable | Normalized validation messages | +| `report_xml_path` | string(1024) nullable | KoSIT XML report | +| `report_html_path` | string(1024) nullable | KoSIT HTML report | +| `validated_at` | timestamp | Validation time | +| `created_at`, `updated_at` | timestamps | | +| `scope` | string nullable, indexed | Reserved; not set by package code yet | + +Indexes: `passed`, `validated_at`, `scope`. + +### `kosit_validatables` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `validatable_type`, `validatable_id` | uuid morph | Owner (UUID primary keys) | +| `kosit_validation_id` | foreignId | Cascade delete with validation | +| `created_at`, `updated_at` | timestamps | | +| `scope` | string nullable, indexed | Reserved | + +Unique: `(validatable_type, validatable_id, kosit_validation_id)`. + +## The KositValidation Model + +Class: `Moox\KositValidator\Models\KositValidation` +Extends `Moox\Core\Entities\Items\Item\BaseItemModel`. + +**Fillable:** `input_path`, `report_xml_path`, `report_html_path`, `passed`, `errors`, `validated_at` +**Casts:** `errors` → array, `passed` → boolean, `validated_at` → datetime + +| Method | Description | +|--------|-------------| +| `getResourceName()` | Returns `kosit-validation` | +| `kositValidatables()` | `HasMany` pivot rows | +| `scopePassed()` / `scopeFailed()` | Filter by `passed` | +| `filenameLabel()` | `basename(input_path)` or translated empty label | +| `reportHtmlPath()` | Returns `report_html_path` | + +`RecordKositValidation` stores `errors` as `KositResult::validationMessages()` (structured `{type, text, location, rule}`), not only error-severity strings. + +## The KositValidatable Pivot + +Class: `Moox\KositValidator\Models\KositValidatable` extends `MorphPivot`. + +| Field | Description | +|-------|-------------| +| `validatable_type` / `validatable_id` | Morph owner (UUID) | +| `kosit_validation_id` | References `kosit_validations.id` | +| `scope` | Reserved; not set by package code yet | + +`KositRelationConfig` reads `config('kosit-validator.relations.kosit_validatables')` for Filament and integrators. + +## Public API + +| Kind | FQCN | +|------|------| +| Service | `Moox\KositValidator\Services\KositService` | +| Result DTO | `Moox\KositValidator\DTOs\KositResult` | +| Record action | `Moox\KositValidator\Actions\RecordKositValidation` | +| Output path | `Moox\KositValidator\Support\KositOutputPath` | +| Message helpers | `Moox\KositValidator\Support\KositValidationMessages` | +| Relation config | `Moox\KositValidator\Support\KositRelationConfig` | +| HTTP controller | `Moox\KositValidator\Http\Controllers\KositReportController` | +| Filament plugin | `Moox\KositValidator\Plugins\KositValidatorPlugin` | + +### `KositService` + +| Method | Description | +|--------|-------------| +| `validate(string $xmlPath, ?string $reportDirectory = null): KositResult` | Runs KoSIT; default report dir from `KositOutputPath::resolve()` | +| `jarPath()` / `scenariosPath()` / `repositoryPath()` | Resolve installed artefacts | +| `isInstalled()` / `javaAvailable()` | Preconditions for CLI and jobs | + +### `KositResult` + +| Method | Description | +|--------|-------------| +| `passed()` / `failed()` | Exit code `=== 0` | +| `validationMessages()` | Parsed report XML (KoSIT + SVRL) or stderr fallback | +| `errors()` | Flat error texts where `type === 'error'` | + +### Artisan commands + +| Command | Options | Description | +|---------|---------|-------------| +| `kosit:install` | `--force` | Download and extract JAR + XRechnung config | +| `kosit:validate` | `{path}` required | Validate XML, persist audit row | +| `kosit:doctor` | — | Check Java, JAR, scenarios, writable output path | + +## Filament admin + +Resource: `Moox\KositValidator\Resources\KositValidationResource` + +| Page | Route | Description | +|------|-------|-------------| +| List | `/` | Table with tabs and filters | +| View | `/{record}` | Summary, messages partial, report iframe | + +**List columns (order):** result badge, filename, error/warning/info count badges, `validated_at` (default sort desc). + +**Tabs:** All, Passed, Failed, With Warnings, With Infos (`__has_message_type` synthetic filter via `KositValidationMessages`). + +**Filters:** Ternary `passed`; `validated_at` date range. + +**View header actions:** Download input XML, report HTML, report XML (hidden when path missing). + +**Relation manager:** `KositValidatablesRelationManager` on the view page. Create is visible only when `owner_types` is non-empty; supports `MorphToSelect` for configured owners. + +**Navigation:** `Heroicon::OutlinedShieldCheck`, sort `21`, group from `navigation_group`. + +### Web routes + +Middleware: `web`, Filament `Authenticate`. Prefix: `admin/kosit-validations`. Name prefix: `kosit-validator.` + +| Route name | Path | Action | +|------------|------|--------| +| `kosit-validator.report.html` | `{validation}/report-html` | Inline HTML iframe | +| `kosit-validator.download.input-file` | `{validation}/download/input` | Source XML attachment | +| `kosit-validator.download.report-html` | `{validation}/download/report-html` | HTML report | +| `kosit-validator.download.report-xml` | `{validation}/download/report` | XML report | + +## Configuration + +File: `config/kosit-validator.php` + +| Key | Description | +|-----|-------------| +| `navigation_group` | Filament navigation group | +| `base_path` | KoSIT artefacts root | +| `paths.validator_dir` / `paths.xrechnung_dir` | Subdirectories under `base_path` | +| `validator.*` / `xrechnung.*` | Download versions and URLs for `kosit:install` | +| `java_binary` | Java executable | +| `output.path` | KoSIT `-o` report directory | +| `resources.kosit-validation` | Filament `single`, `plural`, `tabs` | +| `relations.kosit_validatables` | Pivot registry: `pivot_model`, `owner_types`, etc. | + +## Running tests + +From the package directory: + +```bash +composer test +composer test:arch +``` + +Or from the monorepo root: + +```bash +php vendor/bin/pest --configuration=packages/kosit-validator/phpunit.xml packages/kosit-validator/tests +``` + +## Translations + +- Entity titles: `resources/lang/{locale}/kosit-validator.php` +- Field labels: `resources/lang/{locale}/fields.php` (DE and EN) + +## See also + +- [Moox EBilling](../e-billing/README.md) — pipeline orchestrator (`ValidateXmlJob`) +- [Moox Zugferd](../zugferd/README.md) — XML generation validated here +- [Moox documentation](https://moox.org/docs/kosit-validator) +- [Architecture](docs/ARCHITECTURE.md) + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security + +Please review [our security policy](https://github.com/mooxphp/moox/security/policy) on how to report security vulnerabilities. + +## Credits + +Thanks to so many [people for their contributions](https://github.com/mooxphp/moox#contributors) to this package. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/kosit-validator/SECURITY.md b/packages/kosit-validator/SECURITY.md new file mode 100644 index 0000000000..c5abc6115e --- /dev/null +++ b/packages/kosit-validator/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `Moox KositValidator` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use Github issues, to give us enough time to review and fix the issue, before others can use it, to do stupid things. diff --git a/packages/kosit-validator/composer.json b/packages/kosit-validator/composer.json new file mode 100644 index 0000000000..892a970945 --- /dev/null +++ b/packages/kosit-validator/composer.json @@ -0,0 +1,63 @@ +{ + "name": "moox/kosit-validator", + "description": "KoSIT Validator CLI wrapper and Filament extension for ZUGFeRD / XRechnung XML validation against EN 16931.", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "Moox package", + "Laravel package" + ], + "homepage": "https://moox.org/docs/kosit-validator", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/core": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\KositValidator\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\KositValidator\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\KositValidator\\KositValidatorServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main", + "pestphp/pest": "^4.7", + "pestphp/pest-plugin-livewire": "^4.0" + }, + "scripts": { + "test": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/Unit tests/Feature" + ], + "test:arch": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml --bootstrap=tests/bootstrap-arch.php tests/ArchTest.php" + ] + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/kosit-validator/config/kosit-validator.php b/packages/kosit-validator/config/kosit-validator.php new file mode 100644 index 0000000000..d4dae37d50 --- /dev/null +++ b/packages/kosit-validator/config/kosit-validator.php @@ -0,0 +1,226 @@ + 'KoSIT Validator', + + /* + |-------------------------------------------------------------------------- + | Base Path + |-------------------------------------------------------------------------- + | + | Root directory for KoSIT validator artifacts (JAR and XRechnung config). + | + */ + 'base_path' => env('KOSIT_BASE_PATH', storage_path('app/private/kosit')), + + /* + |-------------------------------------------------------------------------- + | Validator + |-------------------------------------------------------------------------- + | + | KoSIT standalone validator JAR version and download URL. + | + */ + 'validator' => [ + 'version' => env('KOSIT_VALIDATOR_VERSION', '1.6.2'), + 'download_url' => env( + 'KOSIT_VALIDATOR_URL', + 'https://github.com/itplr-kosit/validator/releases/download/v1.6.2/validator-1.6.2-standalone.jar' + ), + ], + + /* + |-------------------------------------------------------------------------- + | XRechnung + |-------------------------------------------------------------------------- + | + | XRechnung validator configuration bundle version, release date, and URL. + | + */ + 'xrechnung' => [ + 'version' => env('KOSIT_XRECHNUNG_VERSION', '3.0.2'), + 'release_date' => env('KOSIT_XRECHNUNG_RELEASE_DATE', '2026-01-31'), + 'download_url' => env( + 'KOSIT_XRECHNUNG_URL', + 'https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/v2026-01-31/xrechnung-3.0.2-validator-configuration-2026-01-31.zip' + ), + ], + + /* + |-------------------------------------------------------------------------- + | Paths + |-------------------------------------------------------------------------- + | + | Relative subdirectory names under the base path for validator and config. + | + */ + 'paths' => [ + 'validator_dir' => 'validator', + 'xrechnung_dir' => 'xrechnung', + ], + + /* + |-------------------------------------------------------------------------- + | Java Binary + |-------------------------------------------------------------------------- + | + | Executable used to run the KoSIT validator JAR. + | + */ + 'java_binary' => env('KOSIT_JAVA_BINARY', 'java'), + + /* + |-------------------------------------------------------------------------- + | Validation report output + |-------------------------------------------------------------------------- + | + | Absolute directory where KoSIT writes `{inputBasename}-report.xml` and + | `{inputBasename}-report.html` (validator `-o` flag). Override in `.env`: + | + | KOSIT_OUTPUT_PATH=/var/kosit/reports + | + | `KOSIT_REPORT_PATH` is still read when `KOSIT_OUTPUT_PATH` is unset. + | + */ + 'output' => [ + 'path' => env('KOSIT_OUTPUT_PATH', env('KOSIT_REPORT_PATH', storage_path('app/private/kosit-reports'))), + ], + + /* + |-------------------------------------------------------------------------- + | Resources + |-------------------------------------------------------------------------- + | + | Filament resource settings keyed by entity slug. + | + */ + 'resources' => [ + 'kosit-validation' => [ + /* + |-------------------------------------------------------------------------- + | Title + |-------------------------------------------------------------------------- + | + | The translatable title of the Resource in singular and plural. + | + */ + 'single' => 'trans//kosit-validator::kosit-validator.kosit-validation', + 'plural' => 'trans//kosit-validator::kosit-validator.kosit-validations', + + /* + |-------------------------------------------------------------------------- + | + |-------------------------------------------------------------------------- + | + | Define the tabs for the Resource table. They are optional, but + | pretty awesome to filter the table by certain values. + | You may simply do a 'tabs' => [], to disable them. + | + */ + 'tabs' => [ + 'all' => [ + 'label' => 'trans//core::core.all', + 'icon' => 'gmdi-filter-list', + 'query' => [], + ], + 'passed' => [ + 'label' => 'trans//kosit-validator::fields.passed', + 'icon' => 'gmdi-check-circle', + 'query' => [ + [ + 'field' => 'passed', + 'operator' => '=', + 'value' => true, + ], + ], + ], + 'failed' => [ + 'label' => 'trans//core::core.failed', + 'icon' => 'gmdi-cancel', + 'query' => [ + [ + 'field' => 'passed', + 'operator' => '=', + 'value' => false, + ], + ], + ], + 'with-warnings' => [ + 'label' => 'trans//kosit-validator::fields.with_warnings', + 'icon' => 'gmdi-warning', + 'query' => [ + [ + 'field' => '__has_message_type', + 'operator' => '=', + 'value' => 'warning', + ], + ], + ], + 'with-infos' => [ + 'label' => 'trans//kosit-validator::fields.with_infos', + 'icon' => 'gmdi-info', + 'query' => [ + [ + 'field' => '__has_message_type', + 'operator' => '=', + 'value' => 'info', + ], + ], + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Relations (registry) + |-------------------------------------------------------------------------- + | + | Declarative list of notable Eloquent relations for this entity. + | Register owner_types when EbillingDocument (or other owners) are wired. + | + */ + 'relations' => [ + 'kosit_validatables' => [ + 'label' => 'trans//kosit-validator::fields.validatables', + 'relationship' => 'kositValidatables', + 'pivot_model' => KositValidatable::class, + 'pivot_table' => 'kosit_validatables', + 'morph_name' => 'validatable', + 'pivot_columns' => [], + 'owner_types' => [ + // \Moox\EBilling\Models\EbillingDocument::class => 'Invoice document', + ], + ], + ], + +]; diff --git a/packages/kosit-validator/database/migrations/create_kosit_validatables_table.php.stub b/packages/kosit-validator/database/migrations/create_kosit_validatables_table.php.stub new file mode 100644 index 0000000000..60a13520fc --- /dev/null +++ b/packages/kosit-validator/database/migrations/create_kosit_validatables_table.php.stub @@ -0,0 +1,31 @@ +id(); + $table->uuidMorphs('validatable'); + $table->foreignId('kosit_validation_id')->constrained('kosit_validations')->cascadeOnDelete(); + $table->timestamps(); + $table->string('scope')->nullable()->index(); + + $table->unique( + ['validatable_type', 'validatable_id', 'kosit_validation_id'], + 'kosit_validatables_morph_validation_unique', + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('kosit_validatables'); + } +}; diff --git a/packages/kosit-validator/database/migrations/create_kosit_validations_table.php.stub b/packages/kosit-validator/database/migrations/create_kosit_validations_table.php.stub new file mode 100644 index 0000000000..b153612671 --- /dev/null +++ b/packages/kosit-validator/database/migrations/create_kosit_validations_table.php.stub @@ -0,0 +1,33 @@ +id(); + $table->string('input_path', 1024)->nullable(); + $table->boolean('passed'); + $table->json('errors')->nullable(); + $table->string('report_xml_path', 1024)->nullable(); + $table->string('report_html_path', 1024)->nullable(); + $table->timestamp('validated_at'); + $table->timestamps(); + $table->string('scope')->nullable()->index(); + + $table->index('passed'); + $table->index('validated_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('kosit_validations'); + } +}; diff --git a/packages/kosit-validator/resources/lang/de/fields.php b/packages/kosit-validator/resources/lang/de/fields.php new file mode 100644 index 0000000000..7ad4ff52e2 --- /dev/null +++ b/packages/kosit-validator/resources/lang/de/fields.php @@ -0,0 +1,46 @@ + 'Dateiname', + 'validation_passed' => 'Prüfung bestanden', + 'error_counts' => 'Fehler / Warnungen / Hinweise', + 'validated_at' => 'Geprüft am', + 'result' => 'Ergebnis', + 'errors' => 'Fehler', + 'warnings' => 'Warnungen', + 'infos' => 'Hinweise', + 'validated_from' => 'Geprüft ab', + 'validated_until' => 'Geprüft bis', + 'source_file' => 'Quelldatei', + 'report_html' => 'KoSIT-Bericht HTML', + 'report_xml' => 'KoSIT-Bericht XML', + + // Tabs + 'passed' => 'Bestanden', + 'with_warnings' => 'Mit Warnungen', + 'with_infos' => 'Mit Hinweisen', + + // Sections + 'summary' => 'Zusammenfassung', + 'validation_messages' => 'Prüfmeldungen', + 'validation_report' => 'KoSIT-Prüfbericht', + + // Messages + 'no_validation_messages' => 'Keine Prüfmeldungen.', + 'no_report_available' => 'Kein KoSIT-Prüfbericht für diese Prüfung verfügbar.', + 'rule' => 'Regel', + 'location' => 'Position', + 'result_passed' => '✓ Bestanden', + 'result_failed' => '✗ Fehlgeschlagen', + 'severity_error' => 'Fehler', + 'severity_warning' => 'Warnung', + 'severity_info' => 'Hinweis', + 'validatables' => 'Zuordnungen', + 'owner' => 'Besitzer', + 'owner_name' => 'Name', + 'add_validatable' => 'Zuordnung hinzufügen', + 'filename_empty' => '—', + 'kosit_report_not_found' => 'KoSIT-Prüfbericht nicht gefunden', + 'could_not_parse_report_xml' => 'Prüfbericht-XML konnte nicht gelesen werden', +]; diff --git a/packages/kosit-validator/resources/lang/de/kosit-validator.php b/packages/kosit-validator/resources/lang/de/kosit-validator.php new file mode 100644 index 0000000000..70acf41785 --- /dev/null +++ b/packages/kosit-validator/resources/lang/de/kosit-validator.php @@ -0,0 +1,6 @@ + 'KoSIT-Prüfung', + 'kosit-validations' => 'KoSIT-Prüfungen', +]; diff --git a/packages/kosit-validator/resources/lang/en/fields.php b/packages/kosit-validator/resources/lang/en/fields.php new file mode 100644 index 0000000000..d0e75865d5 --- /dev/null +++ b/packages/kosit-validator/resources/lang/en/fields.php @@ -0,0 +1,46 @@ + 'Filename', + 'validation_passed' => 'Validation passed', + 'error_counts' => 'Error / warning / info counts', + 'validated_at' => 'Validated at', + 'result' => 'Result', + 'errors' => 'Errors', + 'warnings' => 'Warnings', + 'infos' => 'Info', + 'validated_from' => 'Validated from', + 'validated_until' => 'Validated until', + 'source_file' => 'Source File', + 'report_html' => 'KoSIT Report HTML', + 'report_xml' => 'KoSIT Report XML', + + // Tabs + 'passed' => 'Passed', + 'with_warnings' => 'With Warnings', + 'with_infos' => 'With Info', + + // Sections + 'summary' => 'Summary', + 'validation_messages' => 'Validation messages', + 'validation_report' => 'KoSIT validation report', + + // Messages + 'no_validation_messages' => 'No validation messages.', + 'no_report_available' => 'No KoSIT report available for this validation.', + 'rule' => 'Rule', + 'location' => 'Location', + 'result_passed' => '✓ Passed', + 'result_failed' => '✗ Failed', + 'severity_error' => 'Error', + 'severity_warning' => 'Warning', + 'severity_info' => 'Info', + 'validatables' => 'Assignments', + 'owner' => 'Owner', + 'owner_name' => 'Name', + 'add_validatable' => 'Add assignment', + 'filename_empty' => '—', + 'kosit_report_not_found' => 'KOSIT report not found', + 'could_not_parse_report_xml' => 'Could not parse report XML', +]; diff --git a/packages/kosit-validator/resources/lang/en/kosit-validator.php b/packages/kosit-validator/resources/lang/en/kosit-validator.php new file mode 100644 index 0000000000..550ef92b55 --- /dev/null +++ b/packages/kosit-validator/resources/lang/en/kosit-validator.php @@ -0,0 +1,6 @@ + 'KoSIT Validation', + 'kosit-validations' => 'KoSIT Validations', +]; diff --git a/packages/kosit-validator/resources/views/filament/partials/kosit-report-iframe.blade.php b/packages/kosit-validator/resources/views/filament/partials/kosit-report-iframe.blade.php new file mode 100644 index 0000000000..b84f6ce718 --- /dev/null +++ b/packages/kosit-validator/resources/views/filament/partials/kosit-report-iframe.blade.php @@ -0,0 +1,17 @@ +@php + /** @var \Moox\KositValidator\Models\KositValidation $record */ +@endphp +
+ @if (filled($record->report_html_path)) + + @else +
+ {{ __('kosit-validator::fields.no_report_available') }} +
+ @endif +
diff --git a/packages/kosit-validator/resources/views/filament/partials/kosit-validation-messages.blade.php b/packages/kosit-validator/resources/views/filament/partials/kosit-validation-messages.blade.php new file mode 100644 index 0000000000..33301f786a --- /dev/null +++ b/packages/kosit-validator/resources/views/filament/partials/kosit-validation-messages.blade.php @@ -0,0 +1,59 @@ +@php + use Moox\KositValidator\Support\KositValidationMessages; + + /** @var \Moox\KositValidator\Models\KositValidation $record */ + $messages = KositValidationMessages::normalized(is_array($record->errors) ? $record->errors : null); + $byType = ['error' => [], 'warning' => [], 'info' => []]; + foreach ($messages as $m) { + $t = $m['type']; + if (isset($byType[$t])) { + $byType[$t][] = $m; + } + } + + $kositSeverityChipClasses = static fn (string $type): string => match ($type) { + 'error' => 'inline-flex items-center rounded-md border border-red-500/10 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700 dark:border-red-500/15 dark:bg-red-500/10 dark:text-red-300', + 'warning' => 'inline-flex items-center rounded-md border border-amber-500/10 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:border-amber-500/15 dark:bg-amber-500/10 dark:text-amber-300', + 'info' => 'inline-flex items-center rounded-md border border-gray-500/10 bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:border-gray-500/15 dark:bg-gray-500/10 dark:text-gray-300', + default => throw new \UnexpectedValueException("Unknown KOSIT severity type: {$type}"), + }; +@endphp +@if ($messages === []) +

{{ __('kosit-validator::fields.no_validation_messages') }}

+@else +
+ @foreach (['error', 'warning', 'info'] as $type) + @if ($byType[$type] !== []) +
+

+ {{ __('kosit-validator::fields.' . $type . 's') }} +

+
    + @foreach ($byType[$type] as $msg) +
  • + + {{ __('kosit-validator::fields.severity_' . $type) }} + +

    + {{ $msg['text'] }} +

    + @if (filled($msg['location']) || filled($msg['rule'])) +

    + @if (filled($msg['rule'])) + {{ __('kosit-validator::fields.rule') }}: {{ $msg['rule'] }} + @endif + @if (filled($msg['location'])) + {{ __('kosit-validator::fields.location') }}: {{ $msg['location'] }} + @endif +

    + @endif +
  • + @endforeach +
+
+ @endif + @endforeach +
+@endif diff --git a/packages/kosit-validator/routes/web.php b/packages/kosit-validator/routes/web.php new file mode 100644 index 0000000000..1e42396a06 --- /dev/null +++ b/packages/kosit-validator/routes/web.php @@ -0,0 +1,21 @@ +prefix('admin/kosit-validations') + ->name('kosit-validator.') + ->group(function (): void { + Route::get('{validation}/report-html', [KositReportController::class, 'html']) + ->name('report.html'); + Route::get('{validation}/download/input', [KositReportController::class, 'downloadInputFile']) + ->name('download.input-file'); + Route::get('{validation}/download/report-html', [KositReportController::class, 'downloadReportHtml']) + ->name('download.report-html'); + Route::get('{validation}/download/report', [KositReportController::class, 'downloadReportXml']) + ->name('download.report-xml'); + }); diff --git a/packages/kosit-validator/screenshot/main.jpg b/packages/kosit-validator/screenshot/main.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/kosit-validator/screenshot/main.jpg differ diff --git a/packages/kosit-validator/src/Actions/RecordKositValidation.php b/packages/kosit-validator/src/Actions/RecordKositValidation.php new file mode 100644 index 0000000000..50f1f1b16f --- /dev/null +++ b/packages/kosit-validator/src/Actions/RecordKositValidation.php @@ -0,0 +1,23 @@ + $result->xmlPath, + 'report_xml_path' => $result->reportXmlPath, + 'report_html_path' => $result->reportHtmlPath, + 'passed' => $result->passed(), + 'errors' => $result->validationMessages(), + 'validated_at' => now(), + ]); + } +} diff --git a/packages/kosit-validator/src/Commands/DoctorCommand.php b/packages/kosit-validator/src/Commands/DoctorCommand.php new file mode 100644 index 0000000000..112aa04afd --- /dev/null +++ b/packages/kosit-validator/src/Commands/DoctorCommand.php @@ -0,0 +1,69 @@ +javaAvailable()) { + $this->components->info('Java: OK'); + } else { + $this->components->error('Java: NOT FOUND'); + $allGood = false; + } + + // JAR + try { + $jar = $kosit->jarPath(); + $this->components->info("JAR: {$jar}"); + } catch (RuntimeException $e) { + $this->components->error('JAR: '.$e->getMessage()); + $allGood = false; + } + + // scenarios.xml + try { + $scenarios = $kosit->scenariosPath(); + $this->components->info("scenarios.xml: {$scenarios}"); + } catch (RuntimeException $e) { + $this->components->error('scenarios.xml: '.$e->getMessage()); + $allGood = false; + } + + $outputPath = KositOutputPath::resolve(); + try { + File::ensureDirectoryExists($outputPath); + $this->components->info("Report output: {$outputPath}"); + } catch (\Throwable) { + $this->components->warn("Report output: {$outputPath} (not writable)"); + } + + $this->newLine(); + + if ($allGood) { + $this->components->info('Everything looks good.'); + + return self::SUCCESS; + } + + $this->components->warn('Issues found. Run php artisan kosit:install to fix.'); + + return self::FAILURE; + } +} diff --git a/packages/kosit-validator/src/Commands/InstallKositCommand.php b/packages/kosit-validator/src/Commands/InstallKositCommand.php new file mode 100644 index 0000000000..c5d265ec3c --- /dev/null +++ b/packages/kosit-validator/src/Commands/InstallKositCommand.php @@ -0,0 +1,154 @@ +components->info('Checking Java ...'); + + if (! $kosit->javaAvailable()) { + $this->components->error( + 'Java not found. Install a JRE/JDK on the server first (e.g. sudo apt install default-jre-headless).' + ); + + return self::FAILURE; + } + + $this->components->info('Java found.'); + + // 2. Prepare directories + $basePath = config('kosit-validator.base_path'); + $validatorDir = $basePath.'/'.config('kosit-validator.paths.validator_dir'); + $xrechnungDir = $basePath.'/'.config('kosit-validator.paths.xrechnung_dir'); + + if ($this->option('force') && File::exists($basePath)) { + $this->components->warn("Deleting existing installation at {$basePath}"); + File::deleteDirectory($basePath); + } + + if ($kosit->isInstalled() && ! $this->option('force')) { + $this->components->info('KoSIT is already installed. Use --force to reinstall.'); + + return self::SUCCESS; + } + + File::ensureDirectoryExists($validatorDir); + File::ensureDirectoryExists($xrechnungDir); + + $tmpDir = $basePath.'/tmp'; + File::ensureDirectoryExists($tmpDir); + + try { + // 3. Download + $xrechnungZip = $tmpDir.'/xrechnung.zip'; + + $validatorUrl = config('kosit-validator.validator.download_url'); + + if (str_ends_with($validatorUrl, '.jar')) { + $jarTarget = $validatorDir.'/'.basename($validatorUrl); + $this->downloadFile( + $validatorUrl, + $jarTarget, + 'Validator v'.config('kosit-validator.validator.version') + ); + } else { + $validatorZip = $tmpDir.'/validator.zip'; + $this->downloadFile( + $validatorUrl, + $validatorZip, + 'Validator v'.config('kosit-validator.validator.version') + ); + $this->extractZip($validatorZip, $validatorDir, 'Validator'); + } + + $this->downloadFile( + config('kosit-validator.xrechnung.download_url'), + $xrechnungZip, + 'XRechnung Configuration v'.config('kosit-validator.xrechnung.version') + ); + + // 4. Extract + $this->extractZip($xrechnungZip, $xrechnungDir, 'XRechnung Configuration'); + } catch (RuntimeException $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + // 5. Cleanup tmp + File::deleteDirectory($tmpDir); + + // 6. Verify + try { + $jarPath = $kosit->jarPath(); + $scenariosPath = $kosit->scenariosPath(); + } catch (RuntimeException $e) { + $this->components->error('Installation incomplete: '.$e->getMessage()); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info('KoSIT installation successful.'); + $this->line(" JAR: {$jarPath}"); + $this->line(" scenarios.xml: {$scenariosPath}"); + $this->newLine(); + $this->line('Test with: php artisan kosit:validate /path/to/invoice.xml'); + + return self::SUCCESS; + } + + private function downloadFile(string $url, string $target, string $label): void + { + $this->components->info("Downloading {$label} ..."); + + $response = Http::timeout(600) + ->sink($target) + ->withOptions([ + 'allow_redirects' => true, + ]) + ->get($url); + + if (! $response->successful()) { + throw new RuntimeException("Download failed for {$label}: HTTP {$response->status()} from {$url}"); + } + + if (! File::exists($target) || File::size($target) === 0) { + throw new RuntimeException("Download incomplete for {$label}"); + } + } + + private function extractZip(string $zipFile, string $targetDir, string $label): void + { + $this->components->info("Extracting {$label} ..."); + + $zip = new ZipArchive; + if ($zip->open($zipFile) !== true) { + throw new RuntimeException("Cannot open ZIP: {$zipFile}"); + } + + if (! $zip->extractTo($targetDir)) { + $zip->close(); + throw new RuntimeException("Cannot extract ZIP: {$zipFile}"); + } + + $zip->close(); + } +} diff --git a/packages/kosit-validator/src/Commands/ValidateCommand.php b/packages/kosit-validator/src/Commands/ValidateCommand.php new file mode 100644 index 0000000000..6bbc1dbc6a --- /dev/null +++ b/packages/kosit-validator/src/Commands/ValidateCommand.php @@ -0,0 +1,65 @@ +isInstalled()) { + $this->components->error('KoSIT is not installed. Run php artisan kosit:install first.'); + + return self::FAILURE; + } + + $path = $this->argument('path'); + + if (! file_exists($path)) { + $this->components->error("File not found: {$path}"); + + return self::FAILURE; + } + + $this->components->info("Validating {$path} ..."); + + $result = $kosit->validate($path); + + if ($result->passed()) { + $this->components->info('Validation passed.'); + } else { + $this->components->error('Validation failed.'); + $errors = $result->errors(); + + if ($errors !== []) { + $this->newLine(); + $this->components->warn('Errors:'); + foreach ($errors as $i => $error) { + $this->line(' '.($i + 1).'. '.$error); + } + } + } + + if ($result->reportXmlPath) { + $this->line(" Report XML: {$result->reportXmlPath}"); + } + if ($result->reportHtmlPath) { + $this->line(" Report HTML: {$result->reportHtmlPath}"); + } + + $validation = $recordKositValidation($result); + $this->line(" Validation ID: {$validation->id}"); + + return $result->passed() ? self::SUCCESS : self::FAILURE; + } +} diff --git a/packages/kosit-validator/src/DTOs/KositResult.php b/packages/kosit-validator/src/DTOs/KositResult.php new file mode 100644 index 0000000000..e7cd07b928 --- /dev/null +++ b/packages/kosit-validator/src/DTOs/KositResult.php @@ -0,0 +1,153 @@ +exitCode === 0; + } + + public function failed(): bool + { + return ! $this->passed(); + } + + /** + * Parse the report XML to extract all validation messages with severity. + * + * @return list + */ + public function validationMessages(): array + { + if (! $this->reportXmlPath || ! file_exists($this->reportXmlPath)) { + $text = trim($this->stderr ?: $this->stdout); + + return $this->failed() && $text !== '' + ? [['type' => 'error', 'text' => $text, 'location' => null, 'rule' => null]] + : []; + } + + $messages = []; + $xml = @simplexml_load_file($this->reportXmlPath); + + if ($xml === false) { + return $this->failed() + ? [['type' => 'error', 'text' => __('kosit-validator::fields.could_not_parse_report_xml'), 'location' => null, 'rule' => null]] + : []; + } + + $xml->registerXPathNamespace('rep', 'http://www.xoev.de/de/validator/varl/1'); + $xml->registerXPathNamespace('s', 'http://purl.oclc.org/dml/schematron'); + $xml->registerXPathNamespace('svrl', 'http://purl.oclc.org/dml/schematron'); + + $repMessages = $xml->xpath('//rep:message') ?: []; + foreach ($repMessages as $msg) { + $text = trim((string) $msg); + if ($text === '') { + continue; + } + + $level = strtolower((string) ($msg['level'] ?? 'error')); + $type = match (true) { + str_contains($level, 'info') => 'info', + str_contains($level, 'warn') => 'warning', + default => 'error', + }; + + $location = (string) ($msg['xpathLocation'] ?? ''); + $rule = (string) ($msg['code'] ?? ''); + $messages[] = [ + 'type' => $type, + 'text' => $text, + 'location' => $location !== '' ? $location : null, + 'rule' => $rule !== '' ? $rule : null, + ]; + } + + if ($messages === []) { + $failedAsserts = $xml->xpath('//s:failed-assert | //svrl:failed-assert') ?: []; + foreach ($failedAsserts as $assert) { + $text = self::schematronMessageText($assert); + if ($text !== '') { + $location = (string) ($assert['location'] ?? ''); + $rule = (string) ($assert['id'] ?? ''); + $messages[] = [ + 'type' => 'error', + 'text' => $text, + 'location' => $location !== '' ? $location : null, + 'rule' => $rule !== '' ? $rule : null, + ]; + } + } + + $successfulReports = $xml->xpath('//s:successful-report | //svrl:successful-report') ?: []; + foreach ($successfulReports as $report) { + $text = self::schematronMessageText($report); + if ($text !== '') { + $role = strtolower((string) ($report['role'] ?? 'warning')); + $type = match (true) { + str_contains($role, 'info') => 'info', + str_contains($role, 'warn') => 'warning', + default => 'warning', + }; + $location = (string) ($report['location'] ?? ''); + $rule = (string) ($report['id'] ?? ''); + $messages[] = [ + 'type' => $type, + 'text' => $text, + 'location' => $location !== '' ? $location : null, + 'rule' => $rule !== '' ? $rule : null, + ]; + } + } + } + + return $messages; + } + + /** + * Backward-compatible: returns flat array of error message strings only. + * + * @return list + */ + public function errors(): array + { + return array_map( + fn (array $m): string => $m['text'], + array_values(array_filter( + $this->validationMessages(), + fn (array $m): bool => $m['type'] === 'error' + )) + ); + } + + /** + * SVRL / Schematron output often uses a namespaced {@code text} child element. + */ + private static function schematronMessageText(\SimpleXMLElement $element): string + { + $ns = 'http://purl.oclc.org/dml/schematron'; + $childText = $element->children($ns)->text; + if ($childText !== null) { + $trimmed = trim((string) $childText); + if ($trimmed !== '') { + return $trimmed; + } + } + + return trim((string) $element); + } +} diff --git a/packages/kosit-validator/src/Http/Controllers/KositReportController.php b/packages/kosit-validator/src/Http/Controllers/KositReportController.php new file mode 100644 index 0000000000..8af56558c1 --- /dev/null +++ b/packages/kosit-validator/src/Http/Controllers/KositReportController.php @@ -0,0 +1,65 @@ +report_html_path; + + if ($htmlPath === null || ! is_file($htmlPath)) { + abort(404, __('kosit-validator::fields.kosit_report_not_found')); + } + + return response()->file($htmlPath, [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'SAMEORIGIN', + 'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline' 'self'; img-src data:; sandbox allow-same-origin", + ]); + } + + public function downloadInputFile(KositValidation $validation): BinaryFileResponse + { + return $this->downloadFromAbsolutePath( + $validation->input_path, + 'application/xml', + ); + } + + public function downloadReportXml(KositValidation $validation): BinaryFileResponse + { + return $this->downloadFromAbsolutePath( + $validation->report_xml_path, + 'application/xml', + ); + } + + public function downloadReportHtml(KositValidation $validation): BinaryFileResponse + { + return $this->downloadFromAbsolutePath( + $validation->report_html_path, + 'text/html; charset=UTF-8', + ); + } + + private function downloadFromAbsolutePath(?string $absolutePath, string $contentType): BinaryFileResponse + { + if ($absolutePath === null || ! is_file($absolutePath)) { + abort(404); + } + + return response() + ->download($absolutePath, basename($absolutePath), [ + 'Content-Type' => $contentType, + 'Content-Disposition' => 'attachment; filename="'.basename($absolutePath).'"', + 'X-Content-Type-Options' => 'nosniff', + ]); + } +} diff --git a/packages/kosit-validator/src/KositValidatorServiceProvider.php b/packages/kosit-validator/src/KositValidatorServiceProvider.php new file mode 100644 index 0000000000..f2f1927ef3 --- /dev/null +++ b/packages/kosit-validator/src/KositValidatorServiceProvider.php @@ -0,0 +1,62 @@ + ['input_path', 'passed', 'validated_at'], + 'translation_prefix' => 'kosit-validator::fields', + 'related_resource' => KositValidationResource::class, + 'record_select_label' => 'filenameLabel', + 'record_select_search_columns' => ['input_path'], + ]); + } + + public function configureMoox(Package $package): void + { + $package + ->name('kosit-validator') + ->hasConfigFile() + ->hasTranslations() + ->hasRoutes('web') + ->hasViews() + ->hasCommands([InstallKositCommand::class, ValidateCommand::class, DoctorCommand::class]) + ->hasMigrations([ + 'create_kosit_validations_table', + 'create_kosit_validatables_table', + ]); + + $this->getMooxPackage() + ->title('moox KositValidator') + ->released(true) + ->stability('dev') + ->category('development') + ->usedFor([ + 'KoSIT Validator CLI wrapper and Filament Plugin for ZUGFeRD / XRechnung XML validation', + ]); + } + + public function register(): void + { + parent::register(); + + $this->app->singleton(KositService::class); + } +} diff --git a/packages/kosit-validator/src/Models/KositValidatable.php b/packages/kosit-validator/src/Models/KositValidatable.php new file mode 100644 index 0000000000..b98928e230 --- /dev/null +++ b/packages/kosit-validator/src/Models/KositValidatable.php @@ -0,0 +1,39 @@ + + */ + public function validatable(): MorphTo + { + return $this->morphTo(); + } + + /** + * @return BelongsTo + */ + public function kositValidation(): BelongsTo + { + return $this->belongsTo(KositValidation::class); + } +} diff --git a/packages/kosit-validator/src/Models/KositValidation.php b/packages/kosit-validator/src/Models/KositValidation.php new file mode 100644 index 0000000000..edb58fdf70 --- /dev/null +++ b/packages/kosit-validator/src/Models/KositValidation.php @@ -0,0 +1,88 @@ +|null $errors + * @property Carbon|null $validated_at + */ +class KositValidation extends BaseItemModel +{ + /** + * @var list + */ + protected $fillable = [ + 'input_path', + 'report_xml_path', + 'report_html_path', + 'passed', + 'errors', + 'validated_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'errors' => 'array', + 'passed' => 'boolean', + 'validated_at' => 'datetime', + ]; + } + + public static function getResourceName(): string + { + return 'kosit-validation'; + } + + /** + * @return HasMany + */ + public function kositValidatables(): HasMany + { + return $this->hasMany(KositValidatable::class); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePassed(Builder $query): Builder + { + return $query->where('passed', true); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('passed', false); + } + + public function filenameLabel(): string + { + return $this->input_path !== null + ? basename($this->input_path) + : __('kosit-validator::fields.filename_empty'); + } + + public function reportHtmlPath(): ?string + { + return $this->report_html_path; + } +} diff --git a/packages/kosit-validator/src/Plugins/KositValidatorPlugin.php b/packages/kosit-validator/src/Plugins/KositValidatorPlugin.php new file mode 100644 index 0000000000..8c66043fbe --- /dev/null +++ b/packages/kosit-validator/src/Plugins/KositValidatorPlugin.php @@ -0,0 +1,31 @@ +resources([ + KositValidationResource::class, + ]); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(self::class); + } +} diff --git a/packages/kosit-validator/src/Resources/KositValidationResource.php b/packages/kosit-validator/src/Resources/KositValidationResource.php new file mode 100644 index 0000000000..741ec18444 --- /dev/null +++ b/packages/kosit-validator/src/Resources/KositValidationResource.php @@ -0,0 +1,274 @@ +components([ + Section::make(__('kosit-validator::fields.summary')) + ->schema([ + TextEntry::make('input_path') + ->label(__('kosit-validator::fields.filename')) + ->state(fn (KositValidation $record): string => $record->filenameLabel()), + IconEntry::make('passed') + ->label(__('kosit-validator::fields.validation_passed')) + ->boolean() + ->trueIcon(Heroicon::OutlinedCheckCircle) + ->falseIcon(Heroicon::OutlinedXCircle) + ->trueColor('success') + ->falseColor('danger'), + TextEntry::make('error_counts') + ->label(__('kosit-validator::fields.error_counts')) + ->state(function (KositValidation $record): string { + $counts = KositValidationMessages::counts($record->errors); + + return $counts['error'].' / '.$counts['warning'].' / '.$counts['info']; + }), + TextEntry::make('validated_at') + ->label(__('kosit-validator::fields.validated_at')) + ->dateTime(), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make(__('kosit-validator::fields.validation_messages')) + ->schema([ + View::make('kosit-validator::filament.partials.kosit-validation-messages') + ->viewData(fn (KositValidation $record): array => ['record' => $record]) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + Section::make(__('kosit-validator::fields.validation_report')) + ->schema([ + View::make('kosit-validator::filament.partials.kosit-report-iframe') + ->viewData(fn (KositValidation $record): array => ['record' => $record]) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('passed') + ->label(__('kosit-validator::fields.result')) + ->badge() + ->formatStateUsing(fn (bool $state): string => $state + ? __('kosit-validator::fields.result_passed') + : __('kosit-validator::fields.result_failed')) + ->color(fn (bool $state): string => $state ? 'success' : 'danger') + ->sortable() + ->grow() + ->width('8rem'), + TextColumn::make('filename') + ->label(__('kosit-validator::fields.filename')) + ->state(fn (KositValidation $record): string => $record->filenameLabel()) + ->searchable(query: function (Builder $query, string $search): Builder { + $like = '%'.$search.'%'; + + return $query->where(function (Builder $inner) use ($like, $search): void { + $inner->where('input_path', 'like', $like); + KositValidationMessages::applyErrorsTextSearch($inner, $search); + }); + }) + ->wrap() + ->grow(false), + TextColumn::make('errors_count') + ->label(__('kosit-validator::fields.errors')) + ->state(fn (KositValidation $record): int => KositValidationMessages::counts($record->errors)['error']) + ->badge() + ->color('danger') + ->alignment(Alignment::Center) + ->grow(false) + ->width('5rem'), + TextColumn::make('warnings_count') + ->label(__('kosit-validator::fields.warnings')) + ->state(fn (KositValidation $record): int => KositValidationMessages::counts($record->errors)['warning']) + ->badge() + ->color('warning') + ->alignment(Alignment::Center) + ->grow(false) + ->width('5.5rem'), + TextColumn::make('infos_count') + ->label(__('kosit-validator::fields.infos')) + ->state(fn (KositValidation $record): int => KositValidationMessages::counts($record->errors)['info']) + ->badge() + ->color('info') + ->alignment(Alignment::Center) + ->grow(false) + ->width('4.5rem'), + TextColumn::make('validated_at') + ->label(__('kosit-validator::fields.validated_at')) + ->dateTime('d.m.Y H:i') + ->sortable() + ->grow(false) + ->width('10.5rem'), + ]) + ->defaultSort('validated_at', 'desc') + ->filters([ + TernaryFilter::make('passed') + ->label(__('kosit-validator::fields.result')) + ->trueLabel(__('kosit-validator::fields.passed')) + ->falseLabel(__('core::core.failed')) + ->placeholder(__('core::core.all')), + Filter::make('validated_at_range') + ->schema([ + DatePicker::make('from')->label(__('kosit-validator::fields.validated_from')), + DatePicker::make('to')->label(__('kosit-validator::fields.validated_until')), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when($data['from'] ?? null, fn (Builder $q, string $date): Builder => $q->whereDate('validated_at', '>=', $date)) + ->when($data['to'] ?? null, fn (Builder $q, string $date): Builder => $q->whereDate('validated_at', '<=', $date)); + }), + ]) + ->recordActions([ + ViewAction::make(), + ]) + ->toolbarActions([]); + } + + public static function getRelations(): array + { + return [ + KositValidatablesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListKositValidations::route('/'), + 'view' => ViewKositValidation::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit(Model $record): bool + { + return false; + } + + public static function canDelete(Model $record): bool + { + return false; + } + + public static function canDeleteAny(): bool + { + return false; + } + + public static function getRecordTitle(?Model $record): Htmlable|string|null + { + if ($record instanceof KositValidation) { + return $record->filenameLabel(); + } + + return parent::getRecordTitle($record); + } +} diff --git a/packages/kosit-validator/src/Resources/KositValidationResource/Pages/ListKositValidations.php b/packages/kosit-validator/src/Resources/KositValidationResource/Pages/ListKositValidations.php new file mode 100644 index 0000000000..6e22d2fe06 --- /dev/null +++ b/packages/kosit-validator/src/Resources/KositValidationResource/Pages/ListKositValidations.php @@ -0,0 +1,59 @@ +mountTabsInListPage(); + } + + public function getTabs(): array + { + return $this->getDynamicTabs('kosit-validator.resources.kosit-validation.tabs', KositValidation::class); + } + + /** + * @param Builder $query + * @param list $conditions + * @return Builder + */ + protected function applyConditions($query, $conditions) + { + foreach ($conditions as $condition) { + if ($condition['field'] === '__has_message_type') { + $query = KositValidationMessages::applyHasMessageType($query, (string) $condition['value']); + + continue; + } + + $value = $condition['value']; + + if ($value instanceof Closure) { + $value = $value(); + } + + $query = $query->where($condition['field'], $condition['operator'], $value); + } + + return $query; + } +} diff --git a/packages/kosit-validator/src/Resources/KositValidationResource/Pages/ViewKositValidation.php b/packages/kosit-validator/src/Resources/KositValidationResource/Pages/ViewKositValidation.php new file mode 100644 index 0000000000..0e78501957 --- /dev/null +++ b/packages/kosit-validator/src/Resources/KositValidationResource/Pages/ViewKositValidation.php @@ -0,0 +1,48 @@ +label(__('kosit-validator::fields.source_file')) + ->icon('heroicon-o-document-arrow-down') + ->color('primary') + ->url(fn (KositValidation $record): string => route( + 'kosit-validator.download.input-file', + ['validation' => $record], + )) + ->visible(fn (KositValidation $record): bool => $record->input_path !== null), + Action::make('download_report_html') + ->label(__('kosit-validator::fields.report_html')) + ->icon('heroicon-o-document-arrow-down') + ->color('primary') + ->url(fn (KositValidation $record): string => route( + 'kosit-validator.download.report-html', + ['validation' => $record], + )) + ->visible(fn (KositValidation $record): bool => $record->report_html_path !== null), + Action::make('download_report_xml') + ->label(__('kosit-validator::fields.report_xml')) + ->icon('heroicon-o-document-arrow-down') + ->color('primary') + ->url(fn (KositValidation $record): string => route( + 'kosit-validator.download.report-xml', + ['validation' => $record], + )) + ->visible(fn (KositValidation $record): bool => $record->report_xml_path !== null), + ]; + } +} diff --git a/packages/kosit-validator/src/Resources/KositValidationResource/RelationManagers/KositValidatablesRelationManager.php b/packages/kosit-validator/src/Resources/KositValidationResource/RelationManagers/KositValidatablesRelationManager.php new file mode 100644 index 0000000000..948610235f --- /dev/null +++ b/packages/kosit-validator/src/Resources/KositValidationResource/RelationManagers/KositValidatablesRelationManager.php @@ -0,0 +1,109 @@ +label(__('kosit-validator::fields.owner')) + ->types( + collect($ownerTypes) + ->map(fn (string $label, string $class): Type => Type::make($class)->label($label)) + ->values() + ->all() + ) + ->required() + ->visible(fn (): bool => $ownerTypes !== []), + ]; + + foreach (KositRelationConfig::pivotColumns() as $column) { + $fields[] = Checkbox::make($column) + ->label(__('kosit-validator::fields.'.$column)); + } + + return $schema->components($fields); + } + + public function table(Table $table): Table + { + $morphName = KositRelationConfig::morphName(); + + $columns = [ + TextColumn::make("{$morphName}_type") + ->label(__('kosit-validator::fields.owner')) + ->formatStateUsing(fn (?string $state): string => class_basename((string) $state)) + ->searchable(), + TextColumn::make("{$morphName}_id") + ->label('ID') + ->searchable(), + TextColumn::make("{$morphName}") + ->label(__('kosit-validator::fields.owner_name')) + ->formatStateUsing(function ($record) use ($morphName) { + if ($record->{$morphName} && method_exists($record->{$morphName}, 'displayLabel')) { + return $record->{$morphName}->displayLabel(); + } + if ($record->{$morphName} && property_exists($record->{$morphName}, 'name')) { + return $record->{$morphName}->name; + } + + return (string) ($record->{$morphName.'_id'} ?? ''); + }) + ->searchable(), + ]; + + foreach (KositRelationConfig::pivotColumns() as $column) { + $columns[] = IconColumn::make($column) + ->label(__('kosit-validator::fields.'.$column)) + ->boolean(); + } + + return $table + ->columns($columns) + ->headerActions([ + CreateAction::make() + ->label(__('kosit-validator::fields.add_validatable')) + ->visible(fn (): bool => KositRelationConfig::ownerTypes() !== []), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]); + } +} diff --git a/packages/kosit-validator/src/Services/KositService.php b/packages/kosit-validator/src/Services/KositService.php new file mode 100644 index 0000000000..08d9a5e590 --- /dev/null +++ b/packages/kosit-validator/src/Services/KositService.php @@ -0,0 +1,161 @@ +findStandaloneJarsRecursive($dir); + if ($nested !== []) { + return $nested[0]; + } + + throw new RuntimeException("No standalone JAR found in {$dir}. Run php artisan kosit:install first."); + } + + public function scenariosPath(): string + { + $dir = config('kosit-validator.base_path').'/'.config('kosit-validator.paths.xrechnung_dir'); + + if (! is_dir($dir)) { + throw new RuntimeException("No scenarios.xml found in {$dir}. Run php artisan kosit:install first."); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (! $file instanceof SplFileInfo) { + continue; + } + if ($file->getFilename() === 'scenarios.xml') { + return $file->getPathname(); + } + } + + throw new RuntimeException("No scenarios.xml found in {$dir}. Run php artisan kosit:install first."); + } + + public function repositoryPath(): string + { + return dirname($this->scenariosPath()); + } + + /** + * @param string|null $reportDirectory Absolute filesystem directory for validator output (-o). + * When null, uses `kosit-validator.output.path` config. + */ + public function validate(string $xmlPath, ?string $reportDirectory = null): KositResult + { + if (! file_exists($xmlPath)) { + throw new RuntimeException("File not found: {$xmlPath}"); + } + + $resolvedXmlPath = realpath($xmlPath); + $inputPath = $resolvedXmlPath !== false ? $resolvedXmlPath : $xmlPath; + + $reportDir = $reportDirectory !== null + ? rtrim($reportDirectory, '/\\') + : KositOutputPath::resolve(); + + File::ensureDirectoryExists($reportDir); + + $java = config('kosit-validator.java_binary', 'java'); + + $result = Process::run([ + $java, + '-jar', $this->jarPath(), + '-s', $this->scenariosPath(), + '-r', $this->repositoryPath(), + '-o', $reportDir, + '-h', + $inputPath, + ]); + + $baseName = pathinfo($xmlPath, PATHINFO_FILENAME); + $reportXml = $reportDir.'/'.$baseName.'-report.xml'; + $reportHtml = $reportDir.'/'.$baseName.'-report.html'; + + return new KositResult( + exitCode: $result->exitCode(), + stdout: $result->output(), + stderr: $result->errorOutput(), + reportXmlPath: file_exists($reportXml) ? $reportXml : null, + reportHtmlPath: file_exists($reportHtml) ? $reportHtml : null, + xmlPath: $inputPath, + ); + } + + public function isInstalled(): bool + { + try { + $this->jarPath(); + $this->scenariosPath(); + + return true; + } catch (RuntimeException) { + return false; + } + } + + public function javaAvailable(): bool + { + $java = config('kosit-validator.java_binary', 'java'); + $result = Process::run([$java, '-version']); + + return $result->successful(); + } + + /** + * @return list + */ + private function findStandaloneJarsRecursive(string $dir): array + { + if (! is_dir($dir)) { + return []; + } + + $matches = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (! $file instanceof SplFileInfo || ! $file->isFile()) { + continue; + } + $name = $file->getFilename(); + if (str_ends_with($name, '-standalone.jar')) { + $matches[] = $file->getPathname(); + } + } + + return $matches; + } +} diff --git a/packages/kosit-validator/src/Support/KositOutputPath.php b/packages/kosit-validator/src/Support/KositOutputPath.php new file mode 100644 index 0000000000..a5351a6772 --- /dev/null +++ b/packages/kosit-validator/src/Support/KositOutputPath.php @@ -0,0 +1,41 @@ + + */ + public static function kositValidatables(): array + { + /** @var array $config */ + $config = config('kosit-validator.relations.kosit_validatables', []); + + return $config; + } + + public static function relationshipName(): string + { + return (string) (self::kositValidatables()['relationship'] ?? 'kositValidatables'); + } + + public static function pivotTable(): string + { + return (string) (self::kositValidatables()['pivot_table'] ?? 'kosit_validatables'); + } + + public static function morphName(): string + { + return (string) (self::kositValidatables()['morph_name'] ?? 'validatable'); + } + + /** + * @return list + */ + public static function pivotColumns(): array + { + /** @var list $columns */ + $columns = self::kositValidatables()['pivot_columns'] ?? []; + + return $columns; + } + + /** + * @return array + */ + public static function ownerTypes(): array + { + /** @var array $types */ + $types = self::kositValidatables()['owner_types'] ?? []; + + return $types; + } +} diff --git a/packages/kosit-validator/src/Support/KositValidationMessages.php b/packages/kosit-validator/src/Support/KositValidationMessages.php new file mode 100644 index 0000000000..38d2a9b81a --- /dev/null +++ b/packages/kosit-validator/src/Support/KositValidationMessages.php @@ -0,0 +1,146 @@ + + */ + public static function normalized(?array $raw): array + { + if ($raw === null || $raw === []) { + return []; + } + + $out = []; + foreach ($raw as $item) { + if (is_string($item)) { + $trimmed = trim($item); + if ($trimmed !== '') { + $out[] = [ + 'type' => 'error', + 'text' => $trimmed, + 'location' => null, + 'rule' => null, + ]; + } + + continue; + } + + if (! is_array($item)) { + continue; + } + + $type = is_string($item['type'] ?? null) ? $item['type'] : 'error'; + $text = is_string($item['text'] ?? null) ? trim($item['text']) : ''; + if ($text === '') { + continue; + } + + $location = $item['location'] ?? null; + $rule = $item['rule'] ?? null; + + $out[] = [ + 'type' => $type, + 'text' => $text, + 'location' => is_string($location) && $location !== '' ? $location : null, + 'rule' => is_string($rule) && $rule !== '' ? $rule : null, + ]; + } + + return $out; + } + + /** + * @return array{error: int, warning: int, info: int} + */ + public static function counts(?array $raw): array + { + $messages = self::normalized($raw); + $counts = ['error' => 0, 'warning' => 0, 'info' => 0]; + foreach ($messages as $message) { + if (isset($counts[$message['type']])) { + $counts[$message['type']]++; + } + } + + return $counts; + } + + /** + * @param Builder $query + * @return Builder + */ + public static function applyHasMessageType(Builder $query, string $type): Builder + { + $driver = self::queryDriverName($query); + + return match ($driver) { + 'sqlite' => $query->whereRaw( + 'EXISTS ( + SELECT 1 FROM json_each(COALESCE(errors, ?)) AS e + WHERE json_extract(e.value, \'$.type\') = ? + OR (? = \'error\' AND json_type(e.value) = \'text\') + )', + ['[]', $type, $type] + ), + 'mysql' => $query->whereRaw( + '(JSON_SEARCH(errors, \'one\', ?, NULL, \'$[*].type\') IS NOT NULL + OR (? = \'error\' AND JSON_TYPE(JSON_EXTRACT(errors, \'$[0]\')) = \'STRING\' + AND JSON_LENGTH(errors) > 0))', + [$type, $type] + ), + default => $query->whereRaw('CAST(errors AS TEXT) LIKE ?', ['%"type":"'.$type.'"%']), + }; + } + + /** + * @param Builder $query + * @return Builder + */ + public static function applyErrorsTextSearch(Builder $query, string $search): Builder + { + $like = '%'.$search.'%'; + $driver = self::queryDriverName($query); + + return match ($driver) { + 'sqlite' => $query->orWhereRaw( + 'EXISTS ( + SELECT 1 FROM json_each(COALESCE(errors, ?)) AS e + WHERE json_extract(e.value, \'$.text\') LIKE ? + OR (json_type(e.value) = \'text\' AND e.value LIKE ?) + )', + ['[]', $like, $like] + ), + 'mysql' => $query->orWhereRaw( + "(JSON_SEARCH(errors, 'one', ?, NULL, '\$[*].text') IS NOT NULL + OR CAST(errors AS CHAR) LIKE ?)", + [$search, $like] + ), + default => $query->orWhereRaw('CAST(errors AS TEXT) LIKE ?', [$like]), + }; + } + + /** + * @param Builder $query + */ + private static function queryDriverName(Builder $query): string + { + $connection = $query->getConnection(); + + return $connection instanceof Connection + ? $connection->getDriverName() + : 'unknown'; + } +} diff --git a/packages/kosit-validator/src/Tests/TestCase.php b/packages/kosit-validator/src/Tests/TestCase.php new file mode 100644 index 0000000000..1e629470a6 --- /dev/null +++ b/packages/kosit-validator/src/Tests/TestCase.php @@ -0,0 +1,206 @@ +addPsr4('Moox\\KositValidator\\Tests\\', dirname(__DIR__, 2).'/tests'); + + return; + } + } +})(); + +if (class_exists(Orchestra::class)) { + #[WithMigration('laravel')] + #[WithMigration('session')] + class TestCase extends Orchestra + { + use InteractsWithLivewire; + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + $this->withoutVite(); + + $panel = Filament::getPanel('admin'); + Filament::setCurrentPanel($panel); + Filament::bootCurrentPanel(); + + $errors = new ViewErrorBag; + $errors->put('default', new MessageBag); + $this->app['view']->share('errors', $errors); + + if ($this->app->bound('session')) { + $this->app['session']->put('errors', $errors); + } + + Factory::guessFactoryNamesUsing( + fn (string $modelName): string => 'Moox\\KositValidator\\Database\\Factories\\'.class_basename($modelName).'Factory' + ); + } + + protected function getPackageProviders($app): array + { + return [ + BladeIconsServiceProvider::class, + BladeHeroiconsServiceProvider::class, + BladeGoogleMaterialDesignIconsServiceProvider::class, + LivewireServiceProvider::class, + FilamentServiceProvider::class, + AuthServiceProvider::class, + CookieServiceProvider::class, + DatabaseServiceProvider::class, + EncryptionServiceProvider::class, + SessionServiceProvider::class, + ValidationServiceProvider::class, + ViewServiceProvider::class, + PaginationServiceProvider::class, + TranslationServiceProvider::class, + FilesystemServiceProvider::class, + SupportServiceProvider::class, + SchemasServiceProvider::class, + FormsServiceProvider::class, + TablesServiceProvider::class, + NotificationsServiceProvider::class, + ActionsServiceProvider::class, + InfolistsServiceProvider::class, + WidgetsServiceProvider::class, + CoreServiceProvider::class, + KositValidatorServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + $app['config']->set('database.default', 'testing'); + $app['config']->set('session.driver', 'array'); + $app['config']->set('auth.providers.users.model', TestUser::class); + $app['config']->set('auth.guards.web.provider', 'users'); + $app['config']->set('core.use_google_icons', true); + + $viewErrorBag = new ViewErrorBag; + $viewErrorBag->put('default', new MessageBag); + $app['view']->share('errors', $viewErrorBag); + + $this->setUpFilamentPanel(); + } + + protected function setUpFilamentPanel(): void + { + $panel = Panel::make() + ->default() + ->id('admin') + ->path('admin') + ->login() + ->colors([ + 'primary' => Color::Violet, + 'secondary' => Color::Neutral, + ]) + ->pages([ + Dashboard::class, + ]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + AuthenticateSession::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]) + ->plugins([ + KositValidatorPlugin::make(), + ]); + + Filament::registerPanel($panel); + } + + protected function defineDatabaseMigrations(): void + { + $migration = include dirname(__DIR__, 2).'/database/migrations/create_kosit_validations_table.php.stub'; + + $migration->up(); + } + } +} else { + class TestCase extends ApplicationTestCase + { + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + config()->set('auth.providers.users.model', TestUser::class); + } + } +} diff --git a/packages/kosit-validator/tests/ArchTest.php b/packages/kosit-validator/tests/ArchTest.php new file mode 100644 index 0000000000..bff69d0fce --- /dev/null +++ b/packages/kosit-validator/tests/ArchTest.php @@ -0,0 +1,6 @@ +expect('Moox\KositValidator') + ->toUseStrictTypes() + ->not->toUse(['die', 'dd', 'dump']); diff --git a/packages/kosit-validator/tests/Feature/ExampleTest.php b/packages/kosit-validator/tests/Feature/ExampleTest.php new file mode 100644 index 0000000000..f3aef8edd4 --- /dev/null +++ b/packages/kosit-validator/tests/Feature/ExampleTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/packages/kosit-validator/tests/Feature/KositReportControllerTest.php b/packages/kosit-validator/tests/Feature/KositReportControllerTest.php new file mode 100644 index 0000000000..2dd38988f7 --- /dev/null +++ b/packages/kosit-validator/tests/Feature/KositReportControllerTest.php @@ -0,0 +1,184 @@ +create([ + 'passed' => true, + 'report_html_path' => '/nonexistent/dir/report.html', + 'validated_at' => now(), + ]); + + $this->get(route('kosit-validator.report.html', $validation)) + ->assertRedirect(); +}); + +test('authenticated users receive 404 when the KOSIT HTML file is missing', function (): void { + $user = TestEnvironment::makeTestUser(); + + $validation = KositValidation::query()->create([ + 'passed' => true, + 'report_html_path' => '/nonexistent/dir/foo-report.html', + 'validated_at' => now(), + ]); + + $this->actingAs($user) + ->get(route('kosit-validator.report.html', $validation)) + ->assertNotFound(); +}); + +test('authenticated users can view the KOSIT HTML report with security headers', function (): void { + $user = TestEnvironment::makeTestUser(); + $dir = sys_get_temp_dir().'/kosit-validator-route-test-'.uniqid('', true); + mkdir($dir, 0777, true); + $xmlPath = $dir.'/inv-report.xml'; + $htmlPath = $dir.'/inv-report.html'; + file_put_contents($xmlPath, ''); + file_put_contents($htmlPath, 'Report OK'); + + $validation = KositValidation::query()->create([ + 'passed' => true, + 'report_xml_path' => $xmlPath, + 'report_html_path' => $htmlPath, + 'validated_at' => now(), + ]); + + $response = $this->actingAs($user) + ->get(route('kosit-validator.report.html', $validation)); + + $response->assertOk(); + $response->assertHeader('content-type', 'text/html; charset=UTF-8'); + $response->assertHeader('x-content-type-options', 'nosniff'); + $response->assertHeader('x-frame-options', 'SAMEORIGIN'); + expect($response->headers->get('Content-Security-Policy'))->toContain('default-src'); + + @unlink($htmlPath); + @unlink($xmlPath); + @rmdir($dir); +}); + +test('downloadInputFile returns the file at input_path', function (): void { + $user = TestEnvironment::makeTestUser(); + $tmpFile = tempnam(sys_get_temp_dir(), 'kosit-test-'); + $xmlPath = $tmpFile.'.xml'; + rename($tmpFile, $xmlPath); + file_put_contents($xmlPath, ''); + + $validation = KositValidation::query()->create([ + 'input_path' => $xmlPath, + 'report_xml_path' => '/nonexistent/report.xml', + 'report_html_path' => '/nonexistent/report.html', + 'passed' => true, + 'errors' => [], + 'validated_at' => now(), + ]); + + $response = $this->actingAs($user) + ->get(route('kosit-validator.download.input-file', $validation)); + + $response->assertOk(); + $response->assertHeader('content-type', 'application/xml'); + expect($response->headers->get('content-disposition'))->toContain(basename($xmlPath)); + + @unlink($xmlPath); +}); + +test('downloadInputFile 404s when the file is missing', function (): void { + $user = TestEnvironment::makeTestUser(); + + $validation = KositValidation::query()->create([ + 'input_path' => '/nonexistent/missing-input.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + $this->actingAs($user) + ->get(route('kosit-validator.download.input-file', $validation)) + ->assertNotFound(); +}); + +test('downloadReportHtml returns the file at report_html_path', function (): void { + $user = TestEnvironment::makeTestUser(); + $dir = sys_get_temp_dir().'/kosit-validator-html-dl-test-'.uniqid('', true); + mkdir($dir, 0777, true); + $htmlPath = $dir.'/inv-report.html'; + file_put_contents($htmlPath, 'Report OK'); + + $validation = KositValidation::query()->create([ + 'input_path' => $dir.'/inv.xml', + 'report_html_path' => $htmlPath, + 'passed' => true, + 'errors' => [], + 'validated_at' => now(), + ]); + + $response = $this->actingAs($user) + ->get(route('kosit-validator.download.report-html', $validation)); + + $response->assertOk(); + $response->assertHeader('content-type', 'text/html; charset=UTF-8'); + expect($response->headers->get('content-disposition'))->toContain('inv-report.html'); + + @unlink($htmlPath); + @rmdir($dir); +}); + +test('downloadReportHtml 404s when the file is missing', function (): void { + $user = TestEnvironment::makeTestUser(); + + $validation = KositValidation::query()->create([ + 'input_path' => '/tmp/inv.xml', + 'report_html_path' => '/nonexistent/missing-report.html', + 'passed' => true, + 'validated_at' => now(), + ]); + + $this->actingAs($user) + ->get(route('kosit-validator.download.report-html', $validation)) + ->assertNotFound(); +}); + +test('downloadReportXml returns the file at report_xml_path', function (): void { + $user = TestEnvironment::makeTestUser(); + $dir = sys_get_temp_dir().'/kosit-validator-dl-test-'.uniqid('', true); + mkdir($dir, 0777, true); + $xmlPath = $dir.'/inv-report.xml'; + file_put_contents($xmlPath, ''); + + $validation = KositValidation::query()->create([ + 'input_path' => $dir.'/inv.xml', + 'report_xml_path' => $xmlPath, + 'report_html_path' => '/nonexistent/report.html', + 'passed' => true, + 'errors' => [], + 'validated_at' => now(), + ]); + + $response = $this->actingAs($user) + ->get(route('kosit-validator.download.report-xml', $validation)); + + $response->assertOk(); + $response->assertHeader('content-type', 'application/xml'); + expect($response->headers->get('content-disposition'))->toContain('inv-report.xml'); + + @unlink($xmlPath); + @rmdir($dir); +}); + +test('downloadReportXml 404s when the file is missing', function (): void { + $user = TestEnvironment::makeTestUser(); + + $validation = KositValidation::query()->create([ + 'input_path' => '/tmp/inv.xml', + 'report_xml_path' => '/nonexistent/missing-report.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + $this->actingAs($user) + ->get(route('kosit-validator.download.report-xml', $validation)) + ->assertNotFound(); +}); diff --git a/packages/kosit-validator/tests/Feature/KositValidationResourceTest.php b/packages/kosit-validator/tests/Feature/KositValidationResourceTest.php new file mode 100644 index 0000000000..8d33e8815b --- /dev/null +++ b/packages/kosit-validator/tests/Feature/KositValidationResourceTest.php @@ -0,0 +1,248 @@ +toHaveKeys(['index', 'view']); +}); + +it('defines five dynamic tabs from config', function (): void { + $tabs = config('kosit-validator.resources.kosit-validation.tabs'); + + expect($tabs)->toHaveKeys(['all', 'passed', 'failed', 'with-warnings', 'with-infos']); +}); + +it('filters passed and failed tabs via simple where conditions', function (): void { + KositValidation::query()->create([ + 'input_path' => '/tmp/passed.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'input_path' => '/tmp/failed.xml', + 'passed' => false, + 'validated_at' => now(), + ]); + + $applyConditions = new ReflectionMethod(ListKositValidations::class, 'applyConditions'); + $page = new ListKositValidations; + + $passedConditions = config('kosit-validator.resources.kosit-validation.tabs.passed.query'); + $failedConditions = config('kosit-validator.resources.kosit-validation.tabs.failed.query'); + + /** @var Builder $passedQuery */ + $passedQuery = $applyConditions->invoke($page, KositValidation::query(), $passedConditions); + /** @var Builder $failedQuery */ + $failedQuery = $applyConditions->invoke($page, KositValidation::query(), $failedConditions); + + $passedCount = $passedQuery->count(); + $failedCount = $failedQuery->count(); + + expect($passedCount)->toBe(1) + ->and($failedCount)->toBe(1); +}); + +it('with-warnings tab returns only validations whose errors contain a warning entry', function (): void { + KositValidation::query()->create([ + 'input_path' => '/abs/a.xml', + 'report_xml_path' => '/abs/a-report.xml', + 'report_html_path' => '/abs/a-report.html', + 'passed' => true, + 'errors' => [['type' => 'warning', 'text' => 'Minor issue', 'location' => '', 'rule' => 'X']], + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'input_path' => '/abs/b.xml', + 'report_xml_path' => '/abs/b-report.xml', + 'report_html_path' => '/abs/b-report.html', + 'passed' => false, + 'errors' => [['type' => 'error', 'text' => 'Fatal', 'location' => '', 'rule' => 'Y']], + 'validated_at' => now(), + ]); + + $applyConditions = new ReflectionMethod(ListKositValidations::class, 'applyConditions'); + $conditions = config('kosit-validator.resources.kosit-validation.tabs.with-warnings.query'); + + /** @var Builder $query */ + $query = $applyConditions->invoke(new ListKositValidations, KositValidation::query(), $conditions); + + expect($query->count())->toBe(1); +}); + +it('with-infos tab returns only validations whose errors contain an info entry', function (): void { + KositValidation::query()->create([ + 'input_path' => '/abs/info.xml', + 'passed' => true, + 'errors' => [['type' => 'info', 'text' => 'Note', 'location' => '', 'rule' => 'N']], + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'input_path' => '/abs/err.xml', + 'passed' => false, + 'errors' => [['type' => 'error', 'text' => 'Bad', 'location' => '', 'rule' => 'E']], + 'validated_at' => now(), + ]); + + $applyConditions = new ReflectionMethod(ListKositValidations::class, 'applyConditions'); + $conditions = config('kosit-validator.resources.kosit-validation.tabs.with-infos.query'); + + /** @var Builder $query */ + $query = $applyConditions->invoke(new ListKositValidations, KositValidation::query(), $conditions); + + expect($query->count())->toBe(1); +}); + +it('search matches filename via input_path', function (): void { + KositValidation::query()->create([ + 'input_path' => '/storage/invoices/Rechnung_42.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'input_path' => '/storage/invoices/other.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + $query = KositValidation::query(); + $query->where(function ($inner) { + $inner->where('input_path', 'like', '%Rechnung_42%'); + }); + + expect($query->count())->toBe(1); +}); + +it('search matches text inside errors JSON', function (): void { + KositValidation::query()->create([ + 'input_path' => '/tmp/a.xml', + 'passed' => false, + 'errors' => [['type' => 'error', 'text' => 'UniqueKoSiTMarkerXYZ', 'location' => '', 'rule' => 'R']], + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'input_path' => '/tmp/b.xml', + 'passed' => true, + 'errors' => [], + 'validated_at' => now(), + ]); + + $query = KositValidation::query()->where(function (Builder $inner): void { + KositValidationMessages::applyErrorsTextSearch($inner, 'UniqueKoSiTMarkerXYZ'); + }); + + expect($query->count())->toBe(1); +}); + +it('passed ternary filter narrows validations by passed flag', function (): void { + KositValidation::query()->create([ + 'input_path' => '/tmp/ok.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'input_path' => '/tmp/nok.xml', + 'passed' => false, + 'validated_at' => now(), + ]); + + expect(KositValidation::query()->where('passed', true)->count())->toBe(1) + ->and(KositValidation::query()->where('passed', false)->count())->toBe(1); +}); + +it('validated_at date range filter narrows by validated_at', function (): void { + KositValidation::query()->create([ + 'input_path' => '/tmp/old.xml', + 'passed' => true, + 'validated_at' => '2024-01-15 10:00:00', + ]); + + KositValidation::query()->create([ + 'input_path' => '/tmp/new.xml', + 'passed' => true, + 'validated_at' => '2025-06-20 10:00:00', + ]); + + $query = KositValidation::query() + ->whereDate('validated_at', '>=', '2025-01-01') + ->whereDate('validated_at', '<=', '2025-12-31'); + + expect($query->count())->toBe(1); +}); + +it('counts helper returns error warning and info tallies', function (): void { + $errors = [ + ['type' => 'error', 'text' => 'e1', 'location' => null, 'rule' => null], + ['type' => 'warning', 'text' => 'w1', 'location' => null, 'rule' => null], + ['type' => 'info', 'text' => 'i1', 'location' => null, 'rule' => null], + ['type' => 'warning', 'text' => 'w2', 'location' => null, 'rule' => null], + ]; + + expect(KositValidationMessages::counts($errors))->toBe([ + 'error' => 1, + 'warning' => 2, + 'info' => 1, + ]); +}); + +it('renders validation messages and report partials for a record', function (): void { + $validation = KositValidation::query()->create([ + 'input_path' => '/tmp/invoice.xml', + 'passed' => false, + 'errors' => [ + ['type' => 'error', 'text' => 'Invalid total', 'location' => '/invoice', 'rule' => 'BR-01'], + ], + 'validated_at' => now(), + ]); + + $messagesHtml = view('kosit-validator::filament.partials.kosit-validation-messages', [ + 'record' => $validation, + ])->render(); + + $reportHtml = view('kosit-validator::filament.partials.kosit-report-iframe', [ + 'record' => $validation, + ])->render(); + + expect($messagesHtml) + ->toContain('Invalid total') + ->toContain('BR-01') + ->and($reportHtml)->toContain('No KoSIT report available for this validation.'); +}); + +it('is read-only for create and edit', function (): void { + $validation = KositValidation::query()->make([ + 'input_path' => '/tmp/invoice.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + expect(KositValidationResource::canCreate())->toBeFalse() + ->and(KositValidationResource::canEdit($validation))->toBeFalse() + ->and(KositValidationResource::canDelete($validation))->toBeFalse(); +}); + +it('uses the input xml basename as the record title', function (): void { + $validation = KositValidation::query()->create([ + 'input_path' => '/storage/invoices/Rechnung_99.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + expect(KositValidationResource::getRecordTitle($validation))->toBe('Rechnung_99.xml'); +}); diff --git a/packages/kosit-validator/tests/Feature/RecordKositValidationTest.php b/packages/kosit-validator/tests/Feature/RecordKositValidationTest.php new file mode 100644 index 0000000000..b52ca9f26e --- /dev/null +++ b/packages/kosit-validator/tests/Feature/RecordKositValidationTest.php @@ -0,0 +1,35 @@ +exists)->toBeTrue() + ->and($validation->input_path)->toBe('/abs/path/file.xml') + ->and($validation->report_xml_path)->toBe('/abs/path/file-report.xml') + ->and($validation->report_html_path)->toBe('/abs/path/file-report.html') + ->and($validation->passed)->toBeTrue() + ->and($validation->errors)->toBe([]); + + $fresh = $validation->fresh(); + expect($fresh->getAttributes())->not->toHaveKey('subject_type') + ->and($fresh->getAttributes())->not->toHaveKey('subject_id') + ->and($fresh->getAttributes())->not->toHaveKey('xml_path') + ->and($fresh->getAttributes())->not->toHaveKey('report_path'); +}); diff --git a/packages/kosit-validator/tests/Pest.php b/packages/kosit-validator/tests/Pest.php new file mode 100644 index 0000000000..a3cba1f312 --- /dev/null +++ b/packages/kosit-validator/tests/Pest.php @@ -0,0 +1,11 @@ +extends(TestCase::class) + ->beforeEach(function (): void { + $this->artisan('migrate'); + })->afterEach(function (): void { + $this->artisan('db:wipe'); + $this->artisan('optimize:clear'); + })->in('Feature', 'Unit'); diff --git a/packages/kosit-validator/tests/Support/TestEnvironment.php b/packages/kosit-validator/tests/Support/TestEnvironment.php new file mode 100644 index 0000000000..55a2a00ee7 --- /dev/null +++ b/packages/kosit-validator/tests/Support/TestEnvironment.php @@ -0,0 +1,29 @@ +create(array_merge([ + 'name' => 'Test User', + 'email' => 'test-'.uniqid('', true).'@example.com', + 'password' => 'password', + ], $attributes)); + } + + public static function makeKositValidation(array $attributes = []): KositValidation + { + return KositValidation::query()->create(array_merge([ + 'input_path' => '/tmp/test-invoice.xml', + 'passed' => true, + 'validated_at' => now(), + ], $attributes)); + } +} diff --git a/packages/kosit-validator/tests/TestCase.php b/packages/kosit-validator/tests/TestCase.php new file mode 120000 index 0000000000..62e29b5501 --- /dev/null +++ b/packages/kosit-validator/tests/TestCase.php @@ -0,0 +1 @@ +../src/Tests/TestCase.php \ No newline at end of file diff --git a/packages/kosit-validator/tests/Unit/ExampleTest.php b/packages/kosit-validator/tests/Unit/ExampleTest.php new file mode 100644 index 0000000000..f3aef8edd4 --- /dev/null +++ b/packages/kosit-validator/tests/Unit/ExampleTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/packages/kosit-validator/tests/Unit/KositOutputPathTest.php b/packages/kosit-validator/tests/Unit/KositOutputPathTest.php new file mode 100644 index 0000000000..51b9b08ae1 --- /dev/null +++ b/packages/kosit-validator/tests/Unit/KositOutputPathTest.php @@ -0,0 +1,62 @@ + '/tmp/kosit-reports']); + + expect(KositOutputPath::resolve())->toBe('/tmp/kosit-reports'); +}); + +it('appends an optional subdirectory segment', function (): void { + config(['kosit-validator.output.path' => '/tmp/kosit-reports']); + + expect(KositOutputPath::resolve('default/2026/01/15')) + ->toBe('/tmp/kosit-reports/default/2026/01/15'); +}); + +it('falls back to legacy report_path config when output.path is empty', function (): void { + $tempBase = sys_get_temp_dir().'/kosit-legacy-'.uniqid('', true); + config([ + 'kosit-validator.output.path' => '', + 'kosit-validator.report_path' => $tempBase, + ]); + + expect(KositOutputPath::resolve())->toBe($tempBase) + ->and(is_dir($tempBase))->toBeTrue(); + + File::deleteDirectory($tempBase); +}); + +it('resolve creates the base directory if it does not exist', function (): void { + $tempBase = sys_get_temp_dir().'/kosit-output-'.uniqid('', true); + config(['kosit-validator.output.path' => $tempBase]); + + expect(is_dir($tempBase))->toBeFalse(); + + $resolved = KositOutputPath::resolve(); + + expect($resolved)->toBe($tempBase) + ->and(is_dir($resolved))->toBeTrue(); + + File::deleteDirectory($tempBase); +}); + +it('resolve creates nested subdirectories', function (): void { + $tempBase = sys_get_temp_dir().'/kosit-nested-'.uniqid('', true); + config(['kosit-validator.output.path' => $tempBase]); + + $resolved = KositOutputPath::resolve('default/2026/05/20'); + $expected = $tempBase.'/default/2026/05/20'; + + expect($resolved)->toBe($expected) + ->and(is_dir($resolved))->toBeTrue(); + + File::deleteDirectory($tempBase); +}); diff --git a/packages/kosit-validator/tests/Unit/KositResultTest.php b/packages/kosit-validator/tests/Unit/KositResultTest.php new file mode 100644 index 0000000000..c2df137d5b --- /dev/null +++ b/packages/kosit-validator/tests/Unit/KositResultTest.php @@ -0,0 +1,77 @@ +passed())->toBeTrue()->and($ok->failed())->toBeFalse(); + + $bad = new KositResult(1, '', '', null, null); + expect($bad->passed())->toBeFalse()->and($bad->failed())->toBeTrue(); +}); + +test('errors falls back to stderr when no report and failed', function (): void { + $result = new KositResult(1, '', 'Something broke', null, null); + expect($result->errors())->toBe(['Something broke']); +}); + +test('errors is empty when passed without report', function (): void { + $result = new KositResult(0, '', '', null, null); + expect($result->errors())->toBe([]); +}); + +test('errors prefers stdout when stderr empty and failed', function (): void { + $result = new KositResult(1, 'stdout msg', '', null, null); + expect($result->errors())->toBe(['stdout msg']); +}); + +test('validationMessages falls back to SVRL when report has no rep:message', function (): void { + $path = __DIR__.'/../fixtures/kosit-report-svrl.xml'; + $failed = new KositResult(1, '', '', $path, null); + $messages = $failed->validationMessages(); + + expect($messages)->toHaveCount(3) + ->and($messages[0])->toMatchArray([ + 'type' => 'error', + 'text' => 'Invoice total is wrong', + 'location' => '/invoice', + 'rule' => 'BR-DE-01', + ]) + ->and($messages[1]['type'])->toBe('info') + ->and($messages[1]['text'])->toBe('Optional notice') + ->and($messages[2]['type'])->toBe('warning') + ->and($messages[2]['text'])->toBe('Deprecated field used'); + + expect($failed->errors())->toBe(['Invoice total is wrong']); +}); + +test('validationMessages parses rep:message with level as primary format', function (): void { + $path = __DIR__.'/../fixtures/kosit-report-rep-message.xml'; + $result = new KositResult(1, '', '', $path, null); + + expect($result->validationMessages())->toBe([ + [ + 'type' => 'error', + 'text' => 'Scenario not applicable', + 'location' => '/invoice', + 'rule' => 'BR-SCENARIO', + ], + ])->and($result->errors())->toBe(['Scenario not applicable']); +}); + +test('validationMessages keeps warnings when passed and errors() stays empty', function (): void { + $path = __DIR__.'/../fixtures/kosit-report-warnings-only.xml'; + $ok = new KositResult(0, '', '', $path, null); + + expect($ok->errors())->toBe([]) + ->and($ok->validationMessages())->toBe([ + [ + 'type' => 'warning', + 'text' => 'Heads up only', + 'location' => null, + 'rule' => 'W-1', + ], + ]); +}); diff --git a/packages/kosit-validator/tests/Unit/KositValidationTest.php b/packages/kosit-validator/tests/Unit/KositValidationTest.php new file mode 100644 index 0000000000..ee36753ca4 --- /dev/null +++ b/packages/kosit-validator/tests/Unit/KositValidationTest.php @@ -0,0 +1,105 @@ +create([ + 'passed' => false, + 'errors' => [['type' => 'error', 'text' => 'Invalid total', 'location' => null, 'rule' => null]], + 'validated_at' => now(), + ]); + + $validation->refresh(); + + expect($validation->errors)->toBeArray() + ->and($validation->errors[0]['text'])->toBe('Invalid total'); +}); + +it('casts passed to boolean', function (): void { + $validation = KositValidation::query()->create([ + 'passed' => 1, + 'validated_at' => now(), + ]); + + $validation->refresh(); + + expect($validation->passed)->toBeBool()->toBeTrue(); +}); + +it('casts validated_at to a Carbon instance', function (): void { + $validatedAt = now()->startOfSecond(); + + $validation = KositValidation::query()->create([ + 'passed' => true, + 'validated_at' => $validatedAt, + ]); + + $validation->refresh(); + + expect($validation->validated_at)->toBeInstanceOf(Carbon::class) + ->and($validation->validated_at->equalTo($validatedAt))->toBeTrue(); +}); + +it('scopes to passed validations', function (): void { + KositValidation::query()->create([ + 'passed' => true, + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'passed' => false, + 'validated_at' => now(), + ]); + + expect(KositValidation::query()->passed()->count())->toBe(1); +}); + +it('scopes to failed validations', function (): void { + KositValidation::query()->create([ + 'passed' => true, + 'validated_at' => now(), + ]); + + KositValidation::query()->create([ + 'passed' => false, + 'validated_at' => now(), + ]); + + expect(KositValidation::query()->failed()->count())->toBe(1); +}); + +it('builds a filename label from the input xml path', function (): void { + $validation = KositValidation::query()->create([ + 'input_path' => '/storage/invoices/Rechnung_123.xml', + 'passed' => true, + 'validated_at' => now(), + ]); + + expect($validation->filenameLabel())->toBe('Rechnung_123.xml'); +}); + +it('returns an em dash filename label when input xml path is null', function (): void { + $validation = KositValidation::query()->create([ + 'passed' => true, + 'validated_at' => now(), + ]); + + expect($validation->filenameLabel())->toBe('—'); +}); + +it('reads the HTML report path from the stored column', function (): void { + $validation = KositValidation::query()->create([ + 'passed' => true, + 'report_xml_path' => '/tmp/sample-report.xml', + 'report_html_path' => '/tmp/sample-report.html', + 'validated_at' => now(), + ]); + + expect($validation->reportHtmlPath())->toBe('/tmp/sample-report.html'); +}); diff --git a/packages/kosit-validator/tests/Unit/RecordKositValidationTest.php b/packages/kosit-validator/tests/Unit/RecordKositValidationTest.php new file mode 100644 index 0000000000..7339f857b3 --- /dev/null +++ b/packages/kosit-validator/tests/Unit/RecordKositValidationTest.php @@ -0,0 +1,83 @@ +toBeInstanceOf(KositValidation::class) + ->and($validation->passed)->toBeTrue() + ->and($validation->input_path)->toBe('/tmp/invoice.xml') + ->and($validation->report_xml_path)->toBe('/tmp/report.xml') + ->and($validation->report_html_path)->toBe('/tmp/report.html'); +}); + +it('stores errors as JSON', function (): void { + $reportPath = __DIR__.'/../fixtures/kosit-report-rep-message.xml'; + + $errors = [ + [ + 'type' => 'error', + 'text' => 'Scenario not applicable', + 'location' => '/invoice', + 'rule' => 'BR-SCENARIO', + ], + ]; + + $validation = app(RecordKositValidation::class)(new KositResult( + exitCode: 1, + stdout: '', + stderr: '', + reportXmlPath: $reportPath, + reportHtmlPath: null, + )); + + $validation->refresh(); + + expect($validation->errors)->toBe($errors) + ->and(json_encode($validation->errors))->toContain('Scenario not applicable'); +}); + +it('stores the validated_at timestamp', function (): void { + $before = now()->subSecond(); + + $validation = app(RecordKositValidation::class)(new KositResult( + exitCode: 0, + stdout: '', + stderr: '', + reportXmlPath: null, + reportHtmlPath: null, + )); + + $after = now()->addSecond(); + + expect($validation->validated_at)->not->toBeNull() + ->and($validation->validated_at->between($before, $after))->toBeTrue(); +}); + +it('handles a null input path gracefully', function (): void { + $validation = app(RecordKositValidation::class)(new KositResult( + exitCode: 0, + stdout: '', + stderr: '', + reportXmlPath: null, + reportHtmlPath: null, + xmlPath: null, + )); + + expect($validation->input_path)->toBeNull(); +}); diff --git a/packages/kosit-validator/tests/fixtures/kosit-report-rep-message.xml b/packages/kosit-validator/tests/fixtures/kosit-report-rep-message.xml new file mode 100644 index 0000000000..74ff4ae8f6 --- /dev/null +++ b/packages/kosit-validator/tests/fixtures/kosit-report-rep-message.xml @@ -0,0 +1,4 @@ + + + Scenario not applicable + diff --git a/packages/kosit-validator/tests/fixtures/kosit-report-svrl.xml b/packages/kosit-validator/tests/fixtures/kosit-report-svrl.xml new file mode 100644 index 0000000000..0617e65512 --- /dev/null +++ b/packages/kosit-validator/tests/fixtures/kosit-report-svrl.xml @@ -0,0 +1,14 @@ + + + + + Invoice total is wrong + + + Optional notice + + + Deprecated field used + + + diff --git a/packages/kosit-validator/tests/fixtures/kosit-report-warnings-only.xml b/packages/kosit-validator/tests/fixtures/kosit-report-warnings-only.xml new file mode 100644 index 0000000000..34fec33577 --- /dev/null +++ b/packages/kosit-validator/tests/fixtures/kosit-report-warnings-only.xml @@ -0,0 +1,8 @@ + + + + + Heads up only + + + diff --git a/packages/mail-inbox/.gitignore b/packages/mail-inbox/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/mail-inbox/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/mail-inbox/CHANGELOG.md b/packages/mail-inbox/CHANGELOG.md new file mode 100644 index 0000000000..d806cfe835 --- /dev/null +++ b/packages/mail-inbox/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +We currently don't track changes in this package. Please refer to the [Moox Monorepo](https://github.com/mooxphp/moox) for the latest changes. diff --git a/packages/mail-inbox/LICENSE.md b/packages/mail-inbox/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/mail-inbox/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/mail-inbox/README.md b/packages/mail-inbox/README.md new file mode 100644 index 0000000000..0e7ca7c0b7 --- /dev/null +++ b/packages/mail-inbox/README.md @@ -0,0 +1,297 @@ +![Moox MailInbox](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox MailInbox + +Microsoft Graph–based mail inbox for Laravel (Moox package). + +## Features + +- Microsoft Graph delta sync for inbound mailbox polling +- Persistent `InboxMessage` and `InboxAttachment` records with idempotency via `scope` + `external_id` / `message_id` +- Attachment download and storage on a configurable disk +- PDF parse pipeline dispatch (`ParsePdfJob`) for e-billing integration +- Optional Graph folder routing (Processing, Processed, Failed) +- Scheduled `mail-inbox:poll` command with overlap protection +- Status and retry commands for operations and recovery + +## Requirements + +See [Requirements](https://github.com/mooxphp/moox/blob/main/docs/Requirements.md). + +This package also requires: + +- An Azure app registration with Microsoft Graph application permissions for the target mailbox (client credentials flow) +- Valid values for `MAIL_INBOX_TENANT_ID`, `MAIL_INBOX_CLIENT_ID`, `MAIL_INBOX_CLIENT_SECRET`, and `MAIL_INBOX_MAILBOX` +- The `microsoft/microsoft-graph` package (^2.26), installed automatically via Composer + +## Installation + +```bash +composer require moox/mail-inbox +php artisan moox:install +``` + +Curious what the install command does? See [Installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md). + +## Configuration + +Copy the environment variables below into your `.env` and adjust for your Azure app and mailbox. + +### Environment variables + +```env +# Required — Microsoft Graph client credentials +MAIL_INBOX_TENANT_ID=your-azure-tenant-id +MAIL_INBOX_CLIENT_ID=your-azure-app-client-id +MAIL_INBOX_CLIENT_SECRET=your-azure-app-client-secret + +# Required — mailbox user principal name or ID polled via Graph +MAIL_INBOX_MAILBOX=invoices@example.com + +# Optional — Graph folder display names (defaults shown) +MAIL_INBOX_PROCESSED_FOLDER=Processed +MAIL_INBOX_FAILED_FOLDER=Failed +MAIL_INBOX_PROCESSING_FOLDER=Processing + +# Optional — scheduler and delta sync tuning +MAIL_INBOX_POLL_INTERVAL=5 +MAIL_INBOX_DELTA_MAX_PAGES_PER_POLL=50 + +# Optional — job runtime limits +MAIL_INBOX_MEMORY_LIMIT=512M +MAIL_INBOX_RETRY_STALENESS_MINUTES=30 +MAIL_INBOX_LISTENER_TIMEOUT_MINUTES=5 + +# Optional — attachment storage +MAIL_INBOX_ATTACHMENT_DISK=local +MAIL_INBOX_ATTACHMENT_PATH=mail-inbox/attachments + +# Optional — ZUGFeRD / PDF extraction paths +MAIL_INBOX_ZUGFERD_PATH=zugferd +MAIL_INBOX_PDF_PASSWORD= +``` + +| Variable | Config key | Default | Required | +| --- | --- | --- | --- | +| `MAIL_INBOX_TENANT_ID` | `graph.tenant_id` | — | Yes | +| `MAIL_INBOX_CLIENT_ID` | `graph.client_id` | — | Yes | +| `MAIL_INBOX_CLIENT_SECRET` | `graph.client_secret` | — | Yes | +| `MAIL_INBOX_MAILBOX` | `mailbox` | — | Yes | +| `MAIL_INBOX_PROCESSED_FOLDER` | `processed_folder` | `Processed` | No | +| `MAIL_INBOX_FAILED_FOLDER` | `failed_folder` | `Failed` | No | +| `MAIL_INBOX_PROCESSING_FOLDER` | `processing_folder` | `Processing` | No | +| `MAIL_INBOX_POLL_INTERVAL` | `poll_interval` | `5` | No | +| `MAIL_INBOX_DELTA_MAX_PAGES_PER_POLL` | `delta_max_pages_per_poll` | `50` | No | +| `MAIL_INBOX_MEMORY_LIMIT` | `memory_limit` | `512M` | No | +| `MAIL_INBOX_RETRY_STALENESS_MINUTES` | `retry_staleness_minutes` | `30` | No | +| `MAIL_INBOX_LISTENER_TIMEOUT_MINUTES` | `listener_timeout_minutes` | `5` | No | +| `MAIL_INBOX_ATTACHMENT_DISK` | `attachments.disk` | `local` | No | +| `MAIL_INBOX_ATTACHMENT_PATH` | `attachments.path` | `mail-inbox/attachments` | No | +| `MAIL_INBOX_ZUGFERD_PATH` | `zugferd.path` | `zugferd` | No | +| `MAIL_INBOX_PDF_PASSWORD` | `zugferd.pdf_password` | — | No | + +### Config file (`config/mail-inbox.php`) + +| Key | Controls | +| --- | --- | +| `graph` | Azure AD tenant, client ID, and client secret for Graph API authentication | +| `mailbox` | User principal name or ID of the mailbox to poll | +| `processed_folder` | Display name of the Graph folder for successfully processed messages | +| `failed_folder` | Display name of the Graph folder for failed messages | +| `processing_folder` | Optional intermediate folder; after each delta batch, `FetchMailsJob` moves newly persisted messages here. Set to an empty string to skip the move | +| `poll_interval` | Minutes between scheduled `mail-inbox:poll` runs (clamped to 1–59) | +| `delta_max_pages_per_poll` | Maximum Graph delta pages fetched per `FetchMailsJob` run; large catch-ups span multiple polls | +| `memory_limit` | PHP memory limit applied during fetch jobs | +| `retry_staleness_minutes` | Attachments stuck in `processing` longer than this are eligible for retry | +| `listener_timeout_minutes` | Timeout for listener-style job coordination | +| `attachments.disk` | Laravel filesystem disk for stored attachments | +| `attachments.path` | Base path on the disk for attachment files | +| `zugferd.path` | Sub-path for extracted ZUGFeRD XML | +| `zugferd.pdf_password` | Optional password for encrypted PDF attachments | + +## Commands + +### `mail-inbox:poll` + +Dispatches `FetchMailsJob` to the queue. The job continues the pipeline asynchronously (attachment storage and PDF parsing). + +```bash +php artisan mail-inbox:poll --scope=default +``` + +Use this for manual polling or rely on the scheduler (see [Scheduling](#scheduling)). The `--scope` option selects the mailbox ingest scope (default: `default`). + +### `mail-inbox:fetch` + +Runs `FetchMailsJob` synchronously: fetches delta pages from Graph, persists new messages, and queues attachment/PDF jobs. + +```bash +php artisan mail-inbox:fetch --scope=default +``` + +Use for debugging or one-off catch-up when you need the fetch to complete in the foreground. Attachment and PDF jobs may still run on the queue after the command returns. + +### `mail-inbox:process` + +Processes inbox messages through the e-billing PDF pipeline. + +```bash +php artisan mail-inbox:process --scope=default +``` + +Processes all `new` messages in the scope. To retry failed messages: + +```bash +php artisan mail-inbox:process --scope=default --retry-failed +``` + +To process a single message by database ID: + +```bash +php artisan mail-inbox:process --scope=default --message=42 +``` + +### `mail-inbox:status` + +Shows message and attachment counts per processing status, plus latest received and processed timestamps. + +```bash +php artisan mail-inbox:status --scope=default +``` + +## Scheduling + +When the application runs in the console context, `MailInboxServiceProvider` registers a scheduled `mail-inbox:poll` command. The cadence is driven by `mail-inbox.poll_interval` (default: 5 minutes), expressed as a cron expression `*/{interval} * * * *` with the interval clamped between 1 and 59. + +The scheduled run uses `withoutOverlapping()`, `runInBackground()`, and appends output to `storage/logs/mail-inbox.log`. + +Ensure Laravel's scheduler is active in production (`* * * * * php artisan schedule:run`). + +## The InboxMessage Model + +The `InboxMessage` model (`Moox\MailInbox\Models\InboxMessage`) stores ingested mail metadata and processing state. + +`scope` is the mailbox ingest identifier (not the Laravel scope pattern). It participates in unique constraints `(scope, external_id)` and `(scope, message_id)` for idempotent delta ingest. On MySQL/MariaDB, `external_id` and `message_id` use `utf8mb4_bin` collation so Graph opaque identifiers compare byte-for-byte. + +Existing volatile Graph message IDs are reconciled automatically as delta sync re-discovers each message (see `docs/ARCHITECTURE.md`). + +### Attributes + +- `id` (bigIncrements) - Primary key +- `scope` (string) - Mailbox ingest identifier; indexed; part of unique `(scope, external_id)` and `(scope, message_id)` +- `channel` (string) - Source channel (default: `email`) +- `external_id` (string, nullable) - Microsoft Graph message ID; indexed +- `message_id` (string, nullable) - RFC 822 `Message-ID` header value +- `from_email` (string, nullable) - Sender email address +- `from_name` (string, nullable) - Sender display name +- `to_email` (string, nullable) - Primary recipient email +- `to_name` (string, nullable) - Primary recipient display name +- `subject` (string, nullable) - Message subject +- `received_at` (timestamp, nullable) - When Graph reports the message was received +- `raw_headers` (json, nullable) - Internet message headers as key-value pairs +- `raw_body_text` (longText, nullable) - Plain-text body when available +- `raw_body_html` (longText, nullable) - HTML body when available +- `has_attachments` (boolean) - Whether Graph reports attachments (default: false) +- `processing_status` (string) - Pipeline status (default: `new`) +- `processed_at` (timestamp, nullable) - When processing completed +- `error_message` (text, nullable) - Last error detail when failed +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +### Relationships + +- `attachments()` - `HasMany` to `InboxAttachment` + +## The InboxAttachment Model + +The `InboxAttachment` model (`Moox\MailInbox\Models\InboxAttachment`) stores downloaded attachment metadata and per-file processing state. + +### Attributes + +- `id` (bigIncrements) - Primary key +- `scope` (string) - Mailbox ingest identifier; indexed +- `inbox_message_id` (foreignId, nullable) - References `inbox_messages.id` (`nullOnDelete`) +- `external_attachment_id` (string, nullable) - Graph attachment ID (`utf8mb4_bin` on MySQL/MariaDB) +- `storage_disk` (string) - Laravel filesystem disk name +- `storage_path` (string) - Path on the disk +- `filename` (string) - Original filename +- `mime_type` (string) - MIME type +- `extension` (string, nullable) - File extension +- `filesize` (unsignedBigInteger, nullable) - Size in bytes +- `checksum` (string, nullable) - Content checksum +- `is_pdf` (boolean) - Whether the attachment is a PDF (default: false) +- `attachment_role` (string, nullable) - Role classification when applicable +- `processing_status` (string) - Pipeline status (default: `new`) +- `zugferd_storage_disk` (string, nullable) - Disk for extracted ZUGFeRD XML +- `zugferd_storage_path` (string, nullable) - Path for extracted ZUGFeRD XML +- `processed_at` (timestamp, nullable) - When processing completed +- `error_message` (text, nullable) - Last error detail when failed +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +### Relationships + +- `inboxMessage()` - `BelongsTo` to `InboxMessage` + +## The MailInboxSyncState Model + +The `MailInboxSyncState` model (`Moox\MailInbox\Models\MailInboxSyncState`) stores per-scope Microsoft Graph delta sync cursor state. + +### Attributes + +- `scope` (string) - Primary key; mailbox ingest identifier +- `delta_link` (text, nullable) - Persisted `@odata.deltaLink` URL for incremental sync +- `last_synced_at` (timestamp, nullable) - Timestamp of the last successful delta page +- `created_at` (datetime) - Creation timestamp +- `updated_at` (datetime) - Last update timestamp + +When Graph invalidates a delta link (`syncStateNotFound`), the job resets sync state and performs a full re-sync for that scope. + +## Public API + +### `MailInboxService` + +Primary methods for application and job integration: + +- `persistDeltaMessages(array $graphMessages, string $scope): DeltaPersistResult` — Persist Graph delta messages and enqueue attachment jobs +- `finalizeMessageProcessingAfterAttachments(InboxMessage $message): void` — Resolve message status after all attachments reach a terminal state +- `enqueueParseJobsForInboxMessage(InboxMessage $message): void` — Queue `ParsePdfJob` for new PDF attachments +- `processNewMessages(string $scope = 'default'): int` — Process all `new` messages in a scope +- `retryFailedMessages(string $scope = 'default'): int` — Reset and re-queue failed messages and stale attachments +- `attachmentTerminalCountsForScope(string $scope): array` — Count processed/failed/skipped attachments for a scope +- `attachmentTerminalCountsForMessage(InboxMessage $message): array` — Same counts for one message +- `inboxStatusBreakdown(string $scope): array` — Message and attachment counts per processing status +- `latestReceivedAtForScope(string $scope): ?Carbon` — Latest `received_at` in a scope +- `latestProcessedAtForScope(string $scope): ?Carbon` — Latest `processed_at` in a scope + +### `GraphMailService` + +Microsoft Graph client for mailbox operations: + +- `fetchInboxMessagesViaDelta(?string $deltaLink): DeltaPage` — Fetch one page of inbox mail via Graph delta +- `fetchAttachments(string $messageId): Collection` — List file attachments for a message +- `downloadAttachmentContent(string $messageId, string $attachmentId): array` — Download attachment bytes and metadata +- `markMessageAsRead(string $messageId): void` — Mark a Graph message as read +- `moveMessageToFolder(string $messageId, string $destinationFolderId, ?string $scope = null): void` — Move with pipeline source guard +- `moveGraphMessageToProcessingFolder(string $messageId, string $scope): void` — Best-effort move to `processing_folder` +- `moveGraphMessageToProcessedOrFailedFolder(string $messageId, bool $success, ?string $scope = null): void` — Move to Processed or Failed folder +- `moveGraphMessageToIgnoredFolder(string $messageId, string $ignoredFolderDisplayName, ?string $scope = null): void` — Move to a named ignore folder +- `getMessageParentFolderId(string $messageId): ?string` — Current parent folder ID +- `moveMessageToFolderByName(string $messageId, string $folderName, bool $createIfMissing = true, ?string $scope = null): void` — Resolve folder by display name and move +- `getOrCreateFolder(string $folderName): string` — Resolve or create a mail folder by display name + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security + +Please review [our security policy](https://github.com/mooxphp/moox/security/policy) on how to report security vulnerabilities. + +## Credits + +Thanks to so many [people for their contributions](https://github.com/mooxphp/moox#contributors) to this package. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/mail-inbox/SECURITY.md b/packages/mail-inbox/SECURITY.md new file mode 100644 index 0000000000..b44cc83343 --- /dev/null +++ b/packages/mail-inbox/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `Moox MailInbox` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use Github issues, to give us enough time to review and fix the issue, before others can use it, to do stupid things. diff --git a/packages/mail-inbox/banner.jpg b/packages/mail-inbox/banner.jpg new file mode 100644 index 0000000000..d889a2b55d Binary files /dev/null and b/packages/mail-inbox/banner.jpg differ diff --git a/packages/mail-inbox/composer.json b/packages/mail-inbox/composer.json new file mode 100644 index 0000000000..b57ddc5be0 --- /dev/null +++ b/packages/mail-inbox/composer.json @@ -0,0 +1,57 @@ +{ + "name": "moox/mail-inbox", + "description": "Microsoft Graph–based mail inbox for Laravel (Moox package).", + "keywords": [ + "Moox", + "Laravel", + "Microsoft Graph", + "mail", + "inbox", + "Moox package", + "Laravel package" + ], + "homepage": "https://moox.org/docs/mail-inbox", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "microsoft/microsoft-graph": "^2.26", + "moox/core": "dev-main", + "moox/jobs": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\MailInbox\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\MailInbox\\Tests\\": "tests" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\MailInbox\\MailInboxServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/mail-inbox/config/mail-inbox.php b/packages/mail-inbox/config/mail-inbox.php new file mode 100644 index 0000000000..1573e86fe9 --- /dev/null +++ b/packages/mail-inbox/config/mail-inbox.php @@ -0,0 +1,63 @@ + [ + 'tenant_id' => env('MAIL_INBOX_TENANT_ID'), + 'client_id' => env('MAIL_INBOX_CLIENT_ID'), + 'client_secret' => env('MAIL_INBOX_CLIENT_SECRET'), + ], + + /* + |-------------------------------------------------------------------------- + | Mailbox Configuration + |-------------------------------------------------------------------------- + */ + 'mailbox' => env('MAIL_INBOX_MAILBOX'), // e.g. rechnungen@firma.de + + 'processed_folder' => env('MAIL_INBOX_PROCESSED_FOLDER', 'Processed'), + + 'failed_folder' => env('MAIL_INBOX_FAILED_FOLDER', 'Failed'), + + /* + |-------------------------------------------------------------------------- + | Processing folder (optional UX) + |-------------------------------------------------------------------------- + | + | When set, FetchMailsJob moves each newly delta-persisted message into this + | folder on the Graph side. Null or empty skips the move (backwards compatible). + */ + 'processing_folder' => env('MAIL_INBOX_PROCESSING_FOLDER', 'Processing'), + + 'poll_interval' => env('MAIL_INBOX_POLL_INTERVAL', 5), // minutes + + // Max Graph delta pages fetched per single FetchMailsJob run (initial catch-up spans multiple polls). + 'delta_max_pages_per_poll' => (int) env('MAIL_INBOX_DELTA_MAX_PAGES_PER_POLL', 50), + + 'memory_limit' => env('MAIL_INBOX_MEMORY_LIMIT', '512M'), + + 'retry_staleness_minutes' => env('MAIL_INBOX_RETRY_STALENESS_MINUTES', 30), + + 'listener_timeout_minutes' => env('MAIL_INBOX_LISTENER_TIMEOUT_MINUTES', 5), + + /* + |-------------------------------------------------------------------------- + | Attachment Storage + |-------------------------------------------------------------------------- + */ + 'attachments' => [ + 'disk' => env('MAIL_INBOX_ATTACHMENT_DISK', 'local'), + 'path' => env('MAIL_INBOX_ATTACHMENT_PATH', 'mail-inbox/attachments'), + ], + + 'zugferd' => [ + 'path' => env('MAIL_INBOX_ZUGFERD_PATH', 'zugferd'), + 'pdf_password' => env('MAIL_INBOX_PDF_PASSWORD'), + ], + +]; diff --git a/packages/mail-inbox/database/migrations/create_inbox_attachments_table.php.stub b/packages/mail-inbox/database/migrations/create_inbox_attachments_table.php.stub new file mode 100644 index 0000000000..0e9f9990a9 --- /dev/null +++ b/packages/mail-inbox/database/migrations/create_inbox_attachments_table.php.stub @@ -0,0 +1,74 @@ +id(); + $table->string('scope')->index(); + $table->foreignId('inbox_message_id')->nullable()->constrained('inbox_messages')->nullOnDelete(); + $table->string('external_attachment_id')->nullable(); + $table->string('storage_disk'); + $table->string('storage_path'); + $table->string('filename'); + $table->string('mime_type'); + $table->string('extension')->nullable(); + $table->unsignedBigInteger('filesize')->nullable(); + $table->string('checksum')->nullable(); + $table->boolean('is_pdf')->default(false); + $table->string('attachment_role')->nullable(); + $table->string('processing_status')->default('new'); + $table->string('zugferd_storage_disk')->nullable(); + $table->string('zugferd_storage_path')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + }); + + $this->applyBinaryCollation('inbox_attachments', ['external_attachment_id']); + } + + public function down(): void + { + Schema::dropIfExists('inbox_attachments'); + } + + /** + * @param list $columns + */ + private function applyBinaryCollation(string $table, array $columns): void + { + if (! in_array(Schema::getConnection()->getDriverName(), ['mysql', 'mariadb'], true)) { + return; + } + + if (! Schema::hasTable($table)) { + return; + } + + $physicalTable = $this->backtickIdentifier($this->physicalTableName($table)); + + foreach ($columns as $column) { + $columnSql = $this->backtickIdentifier($column); + DB::statement("ALTER TABLE {$physicalTable} MODIFY {$columnSql} VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL"); + } + } + + private function physicalTableName(string $table): string + { + return Schema::getConnection()->getTablePrefix().$table; + } + + private function backtickIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } +}; diff --git a/packages/mail-inbox/database/migrations/create_inbox_messages_table.php.stub b/packages/mail-inbox/database/migrations/create_inbox_messages_table.php.stub new file mode 100644 index 0000000000..683fe9c3ca --- /dev/null +++ b/packages/mail-inbox/database/migrations/create_inbox_messages_table.php.stub @@ -0,0 +1,77 @@ +id(); + $table->string('scope')->index(); + $table->string('channel')->default('email'); + $table->string('external_id')->nullable()->index(); + $table->string('message_id')->nullable(); + $table->string('from_email')->nullable(); + $table->string('from_name')->nullable(); + $table->string('to_email')->nullable(); + $table->string('to_name')->nullable(); + $table->string('subject')->nullable(); + $table->timestamp('received_at')->nullable(); + $table->json('raw_headers')->nullable(); + $table->longText('raw_body_text')->nullable(); + $table->longText('raw_body_html')->nullable(); + $table->boolean('has_attachments')->default(false); + $table->string('processing_status')->default('new'); + $table->timestamp('processed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->unique(['scope', 'external_id']); + $table->unique(['scope', 'message_id'], 'inbox_messages_scope_message_id_unique'); + }); + + $this->applyBinaryCollation('inbox_messages', ['external_id', 'message_id']); + } + + public function down(): void + { + Schema::dropIfExists('inbox_messages'); + } + + /** + * @param list $columns + */ + private function applyBinaryCollation(string $table, array $columns): void + { + if (! in_array(Schema::getConnection()->getDriverName(), ['mysql', 'mariadb'], true)) { + return; + } + + if (! Schema::hasTable($table)) { + return; + } + + $physicalTable = $this->backtickIdentifier($this->physicalTableName($table)); + + foreach ($columns as $column) { + $columnSql = $this->backtickIdentifier($column); + DB::statement("ALTER TABLE {$physicalTable} MODIFY {$columnSql} VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL"); + } + } + + private function physicalTableName(string $table): string + { + return Schema::getConnection()->getTablePrefix().$table; + } + + private function backtickIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } +}; diff --git a/packages/mail-inbox/database/migrations/create_mail_inbox_sync_states_table.php.stub b/packages/mail-inbox/database/migrations/create_mail_inbox_sync_states_table.php.stub new file mode 100644 index 0000000000..4627709927 --- /dev/null +++ b/packages/mail-inbox/database/migrations/create_mail_inbox_sync_states_table.php.stub @@ -0,0 +1,25 @@ +string('scope')->primary(); + $table->text('delta_link')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_inbox_sync_states'); + } +}; diff --git a/packages/mail-inbox/screenshot/main.jpg b/packages/mail-inbox/screenshot/main.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/mail-inbox/screenshot/main.jpg differ diff --git a/packages/mail-inbox/src/Commands/FetchMailCommand.php b/packages/mail-inbox/src/Commands/FetchMailCommand.php new file mode 100644 index 0000000000..34aeeb3d99 --- /dev/null +++ b/packages/mail-inbox/src/Commands/FetchMailCommand.php @@ -0,0 +1,50 @@ +option('scope'); + $limitOption = $this->option('limit'); + $hadLimitOverride = $limitOption !== null && $limitOption !== ''; + $previousLimit = $hadLimitOverride ? config('mail-inbox.fetch_limit') : null; + + if ($hadLimitOverride) { + Config::set('mail-inbox.fetch_limit', (int) $limitOption); + } + + try { + FetchMailsJob::dispatchSync($scope); + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] Fetch command failed', [ + 'exception' => $e, + 'scope' => $scope, + ]); + $this->error($e->getMessage()); + + return Command::FAILURE; + } finally { + if ($hadLimitOverride) { + Config::set('mail-inbox.fetch_limit', $previousLimit); + } + } + + $this->info("FetchMailsJob completed for scope [{$scope}] (attachment and PDF jobs may still be on the queue)."); + + return Command::SUCCESS; + } +} diff --git a/packages/mail-inbox/src/Commands/PollMailCommand.php b/packages/mail-inbox/src/Commands/PollMailCommand.php new file mode 100644 index 0000000000..b44f402fc3 --- /dev/null +++ b/packages/mail-inbox/src/Commands/PollMailCommand.php @@ -0,0 +1,38 @@ +option('scope'); + + try { + FetchMailsJob::dispatch($scope); + Log::channel('mail-inbox')->debug('[MailInbox] Poll dispatched FetchMailsJob', ['scope' => $scope]); + $this->line("[{$scope}] FetchMailsJob dispatched to queue."); + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] Poll command failed', [ + 'exception' => $e, + 'scope' => $scope, + ]); + $this->error($e->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/packages/mail-inbox/src/Commands/ProcessMailCommand.php b/packages/mail-inbox/src/Commands/ProcessMailCommand.php new file mode 100644 index 0000000000..6bc61bc598 --- /dev/null +++ b/packages/mail-inbox/src/Commands/ProcessMailCommand.php @@ -0,0 +1,96 @@ +option('scope'); + + try { + if ($this->option('message') !== null && $this->option('message') !== '') { + return $this->handleSingleMessage($service, $scope); + } + + if ($this->option('retry-failed')) { + $retried = $service->retryFailedMessages($scope); + $this->info("Retried {$retried} failed message(s) for scope [{$scope}]."); + } else { + $processed = $service->processNewMessages($scope); + $this->info("Processed {$processed} new message(s) in scope [{$scope}]."); + } + + $summary = $service->attachmentTerminalCountsForScope($scope); + $this->table( + ['Metric', 'Count'], + [ + ['Attachments processed', (string) $summary['processed']], + ['Attachments failed', (string) $summary['failed']], + ['Attachments skipped', (string) $summary['skipped']], + ] + ); + + return Command::SUCCESS; + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] Process command failed', [ + 'exception' => $e, + 'scope' => $scope, + ]); + $this->error($e->getMessage()); + + return Command::FAILURE; + } + } + + private function handleSingleMessage(MailInboxService $service, string $scope): int + { + $id = (int) $this->option('message'); + $message = InboxMessage::query()->where('scope', $scope)->find($id); + + if ($message === null) { + $this->error("No inbox message with id [{$id}] for scope [{$scope}]."); + + return Command::FAILURE; + } + + try { + $service->enqueueParseJobsForInboxMessage($message); + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] Process single message failed', [ + 'exception' => $e, + 'inbox_message_id' => $id, + 'scope' => $scope, + ]); + $this->error($e->getMessage()); + + return Command::FAILURE; + } + + $message->refresh(); + $this->info("Processed message [{$id}] — status: {$message->processing_status}"); + $summary = $service->attachmentTerminalCountsForMessage($message); + $this->table( + ['Metric', 'Count'], + [ + ['Attachments processed', (string) $summary['processed']], + ['Attachments failed', (string) $summary['failed']], + ['Attachments skipped', (string) $summary['skipped']], + ] + ); + + return Command::SUCCESS; + } +} diff --git a/packages/mail-inbox/src/Commands/StatusCommand.php b/packages/mail-inbox/src/Commands/StatusCommand.php new file mode 100644 index 0000000000..f27150f010 --- /dev/null +++ b/packages/mail-inbox/src/Commands/StatusCommand.php @@ -0,0 +1,40 @@ +option('scope'); + $breakdown = $service->inboxStatusBreakdown($scope); + + $rows = []; + foreach (InboxMessageProcessingStatus::cases() as $case) { + $status = $case->value; + [$messages, $attachments] = $breakdown[$status]; + $rows[] = [$status, (string) $messages, (string) $attachments]; + } + + $this->table(['Status', 'Messages', 'Attachments'], $rows); + + $received = $service->latestReceivedAtForScope($scope); + $processed = $service->latestProcessedAtForScope($scope); + + $this->newLine(); + $this->info('Latest received: '.($received?->toIso8601String() ?? '—')); + $this->info('Latest processed: '.($processed?->toIso8601String() ?? '—')); + + return Command::SUCCESS; + } +} diff --git a/packages/mail-inbox/src/DeltaPage.php b/packages/mail-inbox/src/DeltaPage.php new file mode 100644 index 0000000000..bd5dee94ff --- /dev/null +++ b/packages/mail-inbox/src/DeltaPage.php @@ -0,0 +1,23 @@ + $messages Delta `value` entries without @removed placeholders. + */ + public function __construct( + public array $messages, + public ?string $nextLink, + public ?string $deltaLink, + public int $removedFiltered, + ) {} +} diff --git a/packages/mail-inbox/src/DeltaPersistResult.php b/packages/mail-inbox/src/DeltaPersistResult.php new file mode 100644 index 0000000000..deab818d99 --- /dev/null +++ b/packages/mail-inbox/src/DeltaPersistResult.php @@ -0,0 +1,14 @@ + + */ + public array $backoff = [60, 300]; + + public function __construct( + public string $scope = 'default', + ) {} + + public function handle(GraphMailService $graph, MailInboxService $inbox): void + { + $this->applyMemoryLimit(); + $this->setProgress(0); + + $maxPages = max(1, (int) config('mail-inbox.delta_max_pages_per_poll', 50)); + + $syncState = MailInboxSyncState::query()->firstOrCreate( + ['scope' => $this->scope], + ['delta_link' => null, 'last_synced_at' => null], + ); + + /** @var string|null $continuationUrl null starts a full delta sync round */ + $continuationUrl = $syncState->delta_link; + + $pagesThisPoll = 0; + $persistedTotal = 0; + $skippedKnownTotal = 0; + $skippedNoAttachmentsTotal = 0; + $removedFilteredTotal = 0; + + while (true) { + try { + $page = $graph->fetchInboxMessagesViaDelta($continuationUrl); + } catch (GraphSyncStateNotFoundException $e) { + Log::channel('mail-inbox')->warning('[MailInbox] Delta sync state not found — clearing token for full resync', [ + 'scope' => $this->scope, + 'exception' => $e, + ]); + $syncState->update(['delta_link' => null]); + $continuationUrl = null; + + continue; + } + + $pagesThisPoll++; + + $result = $inbox->persistDeltaMessages($page->messages, $this->scope); + $persistedTotal += $result->persisted; + $skippedKnownTotal += $result->skippedKnown; + $skippedNoAttachmentsTotal += $result->skippedNoAttachments; + $removedFilteredTotal += $page->removedFiltered; + + foreach ($page->messages as $graphMessage) { + if (DeltaMessageInspector::isRemovedPlaceholder($graphMessage)) { + continue; + } + + $messageId = $graphMessage->getId(); + if ($messageId === null || $messageId === '') { + continue; + } + + try { + $graph->moveGraphMessageToProcessingFolder($messageId, $this->scope); + } catch (Throwable $e) { + Log::channel('mail-inbox')->warning('[MailInbox] move to Processing folder failed (best-effort, will retry on next delta)', [ + 'messageId' => $messageId, + 'scope' => $this->scope, + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + ]); + } + } + + $progressCap = max(1, min($pagesThisPoll + 3, $maxPages + 2)); + $this->setProgress((int) min(99, round(($pagesThisPoll / $progressCap) * 100))); + + $deltaUrl = $page->deltaLink; + if ($deltaUrl !== null && $deltaUrl !== '') { + $syncState->update([ + 'delta_link' => $deltaUrl, + 'last_synced_at' => now(), + ]); + + break; + } + + $next = $page->nextLink; + if ($next === null || $next === '') { + Log::channel('mail-inbox')->warning('[MailInbox] Delta page missing both deltaLink and nextLink', [ + 'scope' => $this->scope, + ]); + + break; + } + + if ($pagesThisPoll >= $maxPages) { + Log::channel('mail-inbox')->warning('[MailInbox] Delta poll reached delta_max_pages_per_poll; deferring continuation to next poll', [ + 'scope' => $this->scope, + 'delta_max_pages_per_poll' => $maxPages, + ]); + $syncState->update(['delta_link' => $next]); + + break; + } + + $continuationUrl = $next; + } + + Log::channel('mail-inbox')->info('[MailInbox] Delta sync complete', [ + 'scope' => $this->scope, + 'persisted' => $persistedTotal, + 'skipped_known' => $skippedKnownTotal, + 'skipped_no_attachments' => $skippedNoAttachmentsTotal, + 'removed_filtered' => $removedFilteredTotal, + 'total_pages' => $pagesThisPoll, + ]); + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + Log::channel('mail-inbox')->error('[MailInbox] FetchMailsJob failed', [ + 'exception' => $exception, + 'scope' => $this->scope, + ]); + } + + private function applyMemoryLimit(): void + { + ini_set('memory_limit', (string) config('mail-inbox.memory_limit', '512M')); + } +} diff --git a/packages/mail-inbox/src/Jobs/HandleFailedJob.php b/packages/mail-inbox/src/Jobs/HandleFailedJob.php new file mode 100644 index 0000000000..3efc3bcd11 --- /dev/null +++ b/packages/mail-inbox/src/Jobs/HandleFailedJob.php @@ -0,0 +1,94 @@ +setProgress(0); + + if ($this->inboxMessageId === null) { + $this->setProgress(100); + + return; + } + + $message = InboxMessage::query()->find($this->inboxMessageId); + + if ($message === null) { + Log::channel('mail-inbox')->warning('[MailInbox] HandleFailedJob: inbox message not found', [ + 'inbox_message_id' => $this->inboxMessageId, + ]); + $this->setProgress(100); + + return; + } + + $errorText = $this->errorMessage !== '' ? $this->errorMessage : 'Job failed'; + + if ($message->hasAttachmentsPendingOrProcessing()) { + $message->markAsPartiallyFailed($errorText); + Log::channel('mail-inbox')->warning('[MailInbox] HandleFailedJob: attachments still pending or processing; message marked partially_failed', [ + 'inbox_message_id' => $message->id, + 'error' => $errorText, + ]); + $this->setProgress(100); + + return; + } + + $externalId = $message->external_id; + if ($externalId !== null && $externalId !== '') { + try { + $graph->moveGraphMessageToProcessedOrFailedFolder($externalId, false, $message->scope ?? 'default'); + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] HandleFailedJob: could not move message to Failed folder', [ + 'exception' => $e, + 'inbox_message_id' => $message->id, + 'external_id' => $externalId, + ]); + } + } + + $message->markAsFailed($errorText); + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + Log::channel('mail-inbox')->error('[MailInbox] HandleFailedJob itself failed', [ + 'exception' => $exception, + 'inbox_message_id' => $this->inboxMessageId, + ]); + } +} diff --git a/packages/mail-inbox/src/Jobs/ParsePdfJob.php b/packages/mail-inbox/src/Jobs/ParsePdfJob.php new file mode 100644 index 0000000000..0014ea2976 --- /dev/null +++ b/packages/mail-inbox/src/Jobs/ParsePdfJob.php @@ -0,0 +1,109 @@ +setProgress(0); + + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + + if ($attachment === null) { + Log::channel('mail-inbox')->warning('[MailInbox] ParsePdfJob: attachment not found', [ + 'inbox_attachment_id' => $this->inboxAttachmentId, + ]); + $this->setProgress(100); + + return; + } + + $message = $attachment->message; + + if ($message === null) { + Log::channel('mail-inbox')->error('[MailInbox] ParsePdfJob: attachment has no message', [ + 'inbox_attachment_id' => $attachment->id, + ]); + $this->setProgress(100); + + return; + } + + if (! $attachment->isPdf() || $attachment->processing_status !== InboxAttachmentProcessingStatus::New->value) { + $this->setProgress(100); + + return; + } + + $attachment->markAsProcessing(); + $this->setProgress(20); + + $freshAttachment = $attachment->fresh(); + $freshMessage = $message->fresh(['attachments']); + + if ($freshAttachment !== null && $freshMessage !== null) { + event(new InboxAttachmentProcessed($freshMessage, $freshAttachment)); + } + + VerifyAttachmentProgressJob::dispatch($this->inboxAttachmentId) + ->delay(now()->addMinutes((int) config('mail-inbox.listener_timeout_minutes', 5))); + + $this->setProgress(80); + + $finalMessage = InboxAttachment::query()->find($this->inboxAttachmentId)?->message; + if ($finalMessage !== null) { + $inbox->finalizeMessageProcessingAfterAttachments($finalMessage->fresh(['attachments'])); + } + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + $attachment = InboxAttachment::query()->find($this->inboxAttachmentId); + $messageId = $attachment?->inbox_message_id; + + try { + HandleFailedJob::dispatchSync( + $messageId, + $exception?->getMessage() ?? 'ParsePdfJob failed' + ); + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] HandleFailedJob also failed', [ + 'inbox_message_id' => $messageId, + 'original_error' => $exception?->getMessage(), + 'handler_error' => $e->getMessage(), + ]); + } + } +} diff --git a/packages/mail-inbox/src/Jobs/StoreAttachmentsJob.php b/packages/mail-inbox/src/Jobs/StoreAttachmentsJob.php new file mode 100644 index 0000000000..c1d9486fe0 --- /dev/null +++ b/packages/mail-inbox/src/Jobs/StoreAttachmentsJob.php @@ -0,0 +1,199 @@ + + */ + public array $backoff = [60, 300]; + + public function __construct( + public int $inboxMessageId, + ) {} + + public function handle(GraphMailService $graph, MailInboxService $inbox): void + { + $this->applyMemoryLimit(); + $this->setProgress(0); + + $message = InboxMessage::query()->find($this->inboxMessageId); + + if ($message === null) { + Log::channel('mail-inbox')->error('[MailInbox] StoreAttachmentsJob: message not found', [ + 'inbox_message_id' => $this->inboxMessageId, + ]); + $this->setProgress(100); + + return; + } + + $externalId = $message->external_id; + if ($externalId === null || $externalId === '') { + Log::channel('mail-inbox')->error('[MailInbox] Cannot fetch attachments: message has no external_id', [ + 'inbox_message_id' => $message->id, + ]); + throw new InvalidArgumentException('Inbox message has no external_id'); + } + + $attachments = $graph->fetchAttachments($externalId); + $total = max(1, $attachments->count()); + $disk = (string) config('mail-inbox.attachments.disk'); + $basePath = trim((string) config('mail-inbox.attachments.path'), '/'); + + $pdfAttachmentIds = []; + $i = 0; + + foreach ($attachments as $attachment) { + $i++; + + if (! $attachment instanceof FileAttachment) { + $this->setProgress((int) round(($i / $total) * 90)); + + continue; + } + + $attachmentId = $attachment->getId(); + if ($attachmentId === null) { + Log::channel('mail-inbox')->error('[MailInbox] Skipping attachment with null id', [ + 'message_external_id' => $externalId, + ]); + $this->setProgress((int) round(($i / $total) * 90)); + + continue; + } + + if (InboxAttachment::query() + ->where('inbox_message_id', $message->id) + ->where('external_attachment_id', $attachmentId) + ->exists() + ) { + $existing = InboxAttachment::query() + ->where('inbox_message_id', $message->id) + ->where('external_attachment_id', $attachmentId) + ->first(); + if ($existing !== null && $existing->is_pdf && $existing->processing_status === InboxAttachmentProcessingStatus::New->value) { + $pdfAttachmentIds[] = $existing->id; + } + $this->setProgress((int) round(($i / $total) * 90)); + + continue; + } + + $payload = $graph->downloadAttachmentContent($externalId, $attachmentId); + $contentBytes = $payload['contentBytes']; + $filename = $attachment->getName() ?? 'attachment'; + + $relativePath = "{$basePath}/{$message->scope}/".now()->format('Y/m/d')."/msg-{$message->id}/{$filename}"; + + Storage::disk($disk)->put($relativePath, $contentBytes); + + $checksum = hash('sha256', $contentBytes); + $mimeType = $attachment->getContentType() ?? 'application/octet-stream'; + $isPdf = $mimeType === 'application/pdf' + || str_ends_with(strtolower($filename), '.pdf'); + + $role = match (true) { + $isPdf => 'invoice_pdf', + $this->mimeLooksLikeXml($mimeType) => 'xml', + default => 'other', + }; + + $inboxAttachment = InboxAttachment::create([ + 'scope' => $message->scope, + 'inbox_message_id' => $message->id, + 'external_attachment_id' => $attachmentId, + 'storage_disk' => $disk, + 'storage_path' => $relativePath, + 'filename' => $filename, + 'mime_type' => $mimeType, + 'extension' => pathinfo($filename, PATHINFO_EXTENSION) ?: null, + 'filesize' => $attachment->getSize(), + 'checksum' => $checksum, + 'is_pdf' => $isPdf, + 'attachment_role' => $role, + 'processing_status' => InboxAttachmentProcessingStatus::New->value, + ]); + + if (! $isPdf) { + $inboxAttachment->markAsSkipped(); + } else { + $pdfAttachmentIds[] = $inboxAttachment->id; + } + + $this->setProgress((int) round(($i / $total) * 90)); + } + + $message->refresh(); + + if ($message->pdfAttachments()->exists()) { + foreach ($pdfAttachmentIds as $pdfId) { + ParsePdfJob::dispatch($pdfId); + } + } else { + $inbox->finalizeMessageProcessingAfterAttachments($message->fresh(['attachments'])); + } + + $this->setProgress(100); + } + + public function failed(?Throwable $exception = null): void + { + try { + HandleFailedJob::dispatchSync( + $this->inboxMessageId, + $exception?->getMessage() ?? 'StoreAttachmentsJob failed' + ); + } catch (Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] HandleFailedJob also failed', [ + 'inbox_message_id' => $this->inboxMessageId, + 'original_error' => $exception?->getMessage(), + 'handler_error' => $e->getMessage(), + ]); + } + } + + private function applyMemoryLimit(): void + { + ini_set('memory_limit', (string) config('mail-inbox.memory_limit', '512M')); + } + + private function mimeLooksLikeXml(string $mimeType): bool + { + $lower = strtolower($mimeType); + + return str_contains($lower, 'xml') + || $lower === 'application/xml' + || $lower === 'text/xml'; + } +} diff --git a/packages/mail-inbox/src/Jobs/VerifyAttachmentProgressJob.php b/packages/mail-inbox/src/Jobs/VerifyAttachmentProgressJob.php new file mode 100644 index 0000000000..3d9d344190 --- /dev/null +++ b/packages/mail-inbox/src/Jobs/VerifyAttachmentProgressJob.php @@ -0,0 +1,61 @@ +find($this->attachmentId); + if ($attachment === null) { + return; + } + + if ($attachment->processing_status === InboxAttachmentProcessingStatus::Processing->value) { + Log::channel('mail-inbox')->warning('[MailInbox] PDF still in processing after InboxAttachmentProcessed (no listener handled it)', [ + 'inbox_attachment_id' => $attachment->id, + 'listener_timeout_minutes' => (int) config('mail-inbox.listener_timeout_minutes', 5), + ]); + + $attachment->markAsFailed(self::STALLED_ATTACHMENT_ERROR); + $attachment->refresh(); + } + + if ($attachment->processing_status !== InboxAttachmentProcessingStatus::Failed->value + || $attachment->error_message !== self::STALLED_ATTACHMENT_ERROR + ) { + return; + } + + $externalId = $attachment->message?->external_id; + if ($externalId === null || $externalId === '') { + return; + } + + $scope = (string) ($attachment->scope ?? $attachment->message?->scope ?? 'default'); + $graphMailService->moveGraphMessageToIgnoredFolder($externalId, 'Ignored', $scope); + } +} diff --git a/packages/mail-inbox/src/MailInboxServiceProvider.php b/packages/mail-inbox/src/MailInboxServiceProvider.php new file mode 100644 index 0000000000..23c0cb903e --- /dev/null +++ b/packages/mail-inbox/src/MailInboxServiceProvider.php @@ -0,0 +1,94 @@ +name('mail-inbox') + ->hasConfigFile() + ->hasMigrations([ + 'create_inbox_messages_table', + 'create_inbox_attachments_table', + 'create_mail_inbox_sync_states_table', + ]) + ->hasCommands([ + FetchMailCommand::class, + ProcessMailCommand::class, + PollMailCommand::class, + StatusCommand::class, + ]); + + $this->getMooxPackage() + ->title('Moox MailInbox') + ->released(false) + ->stability('dev') + ->category('billing') + ->usedFor([ + 'Microsoft Graph mailbox polling for inbound mail', + ]); + } + + public function register(): void + { + parent::register(); + + if (config('logging.channels.mail-inbox') === null) { + config()->set('logging.channels.mail-inbox', [ + 'driver' => 'single', + 'path' => storage_path('logs/mail-inbox.log'), + 'level' => config('logging.channels.single.level', 'debug'), + 'replace_placeholders' => true, + ]); + } + + $this->app->singleton(GraphServiceClient::class, fn (): GraphServiceClient => MailInboxGraphServiceClientFactory::make( + new ClientCredentialContext( + (string) config('mail-inbox.graph.tenant_id'), + (string) config('mail-inbox.graph.client_id'), + (string) config('mail-inbox.graph.client_secret'), + ), + )); + + $this->app->singleton(GraphMailService::class, function ($app): GraphMailService { + return new GraphMailService($app->make(GraphServiceClient::class)); + }); + + $this->app->singleton(MailInboxService::class); + } + + public function boot(): void + { + parent::boot(); + + if ($this->app->runningInConsole()) { + $this->app->booted(function () { + $schedule = $this->app->make(Schedule::class); + $interval = max(1, min(59, (int) config('mail-inbox.poll_interval', 5))); + + $schedule->command('mail-inbox:poll') + ->cron("*/{$interval} * * * *") + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/mail-inbox.log')); + }); + } + } +} diff --git a/packages/mail-inbox/src/Models/InboxAttachment.php b/packages/mail-inbox/src/Models/InboxAttachment.php new file mode 100644 index 0000000000..6c92167e45 --- /dev/null +++ b/packages/mail-inbox/src/Models/InboxAttachment.php @@ -0,0 +1,101 @@ + + */ + protected $fillable = [ + 'attachment_role', + 'checksum', + 'created_at', + 'error_message', + 'extension', + 'external_attachment_id', + 'filesize', + 'filename', + 'inbox_message_id', + 'is_pdf', + 'mime_type', + 'processed_at', + 'processing_status', + 'scope', + 'storage_disk', + 'storage_path', + 'zugferd_storage_disk', + 'zugferd_storage_path', + 'updated_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'filesize' => 'integer', + 'is_pdf' => 'boolean', + 'processed_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function message(): BelongsTo + { + return $this->belongsTo(InboxMessage::class, 'inbox_message_id'); + } + + public function isPdf(): bool + { + return $this->is_pdf + || $this->mime_type === 'application/pdf' + || str_ends_with(strtolower($this->filename), '.pdf'); + } + + public function fullPath(): string + { + return Storage::disk($this->storage_disk)->path($this->storage_path); + } + + public function markAsProcessing(): void + { + $this->processing_status = InboxAttachmentProcessingStatus::Processing->value; + $this->save(); + } + + public function markAsProcessed(): void + { + $this->processing_status = InboxAttachmentProcessingStatus::Processed->value; + $this->processed_at = now(); + $this->save(); + } + + public function markAsFailed(string $error): void + { + $this->processing_status = InboxAttachmentProcessingStatus::Failed->value; + $this->error_message = $error; + $this->save(); + } + + public function markAsSkipped(): void + { + $this->processing_status = InboxAttachmentProcessingStatus::Skipped->value; + $this->save(); + } +} diff --git a/packages/mail-inbox/src/Models/InboxMessage.php b/packages/mail-inbox/src/Models/InboxMessage.php new file mode 100644 index 0000000000..2fb15fef95 --- /dev/null +++ b/packages/mail-inbox/src/Models/InboxMessage.php @@ -0,0 +1,147 @@ + + */ + protected $fillable = [ + 'channel', + 'created_at', + 'error_message', + 'external_id', + 'from_email', + 'from_name', + 'has_attachments', + 'message_id', + 'processed_at', + 'processing_status', + 'raw_body_html', + 'raw_body_text', + 'raw_headers', + 'received_at', + 'scope', + 'subject', + 'to_email', + 'to_name', + 'updated_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'has_attachments' => 'boolean', + 'processed_at' => 'datetime', + 'raw_headers' => 'array', + 'received_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function attachments(): HasMany + { + return $this->hasMany(InboxAttachment::class, 'inbox_message_id'); + } + + /** + * @param Builder $query + */ + public function scopeFailed(Builder $query): void + { + $query->whereIn('processing_status', [ + InboxMessageProcessingStatus::Failed->value, + InboxMessageProcessingStatus::PartiallyFailed->value, + ]); + } + + /** + * @param Builder $query + */ + public function scopeForScope(Builder $query, string $scope): void + { + $query->where('scope', $scope); + } + + /** + * @param Builder $query + */ + public function scopeNew(Builder $query): void + { + $query->where('processing_status', InboxMessageProcessingStatus::New->value); + } + + /** + * @param Builder $query + */ + public function scopeWithChannel(Builder $query, string $channel): void + { + $query->where('channel', $channel); + } + + public function markAsRead(): void + { + $this->processing_status = InboxMessageProcessingStatus::Read->value; + $this->save(); + } + + public function markAsProcessed(): void + { + $this->processing_status = InboxMessageProcessingStatus::Processed->value; + $this->processed_at = now(); + $this->save(); + } + + public function markAsFailed(string $error): void + { + $this->processing_status = InboxMessageProcessingStatus::Failed->value; + $this->error_message = $error; + $this->save(); + } + + public function markAsPartiallyFailed(string $error): void + { + $this->processing_status = InboxMessageProcessingStatus::PartiallyFailed->value; + $this->error_message = $error; + $this->save(); + } + + public function hasAttachmentsPendingOrProcessing(): bool + { + return $this->attachments() + ->whereIn('processing_status', [ + InboxAttachmentProcessingStatus::New->value, + InboxAttachmentProcessingStatus::Processing->value, + ]) + ->exists(); + } + + /** + * @return HasMany + */ + public function pdfAttachments(): HasMany + { + return $this->attachments()->where('is_pdf', true); + } +} diff --git a/packages/mail-inbox/src/Models/MailInboxSyncState.php b/packages/mail-inbox/src/Models/MailInboxSyncState.php new file mode 100644 index 0000000000..ac1fc62dd3 --- /dev/null +++ b/packages/mail-inbox/src/Models/MailInboxSyncState.php @@ -0,0 +1,42 @@ + + */ + protected $fillable = [ + 'scope', + 'delta_link', + 'last_synced_at', + 'updated_at', + 'created_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'last_synced_at' => 'datetime', + ]; + } +} diff --git a/packages/mail-inbox/src/Services/GraphMailService.php b/packages/mail-inbox/src/Services/GraphMailService.php new file mode 100644 index 0000000000..e2e036ccde --- /dev/null +++ b/packages/mail-inbox/src/Services/GraphMailService.php @@ -0,0 +1,691 @@ +client = $client ?? $this->buildDefaultClient(); + } + + /** + * Fetches one page of inbox mail via Graph delta (`messages/delta` or a persisted `@odata.deltaLink` / `@odata.nextLink` URL). + */ + public function fetchInboxMessagesViaDelta(?string $deltaLink): DeltaPage + { + return $this->wrapGraphCall(function () use ($deltaLink): DeltaPage { + $deltaBuilder = ($deltaLink !== null && $deltaLink !== '') + ? $this->client->users() + ->byUserId($this->mailbox()) + ->mailFolders() + ->byMailFolderId('inbox') + ->messages() + ->delta() + ->withUrl($deltaLink) + : $this->client->users() + ->byUserId($this->mailbox()) + ->mailFolders() + ->byMailFolderId('inbox') + ->messages() + ->delta(); + + $requestConfiguration = null; + if ($deltaLink === null || $deltaLink === '') { + $query = new DeltaRequestBuilderGetQueryParameters( + select: $this->deltaSelectFields(), + top: 50, + ); + $requestConfiguration = new DeltaRequestBuilderGetRequestConfiguration(null, null, $query); + } + + $result = $deltaBuilder->get($requestConfiguration)->wait(); + if ($result === null) { + throw new GraphException('Graph delta returned null response.'); + } + + $removedFiltered = 0; + $messages = []; + foreach ($result->getValue() ?? [] as $item) { + if (! $item instanceof Message) { + continue; + } + if (DeltaMessageInspector::isRemovedPlaceholder($item)) { + $removedFiltered++; + + continue; + } + $messages[] = $item; + } + + $next = $result->getOdataNextLink(); + $finalDelta = $result->getOdataDeltaLink(); + $hasNext = $next !== null && $next !== ''; + $hasFinal = $finalDelta !== null && $finalDelta !== ''; + + if (($hasNext && $hasFinal) || (! $hasNext && ! $hasFinal)) { + throw new GraphException('Graph delta page must expose exactly one of @odata.nextLink or @odata.deltaLink.'); + } + + return new DeltaPage( + messages: $messages, + nextLink: $hasNext ? $next : null, + deltaLink: $hasFinal ? $finalDelta : null, + removedFiltered: $removedFiltered, + ); + }, 'fetchInboxMessagesViaDelta'); + } + + public function fetchAttachments(string $messageId): Collection + { + return $this->wrapGraphCall(function () use ($messageId) { + $result = $this->client + ->users() + ->byUserId($this->mailbox()) + ->messages() + ->byMessageId($messageId) + ->attachments() + ->get() + ->wait(); + + if ($result === null || $result->getValue() === null) { + return collect(); + } + + return collect($result->getValue())->filter( + fn ($attachment) => $attachment instanceof FileAttachment + ); + }, 'fetchAttachments'); + } + + /** + * @return array{name: string|null, contentType: string|null, size: int|null, contentBytes: string} + */ + public function downloadAttachmentContent(string $messageId, string $attachmentId): array + { + return $this->wrapGraphCall(function () use ($messageId, $attachmentId) { + $attachment = $this->client + ->users() + ->byUserId($this->mailbox()) + ->messages() + ->byMessageId($messageId) + ->attachments() + ->byAttachmentId($attachmentId) + ->get() + ->wait(); + + if (! $attachment instanceof FileAttachment) { + throw new InvalidArgumentException("Attachment {$attachmentId} is not a file attachment"); + } + + return [ + 'name' => $attachment->getName(), + 'contentType' => $attachment->getContentType(), + 'size' => $attachment->getSize(), + 'contentBytes' => $this->binaryContentFromFileAttachment($attachment), + ]; + }, 'downloadAttachmentContent'); + } + + public function markMessageAsRead(string $messageId): void + { + $this->wrapGraphCall(function () use ($messageId) { + $body = new Message; + $body->setIsRead(true); + + $this->client + ->users() + ->byUserId($this->mailbox()) + ->messages() + ->byMessageId($messageId) + ->patch($body) + ->wait(); + }, 'markMessageAsRead'); + } + + /** + * Moves a message to a folder by Graph folder id. Enforces an acceptable pipeline parent (well-known Inbox or + * optional Processing folder) before calling Graph, so terminal folders and arbitrary locations are not pulled + * back into the pipeline. {@see InboxMessagePipelineFinalizer} calls this method directly and inherits the guard. + * + * Idempotent: when the message already sits in {@code $destinationFolderId}, logs at **debug** and returns before + * any source check (re-finalizing into Processed must not warn). + * + * @param ?string $scope Mail-ingest scope (delta / {@see InboxMessage::scope}); defaults to {@code default}. + */ + public function moveMessageToFolder(string $messageId, string $destinationFolderId, ?string $scope = null): void + { + $scope = $scope ?? 'default'; + + $currentParentId = $this->getMessageParentFolderId($messageId); + + if ($currentParentId !== null && $currentParentId === $destinationFolderId) { + Log::channel('mail-inbox')->debug('[MailInbox] Message already in destination folder; skipping move', [ + 'messageId' => $messageId, + 'destinationFolderId' => $destinationFolderId, + 'scope' => $scope, + ]); + + return; + } + + try { + $inboxFolderId = $this->cachedInboxFolderId($scope); + } catch (Throwable $e) { + $context = [ + 'messageId' => $messageId, + 'scope' => $scope, + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + ]; + if ($this->looksLikeGraphFolderResolutionFailure($e)) { + $context['hint'] = 'Could not resolve the well-known Inbox folder id — verify MAIL_INBOX_MAILBOX and Graph permissions for this scope/mailbox.'; + } + Log::channel('mail-inbox')->warning('[MailInbox] Skipping move: Inbox folder id unavailable for pipeline guard', $context); + + return; + } + + try { + $acceptable = $this->parentIsAcceptablePipelineSource($currentParentId, $inboxFolderId); + } catch (Throwable $e) { + Log::channel('mail-inbox')->warning('[MailInbox] Skipping move: could not resolve folder ids for pipeline guard', [ + 'messageId' => $messageId, + 'scope' => $scope, + 'parentFolderId' => $currentParentId, + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'hint' => $this->processingFolderMisconfigurationHint($e), + ]); + + return; + } + + if (! $acceptable) { + $this->logUnexpectedParentForMove($messageId, $currentParentId, $scope); + + return; + } + + $this->postGraphMoveMessageToFolder($messageId, $destinationFolderId); + } + + /** + * Best-effort move into the optional {@see config('mail-inbox.processing_folder')} folder after delta persist. + */ + public function moveGraphMessageToProcessingFolder(string $messageId, string $scope): void + { + $folderName = config('mail-inbox.processing_folder'); + if ($folderName === null || $folderName === '') { + Log::channel('mail-inbox')->debug('[MailInbox] processing folder not configured, skipping move', [ + 'messageId' => $messageId, + 'scope' => $scope, + ]); + + return; + } + + try { + $destinationId = $this->getOrCreateFolder((string) $folderName); + } catch (Throwable $e) { + Log::channel('mail-inbox')->warning('[MailInbox] move to Processing folder failed (folder resolution)', [ + 'messageId' => $messageId, + 'scope' => $scope, + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'hint' => $this->processingFolderMisconfigurationHint($e), + ]); + + return; + } + + try { + $this->moveMessageToFolder($messageId, $destinationId, $scope); + } catch (Throwable $e) { + Log::channel('mail-inbox')->warning('[MailInbox] move to Processing folder failed', [ + 'messageId' => $messageId, + 'scope' => $scope, + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'hint' => $this->processingFolderMisconfigurationHint($e), + ]); + } + } + + /** + * Moves a message into {@see config('mail-inbox.processed_folder')} or {@see config('mail-inbox.failed_folder')}. + * + * @param ?string $scope Defaults to {@code default} when omitted (e.g. legacy callers). + */ + public function moveGraphMessageToProcessedOrFailedFolder(string $messageId, bool $success, ?string $scope = null): void + { + $folderConfig = $success ? 'mail-inbox.processed_folder' : 'mail-inbox.failed_folder'; + $destinationId = $this->getOrCreateFolder((string) config($folderConfig)); + $this->moveMessageToFolder($messageId, $destinationId, $scope); + } + + /** + * Moves a message into an Ignored (or equivalent) folder by display name. + * + * @param ?string $scope Defaults to {@code default} when omitted. + */ + public function moveGraphMessageToIgnoredFolder(string $messageId, string $ignoredFolderDisplayName, ?string $scope = null): void + { + $destinationId = $this->getOrCreateFolder($ignoredFolderDisplayName); + $this->moveMessageToFolder($messageId, $destinationId, $scope); + } + + /** + * Current parent folder id for idempotent moves (see {@see moveMessageToFolderByName()}). + */ + public function getMessageParentFolderId(string $messageId): ?string + { + return $this->wrapGraphCall(function () use ($messageId) { + $config = new MessageItemRequestBuilderGetRequestConfiguration; + $config->queryParameters = new MessageItemRequestBuilderGetQueryParameters( + select: ['parentFolderId'], + ); + + $message = $this->client + ->users() + ->byUserId($this->mailbox()) + ->messages() + ->byMessageId($messageId) + ->get($config) + ->wait(); + + return $message?->getParentFolderId(); + }, 'getMessageParentFolderId'); + } + + /** + * Resolves a mail folder by display name (optionally creating it), then moves the message there. + * + * Before calling Graph move, {@see moveMessageToFolder()} compares the message's current parent to the + * destination id (debug no-op when equal) and enforces an acceptable pipeline source (Inbox or optional Processing). + */ + public function moveMessageToFolderByName(string $messageId, string $folderName, bool $createIfMissing = true, ?string $scope = null): void + { + if ($createIfMissing) { + $folderId = $this->getOrCreateFolder($folderName); + $this->moveMessageToFolder($messageId, $folderId, $scope); + + return; + } + + $escapedName = $this->escapeODataSingleQuotedString($folderName); + + $listConfig = new MailFoldersRequestBuilderGetRequestConfiguration; + $listConfig->queryParameters = new MailFoldersRequestBuilderGetQueryParameters( + filter: "displayName eq '{$escapedName}'", + ); + + $result = $this->wrapGraphCall( + fn () => $this->client + ->users() + ->byUserId($this->mailbox()) + ->mailFolders() + ->get($listConfig) + ->wait(), + 'moveMessageToFolderByName.list' + ); + + $folders = $result?->getValue() ?? []; + if ($folders === []) { + throw new GraphException("Mail folder \"{$folderName}\" not found."); + } + + $id = $folders[0]->getId(); + if ($id === null) { + throw new GraphException("Mail folder \"{$folderName}\" has no id."); + } + + $this->moveMessageToFolder($messageId, $id, $scope); + } + + public function getOrCreateFolder(string $folderName): string + { + $cacheKey = 'mail-inbox:folder:'.$this->mailbox().':'.$folderName; + + return Cache::remember($cacheKey, now()->addHours(24), function () use ($folderName) { + $escapedName = $this->escapeODataSingleQuotedString($folderName); + + $listConfig = new MailFoldersRequestBuilderGetRequestConfiguration; + $listConfig->queryParameters = new MailFoldersRequestBuilderGetQueryParameters( + filter: "displayName eq '{$escapedName}'", + ); + + $result = $this->wrapGraphCall( + fn () => $this->client + ->users() + ->byUserId($this->mailbox()) + ->mailFolders() + ->get($listConfig) + ->wait(), + 'getOrCreateFolder.list' + ); + + $folders = $result?->getValue() ?? []; + if ($folders !== []) { + $id = $folders[0]->getId(); + if ($id !== null) { + return $id; + } + } + + $newFolder = new MailFolder; + $newFolder->setDisplayName($folderName); + + $created = $this->wrapGraphCall( + fn () => $this->client + ->users() + ->byUserId($this->mailbox()) + ->mailFolders() + ->post($newFolder) + ->wait(), + 'getOrCreateFolder.create' + ); + + $id = $created?->getId(); + if ($id === null) { + throw new GraphException('Graph API did not return a folder id after create.'); + } + + return $id; + }); + } + + private function buildDefaultClient(): GraphServiceClient + { + return MailInboxGraphServiceClientFactory::make(new ClientCredentialContext( + (string) config('mail-inbox.graph.tenant_id'), + (string) config('mail-inbox.graph.client_id'), + (string) config('mail-inbox.graph.client_secret'), + )); + } + + private function mailbox(): string + { + return (string) config('mail-inbox.mailbox'); + } + + /** + * @param callable(): mixed $call + */ + private function wrapGraphCall(callable $call, string $operation): mixed + { + Log::channel('mail-inbox')->debug('[MailInbox] Graph API: '.$operation); + + try { + return $call(); + } catch (ApiException $e) { + $statusCode = $e->getResponseStatusCode() ?? $e->getCode(); + + Log::channel('mail-inbox')->error('[MailInbox] Graph API error: '.$operation, [ + 'exception' => $e, + 'status' => $statusCode, + ]); + + // Extract actual Graph API error details + $errorMessage = $e->getMessage(); + $odataCode = null; + if ($e instanceof ODataError) { + $mainError = $e->getError(); + if ($mainError) { + $odataCode = $mainError->getCode(); + $errorMessage = $odataCode.': '.$mainError->getMessage(); + } + } + Log::channel('mail-inbox')->error('[MailInbox] Graph API error detail: '.$errorMessage); + + throw match (true) { + $statusCode === 401 || $statusCode === 403 => new GraphAuthenticationException( + 'Graph API auth failed. Check MAIL_INBOX_TENANT_ID, MAIL_INBOX_CLIENT_ID, MAIL_INBOX_CLIENT_SECRET in .env', + $statusCode, + $e + ), + $odataCode !== null + && strcasecmp((string) $odataCode, 'syncStateNotFound') === 0 + && ($statusCode === 410 || $statusCode === 400) => new GraphSyncStateNotFoundException( + 'Graph delta sync state expired or invalid: '.$errorMessage, + $statusCode, + $e + ), + $statusCode === 404 && $odataCode === 'ErrorItemNotFound' => new GraphItemNotFoundException( + 'Graph item not found (likely deleted or purged from the store): '.$errorMessage, + $statusCode, + $e + ), + $statusCode === 404 => new GraphMailboxNotFoundException( + "Mailbox '{$this->mailbox()}' not found. Check MAIL_INBOX_MAILBOX in .env", + $statusCode, + $e + ), + $statusCode === 429 => new GraphRateLimitException( + 'Graph API rate limit reached. Try again later.', + $statusCode, + $e + ), + default => new GraphException( + 'Graph API error: '.$errorMessage, + $statusCode, + $e + ), + }; + } catch (InvalidArgumentException $e) { + Log::channel('mail-inbox')->error('[MailInbox] Graph API invalid argument: '.$operation, ['exception' => $e]); + + throw $e; + } catch (\Exception $e) { + Log::channel('mail-inbox')->error('[MailInbox] Graph API connection failed: '.$operation, ['exception' => $e]); + + throw new GraphConnectionException( + 'Graph API connection failed: '.$e->getMessage(), + 0, + $e + ); + } + } + + private function binaryContentFromFileAttachment(FileAttachment $attachment): string + { + $contentBytes = $attachment->getContentBytes(); + if ($contentBytes === null) { + throw new InvalidArgumentException('File attachment has no content bytes.'); + } + + if ($contentBytes instanceof StreamInterface) { + if ($contentBytes->isSeekable()) { + $contentBytes->rewind(); + } + + return base64_decode($contentBytes->getContents()); + } + + throw new InvalidArgumentException('Unexpected contentBytes type on file attachment.'); + } + + private function escapeODataSingleQuotedString(string $value): string + { + return str_replace("'", "''", $value); + } + + /** + * @return list + */ + private function deltaSelectFields(): array + { + return [ + 'id', 'internetMessageId', 'subject', 'from', 'toRecipients', + 'ccRecipients', 'receivedDateTime', 'bodyPreview', 'body', + 'hasAttachments', + ]; + } + + private function postGraphMoveMessageToFolder(string $messageId, string $destinationFolderId): void + { + $this->wrapGraphCall(function () use ($messageId, $destinationFolderId): void { + $body = new MovePostRequestBody; + $body->setDestinationId($destinationFolderId); + + $this->client + ->users() + ->byUserId($this->mailbox()) + ->messages() + ->byMessageId($messageId) + ->move() + ->post($body) + ->wait(); + }, 'moveMessageToFolder.post'); + } + + private function cachedInboxFolderId(string $scope): string + { + $cacheKey = 'mail-inbox:inbox-folder-id:'.((string) $scope).':'.$this->mailbox(); + + return Cache::remember($cacheKey, now()->addHours(24), function (): string { + $folder = $this->wrapGraphCall( + fn () => $this->client + ->users() + ->byUserId($this->mailbox()) + ->mailFolders() + ->byMailFolderId('inbox') + ->get() + ->wait(), + 'getInboxMailFolder' + ); + + $id = $folder?->getId(); + if ($id === null || $id === '') { + throw new GraphException('Graph API did not return an id for the Inbox folder.'); + } + + return $id; + }); + } + + private function optionalProcessingFolderId(): ?string + { + $name = config('mail-inbox.processing_folder'); + if ($name === null || $name === '') { + return null; + } + + return $this->getOrCreateFolder((string) $name); + } + + /** + * @param ?string $inboxFolderId Resolved well-known Inbox folder id (must not trigger extra Graph calls here). + */ + private function parentIsAcceptablePipelineSource(?string $parentFolderId, string $inboxFolderId): bool + { + if ($parentFolderId === null || $parentFolderId === '') { + return false; + } + + if ($parentFolderId === $inboxFolderId) { + return true; + } + + $processingId = $this->optionalProcessingFolderId(); + + return $processingId !== null && $parentFolderId === $processingId; + } + + private function logUnexpectedParentForMove(string $messageId, ?string $parentFolderId, string $scope): void + { + $context = [ + 'messageId' => $messageId, + 'parentFolderId' => $parentFolderId, + 'scope' => $scope, + ]; + + try { + $processedId = $this->getOrCreateFolder((string) config('mail-inbox.processed_folder')); + if ($parentFolderId !== null && $parentFolderId === $processedId) { + Log::channel('mail-inbox')->warning('[MailInbox] Skipping move: message parent appears to be a terminal mailbox folder (Processed/Failed)', $context); + + return; + } + $failedId = $this->getOrCreateFolder((string) config('mail-inbox.failed_folder')); + if ($parentFolderId !== null && $parentFolderId === $failedId) { + Log::channel('mail-inbox')->warning('[MailInbox] Skipping move: message parent appears to be a terminal mailbox folder (Processed/Failed)', $context); + + return; + } + } catch (Throwable) { + } + + Log::channel('mail-inbox')->warning('[MailInbox] Skipping move: message parent is not an acceptable pipeline source (Inbox or Processing)', $context); + } + + private function looksLikeGraphFolderResolutionFailure(Throwable $e): bool + { + $current = $e; + for ($i = 0; $i < 6 && $current !== null; $i++) { + if ($current instanceof ODataError) { + $code = $current->getError()?->getCode(); + if ($code !== null && in_array((string) $code, ['ErrorFolderNotFound', 'ErrorInvalidIdMalformed'], true)) { + return true; + } + } + $previous = $current->getPrevious(); + if (! $previous instanceof Throwable) { + break; + } + $current = $previous; + } + + return false; + } + + private function processingFolderMisconfigurationHint(Throwable $e): ?string + { + if ($this->looksLikeGraphFolderResolutionFailure($e)) { + return 'Processing folder may be misconfigured — verify MAIL_INBOX_PROCESSING_FOLDER names an existing mailbox folder for this scope/mailbox.'; + } + + return null; + } +} diff --git a/packages/mail-inbox/src/Services/MailInboxService.php b/packages/mail-inbox/src/Services/MailInboxService.php new file mode 100644 index 0000000000..e5e5e3dba9 --- /dev/null +++ b/packages/mail-inbox/src/Services/MailInboxService.php @@ -0,0 +1,564 @@ + $graphMessages + */ + public function persistDeltaMessages(array $graphMessages, string $scope): DeltaPersistResult + { + $persisted = 0; + $skippedKnown = 0; + $skippedNoAttachments = 0; + + foreach ($graphMessages as $graphMessage) { + if (! $graphMessage instanceof Message) { + continue; + } + + $externalId = $graphMessage->getId(); + if ($externalId === null || $externalId === '') { + Log::channel('mail-inbox')->error('[MailInbox] Skipping Graph message with null id during delta persist'); + + continue; + } + + if (! ($graphMessage->getHasAttachments() ?? false)) { + $skippedNoAttachments++; + + continue; + } + + $internetId = $graphMessage->getInternetMessageId(); + $internetPresent = $internetId !== null && $internetId !== ''; + + if ($internetPresent) { + $existing = InboxMessage::query() + ->where('scope', $scope) + ->where('message_id', $internetId) + ->first(); + + if ($existing !== null) { + if ($existing->external_id !== $externalId) { + $currentExternalId = $existing->external_id; + + try { + // Migrate volatile → immutable as Delta re-delivers this mail (Prefer immutable on Graph traffic). + $existing->external_id = $externalId; + $existing->saveQuietly(); + + Log::channel('mail-inbox')->info('[MailInbox] Updated external_id on known message (likely volatile → immutable migration)', [ + 'scope' => $scope, + 'inbox_message_id' => $existing->id, + 'message_id' => $internetId, + ]); + } catch (UniqueConstraintViolationException) { + $existing->refresh(); + + /** @var int|string|null $conflictingId */ + $conflictingId = InboxMessage::query() + ->where('scope', $scope) + ->where('external_id', $externalId) + ->whereKeyNot($existing->id) + ->value('id'); + + Log::channel('mail-inbox')->warning('[MailInbox] Could not update external_id on known message due to unique constraint', [ + 'scope' => $scope, + 'inbox_message_id' => $existing->id, + 'conflicting_inbox_message_id' => $conflictingId, + 'message_id' => $internetId, + 'attempted_external_id' => $externalId, + 'current_external_id' => $currentExternalId, + ]); + } + } + + Log::channel('mail-inbox')->debug('[MailInbox] Delta returned known message, skipping (pre-check)', [ + 'external_id' => $externalId, + 'message_id' => $internetId, + 'scope' => $scope, + ]); + $skippedKnown++; + + continue; + } + } else { + Log::channel('mail-inbox')->warning('[MailInbox] Delta message missing internetMessageId, falling back to external_id for dedup', [ + 'external_id' => $externalId, + 'scope' => $scope, + ]); + + // Dedupe uses Graph id equals row.external_id; there is nothing to reconcile (no RFC822 anchor for a stale-id update). + $existsAlready = InboxMessage::query() + ->where('scope', $scope) + ->where('external_id', $externalId) + ->exists(); + + if ($existsAlready) { + Log::channel('mail-inbox')->debug('[MailInbox] Delta returned known message, skipping (pre-check)', [ + 'external_id' => $externalId, + 'message_id' => null, + 'scope' => $scope, + ]); + $skippedKnown++; + + continue; + } + } + + try { + $row = $this->createInboxMessageFromGraphMessage($graphMessage, $scope); + + if ($row !== null) { + StoreAttachmentsJob::dispatch($row->id); + $persisted++; + } + } catch (UniqueConstraintViolationException) { + $skippedKnown++; + + $internetMessageIdForLog = $internetPresent ? $internetId : null; + + $payload = [ + 'scope' => $scope, + 'message_id' => $internetMessageIdForLog, + 'external_id' => $externalId, + ]; + + try { + $colliding = $this->findCollidingInboxMessageForUniqueViolation($scope, $externalId, $internetMessageIdForLog); + + if ($colliding === null) { + $payload['db_match'] = 'not_found'; + } else { + $payload['db_id'] = $colliding->id; + $payload['db_external_id'] = $colliding->external_id; + $payload['db_message_id'] = $colliding->message_id; + $payload['db_subject'] = $colliding->subject; + $payload['db_created_at'] = $colliding->created_at?->toIso8601String(); + } + } catch (\Throwable $diagnosticError) { + $payload['db_match'] = 'diagnostic_query_failed'; + $payload['diagnostic_error'] = $diagnosticError::class.': '.$diagnosticError->getMessage(); + } + + Log::channel('mail-inbox')->info('[MailInbox] Delta race condition caught by unique constraint, skipping', $payload); + } + } + + return new DeltaPersistResult( + persisted: $persisted, + skippedKnown: $skippedKnown, + skippedNoAttachments: $skippedNoAttachments, + ); + } + + public function finalizeMessageProcessingAfterAttachments(InboxMessage $message): void + { + $message = $message->fresh(['attachments']); + + if ($message === null) { + return; + } + + if ($message->processing_status !== InboxMessageProcessingStatus::PartiallyFailed->value + && in_array($message->processing_status, [ + InboxMessageProcessingStatus::Processed->value, + InboxMessageProcessingStatus::Failed->value, + ], true) + ) { + return; + } + + foreach ($message->attachments as $attachment) { + if ($attachment->processing_status === InboxAttachmentProcessingStatus::New->value && ! $attachment->is_pdf) { + $attachment->markAsSkipped(); + } + } + + $message->load('attachments'); + + if ($message->processing_status === InboxMessageProcessingStatus::PartiallyFailed->value + && $message->attachments->isEmpty() + ) { + $error = $message->error_message !== null && $message->error_message !== '' + ? $message->error_message + : 'Attachment storage failed'; + $message->markAsFailed($error); + $this->tryMoveGraphMessageToTerminalFolder($message->external_id, false, $message->id, $message->scope); + + return; + } + + if ($message->attachments->contains( + fn (InboxAttachment $a): bool => in_array($a->processing_status, [ + InboxAttachmentProcessingStatus::New->value, + InboxAttachmentProcessingStatus::Processing->value, + ], true) + )) { + return; + } + + $hasFailed = $message->attachments->contains( + fn (InboxAttachment $a): bool => $a->processing_status === InboxAttachmentProcessingStatus::Failed->value + ); + + $allPdfs = $message->pdfAttachments()->get(); + + if ($message->processing_status === InboxMessageProcessingStatus::PartiallyFailed->value) { + if ($hasFailed) { + $error = $message->error_message !== null && $message->error_message !== '' + ? $message->error_message + : 'One or more attachments failed processing'; + $message->markAsFailed($error); + $this->tryMoveGraphMessageToTerminalFolder($message->external_id, false, $message->id, $message->scope); + } else { + $message->error_message = null; + $message->markAsProcessed(); + $this->tryMoveGraphMessageToTerminalFolder($message->external_id, true, $message->id, $message->scope); + } + + return; + } + + if ($allPdfs->isEmpty()) { + $message->markAsProcessed(); + $this->tryMoveGraphMessageToTerminalFolder($message->external_id, true, $message->id, $message->scope); + + return; + } + + if ($hasFailed) { + $message->markAsFailed('One or more attachments failed processing'); + $this->tryMoveGraphMessageToTerminalFolder($message->external_id, false, $message->id, $message->scope); + + return; + } + + if ($allPdfs->every( + fn (InboxAttachment $a): bool => $a->processing_status === InboxAttachmentProcessingStatus::Processed->value + )) { + $message->markAsProcessed(); + $this->tryMoveGraphMessageToTerminalFolder($message->external_id, true, $message->id, $message->scope); + } + } + + private function tryMoveGraphMessageToTerminalFolder(?string $externalId, bool $success, ?int $inboxMessageId = null, ?string $scope = null): void + { + if ($externalId === null || $externalId === '') { + return; + } + + try { + $this->graphService->moveGraphMessageToProcessedOrFailedFolder($externalId, $success, $scope); + } catch (GraphItemNotFoundException $e) { + Log::channel('mail-inbox')->warning('[MailInbox] Terminal folder move skipped: Graph message not found', [ + 'external_id' => $externalId, + 'inbox_message_id' => $inboxMessageId, + 'success_path' => $success, + 'exception' => $e, + ]); + } catch (\Throwable $e) { + Log::channel('mail-inbox')->error('[MailInbox] Terminal folder move failed', [ + 'exception' => $e, + 'external_id' => $externalId, + 'inbox_message_id' => $inboxMessageId, + 'success_path' => $success, + ]); + } + } + + public function enqueueParseJobsForInboxMessage(InboxMessage $message): void + { + $message->load('attachments'); + + foreach ($message->attachments as $attachment) { + if ($attachment->processing_status === InboxAttachmentProcessingStatus::New->value && ! $attachment->is_pdf) { + $attachment->markAsSkipped(); + } + } + + $pdfNew = $message->pdfAttachments() + ->where('processing_status', InboxAttachmentProcessingStatus::New->value) + ->get(); + + if ($pdfNew->isEmpty()) { + $this->finalizeMessageProcessingAfterAttachments($message->fresh(['attachments'])); + + return; + } + + foreach ($pdfNew as $attachment) { + ParsePdfJob::dispatch($attachment->id); + } + } + + public function processNewMessages(string $scope = 'default'): int + { + $messages = InboxMessage::forScope($scope)->new()->with('attachments')->get(); + + foreach ($messages as $message) { + $this->enqueueParseJobsForInboxMessage($message); + } + + return $messages->count(); + } + + public function retryFailedMessages(string $scope = 'default'): int + { + $messages = InboxMessage::forScope($scope)->failed()->with('attachments')->get(); + + $stalenessMinutes = max(1, (int) config('mail-inbox.retry_staleness_minutes', 30)); + $stalenessThreshold = now()->subMinutes($stalenessMinutes); + + foreach ($messages as $message) { + $messageId = $message->id; + + DB::transaction(function () use ($message, $stalenessThreshold): void { + $message->update([ + 'processing_status' => InboxMessageProcessingStatus::New->value, + 'error_message' => null, + ]); + + $message->attachments() + ->where('processing_status', InboxAttachmentProcessingStatus::Failed->value) + ->update([ + 'processing_status' => InboxAttachmentProcessingStatus::New->value, + 'error_message' => null, + ]); + + $message->attachments() + ->where('processing_status', InboxAttachmentProcessingStatus::Processing->value) + ->where('updated_at', '<', $stalenessThreshold) + ->update([ + 'processing_status' => InboxAttachmentProcessingStatus::New->value, + 'error_message' => null, + ]); + }); + + $recentProcessingCount = InboxAttachment::query() + ->where('inbox_message_id', $messageId) + ->where('processing_status', InboxAttachmentProcessingStatus::Processing->value) + ->where('updated_at', '>=', $stalenessThreshold) + ->count(); + + if ($recentProcessingCount > 0) { + Log::channel('mail-inbox')->warning('[MailInbox] retryFailedMessages: left processing attachments unchanged (within staleness window)', [ + 'inbox_message_id' => $messageId, + 'count' => $recentProcessingCount, + 'staleness_minutes' => $stalenessMinutes, + ]); + } + + $this->enqueueParseJobsForInboxMessage($message->fresh(['attachments'])); + } + + return $messages->count(); + } + + /** + * @return array{processed: int, failed: int, skipped: int} + */ + public function attachmentTerminalCountsForScope(string $scope): array + { + $rows = InboxAttachment::query() + ->where('scope', $scope) + ->whereIn('processing_status', [ + InboxAttachmentProcessingStatus::Processed->value, + InboxAttachmentProcessingStatus::Failed->value, + InboxAttachmentProcessingStatus::Skipped->value, + ]) + ->selectRaw('processing_status, COUNT(*) as aggregate') + ->groupBy('processing_status') + ->pluck('aggregate', 'processing_status'); + + return [ + 'processed' => (int) ($rows[InboxAttachmentProcessingStatus::Processed->value] ?? 0), + 'failed' => (int) ($rows[InboxAttachmentProcessingStatus::Failed->value] ?? 0), + 'skipped' => (int) ($rows[InboxAttachmentProcessingStatus::Skipped->value] ?? 0), + ]; + } + + /** + * @return array{processed: int, failed: int, skipped: int} + */ + public function attachmentTerminalCountsForMessage(InboxMessage $message): array + { + $rows = $message->attachments() + ->whereIn('processing_status', [ + InboxAttachmentProcessingStatus::Processed->value, + InboxAttachmentProcessingStatus::Failed->value, + InboxAttachmentProcessingStatus::Skipped->value, + ]) + ->selectRaw('processing_status, COUNT(*) as aggregate') + ->groupBy('processing_status') + ->pluck('aggregate', 'processing_status'); + + return [ + 'processed' => (int) ($rows[InboxAttachmentProcessingStatus::Processed->value] ?? 0), + 'failed' => (int) ($rows[InboxAttachmentProcessingStatus::Failed->value] ?? 0), + 'skipped' => (int) ($rows[InboxAttachmentProcessingStatus::Skipped->value] ?? 0), + ]; + } + + /** + * Rows keyed by status: [messages count, attachments count]. Message `processed` aligns with attachment `processed`. + * + * @return array + */ + public function inboxStatusBreakdown(string $scope): array + { + $statuses = array_map( + static fn (InboxMessageProcessingStatus $s): string => $s->value, + InboxMessageProcessingStatus::cases() + ); + + $msgCounts = InboxMessage::query() + ->where('scope', $scope) + ->selectRaw('processing_status, COUNT(*) as aggregate') + ->groupBy('processing_status') + ->pluck('aggregate', 'processing_status'); + + $attByRaw = InboxAttachment::query() + ->where('scope', $scope) + ->selectRaw('processing_status, COUNT(*) as aggregate') + ->groupBy('processing_status') + ->pluck('aggregate', 'processing_status'); + + $rows = []; + + foreach ($statuses as $status) { + $messages = (int) ($msgCounts[$status] ?? 0); + + $attachments = match ($status) { + InboxMessageProcessingStatus::New->value => (int) ($attByRaw[InboxAttachmentProcessingStatus::New->value] ?? 0) + + (int) ($attByRaw[InboxAttachmentProcessingStatus::Processing->value] ?? 0), + InboxMessageProcessingStatus::Read->value => 0, + InboxMessageProcessingStatus::Processed->value => (int) ($attByRaw[InboxAttachmentProcessingStatus::Processed->value] ?? 0), + InboxMessageProcessingStatus::PartiallyFailed->value => (int) ($attByRaw[InboxAttachmentProcessingStatus::Failed->value] ?? 0), + InboxMessageProcessingStatus::Failed->value => (int) ($attByRaw[InboxAttachmentProcessingStatus::Failed->value] ?? 0), + InboxMessageProcessingStatus::Skipped->value => (int) ($attByRaw[InboxAttachmentProcessingStatus::Skipped->value] ?? 0), + }; + + $rows[$status] = [$messages, $attachments]; + } + + return $rows; + } + + public function latestReceivedAtForScope(string $scope): ?Carbon + { + $value = InboxMessage::query() + ->where('scope', $scope) + ->whereNotNull('received_at') + ->max('received_at'); + + if ($value === null) { + return null; + } + + return Carbon::parse($value); + } + + public function latestProcessedAtForScope(string $scope): ?Carbon + { + $value = InboxMessage::query() + ->where('scope', $scope) + ->whereNotNull('processed_at') + ->max('processed_at'); + + if ($value === null) { + return null; + } + + return Carbon::parse($value); + } + + /** + * Resolves an existing row when a unique constraint fires during delta ingest (test seam for diagnostic failures). + */ + protected function findCollidingInboxMessageForUniqueViolation(string $scope, string $externalId, ?string $internetMessageIdForLog): ?InboxMessage + { + return InboxMessage::query() + ->where('scope', $scope) + ->where(function ($query) use ($externalId, $internetMessageIdForLog): void { + $query->where('external_id', $externalId); + + if ($internetMessageIdForLog !== null && $internetMessageIdForLog !== '') { + $query->orWhere('message_id', $internetMessageIdForLog); + } + }) + ->first(); + } + + protected function createInboxMessageFromGraphMessage(Message $graphMessage, string $scope): ?InboxMessage + { + $externalId = $graphMessage->getId(); + if ($externalId === null || $externalId === '') { + return null; + } + + $from = $graphMessage->getFrom()?->getEmailAddress(); + $toRecipients = $graphMessage->getToRecipients() ?? []; + $firstRecipient = $toRecipients[0] ?? null; + $firstTo = $firstRecipient?->getEmailAddress(); + $body = $graphMessage->getBody(); + + $headers = collect($graphMessage->getInternetMessageHeaders() ?? []) + ->mapWithKeys(function ($h): array { + $name = $h->getName(); + if ($name === null || $name === '') { + return []; + } + + return [$name => $h->getValue()]; + }) + ->toArray(); + + $contentType = $body?->getContentType(); + + return InboxMessage::create([ + 'scope' => $scope, + 'channel' => 'email', + 'external_id' => $graphMessage->getId(), + 'message_id' => $graphMessage->getInternetMessageId(), + 'from_email' => $from?->getAddress(), + 'from_name' => $from?->getName(), + 'to_email' => $firstTo?->getAddress(), + 'to_name' => $firstTo?->getName(), + 'subject' => $graphMessage->getSubject(), + 'received_at' => $graphMessage->getReceivedDateTime(), + 'raw_headers' => $headers !== [] ? $headers : null, + 'raw_body_text' => ($contentType !== null && $contentType->value() === BodyType::TEXT) + ? $body?->getContent() + : null, + 'raw_body_html' => ($contentType !== null && $contentType->value() === BodyType::HTML) + ? $body?->getContent() + : null, + 'has_attachments' => $graphMessage->getHasAttachments() ?? false, + 'processing_status' => InboxMessageProcessingStatus::New->value, + ]); + } +} diff --git a/packages/mail-inbox/src/Support/DeltaMessageInspector.php b/packages/mail-inbox/src/Support/DeltaMessageInspector.php new file mode 100644 index 0000000000..1089d2603b --- /dev/null +++ b/packages/mail-inbox/src/Support/DeltaMessageInspector.php @@ -0,0 +1,27 @@ +getAdditionalData(); + + if ($extra === null) { + return false; + } + + return array_key_exists('@removed', $extra); + } +} diff --git a/packages/mail-inbox/src/Support/MailInboxGraphServiceClientFactory.php b/packages/mail-inbox/src/Support/MailInboxGraphServiceClientFactory.php new file mode 100644 index 0000000000..88d0b32222 --- /dev/null +++ b/packages/mail-inbox/src/Support/MailInboxGraphServiceClientFactory.php @@ -0,0 +1,68 @@ + $scopes + */ + public static function make( + ClientCredentialContext $tokenContext, + array $scopes = [], + string $nationalCloud = NationalCloud::GLOBAL, + ?Client $httpClient = null, + ): GraphServiceClient { + if ($httpClient === null) { + GraphClientFactory::setNationalCloud($nationalCloud); + GraphClientFactory::setTelemetryOption( + new GraphTelemetryOption(GraphConstants::API_VERSION, GraphConstants::SDK_VERSION), + ); + + $handlerStack = GraphClientFactory::getDefaultHandlerStack(); + self::prependPreferImmutableIdHeader($handlerStack); + + $httpClient = GraphClientFactory::createWithMiddleware($handlerStack); + } + + $authenticationProvider = GraphPhpLeagueAuthenticationProvider::createWithAccessTokenProvider( + new GraphPhpLeagueAccessTokenProvider($tokenContext, $scopes, $nationalCloud), + ); + + $requestAdapter = new GraphRequestAdapter($authenticationProvider, $httpClient); + + return new GraphServiceClient($tokenContext, $scopes, $nationalCloud, $requestAdapter); + } + + public static function prependPreferImmutableIdHeader(HandlerStack $handlerStack): void + { + $handlerStack->unshift( + Middleware::mapRequest( + fn (RequestInterface $request): RequestInterface => $request->withHeader('Prefer', 'IdType="ImmutableId"'), + ), + 'mail_inbox_graph_prefer_immutable_id', + ); + } +} diff --git a/packages/pdf-parser/.gitignore b/packages/pdf-parser/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/pdf-parser/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/pdf-parser/CHANGELOG.md b/packages/pdf-parser/CHANGELOG.md new file mode 100644 index 0000000000..d806cfe835 --- /dev/null +++ b/packages/pdf-parser/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +We currently don't track changes in this package. Please refer to the [Moox Monorepo](https://github.com/mooxphp/moox) for the latest changes. diff --git a/packages/pdf-parser/LICENSE.md b/packages/pdf-parser/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/pdf-parser/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/pdf-parser/README.md b/packages/pdf-parser/README.md new file mode 100644 index 0000000000..af26c51e8d --- /dev/null +++ b/packages/pdf-parser/README.md @@ -0,0 +1,167 @@ +![Moox PdfParser](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox PdfParser + +Thin Laravel wrapper around `pdftotext` (via `spatie/pdf-to-text`) that extracts plain or layout-preserving text from PDF files and returns a `ParsedDocument` value object. The package is intentionally minimal: no database, Filament UI, routes, or translations — downstream packages own semantic invoice parsing. + +## Features + + + +- `PdfParser::parse()` — plain text extraction +- `PdfParser::parseWithLayout()` — column/layout-preserving extraction (`pdftotext -layout`) +- Immutable `ParsedDocument` DTO (`filePath`, `text`, `parser`, `layout`) +- Configurable `pdftotext` binary path (`PDFTOTEXT_PATH` / `config/pdf-parser.php`) +- Container singleton via `PdfParserServiceProvider` + + + +## Responsibility Boundaries + +- `moox/pdf-parser` owns PDF-to-text extraction only. +- `moox/e-billing` depends on this package for PDF text extraction; host applications bind `Moox\EBilling\Contracts\InvoiceParserInterface` implementations that typically call `parseWithLayout()` before semantic invoice parsing. +- This package does not implement semantic parsing, persistence, XML generation, or validation. + +## Requirements + +| Requirement | Purpose | +|-------------|---------| +| `moox/core` | Moox service provider and installer | +| `spatie/pdf-to-text` ^1.5 | PHP binding to `pdftotext` | +| `pdftotext` binary | Must exist at configured path or be discoverable by Spatie when path is empty | + +See [Requirements](https://github.com/mooxphp/moox/blob/main/docs/Requirements.md). + +## Installation + +```bash +composer require moox/pdf-parser +php artisan moox:install +``` + +Curious what the install command does? See [Installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md). + +Place or copy a `pdftotext` binary, or set an explicit path: + +```env +PDFTOTEXT_PATH=/usr/local/bin/pdftotext +``` + +Default when unset: `storage/app/private/pdf-parser/pdftotext` (see `config/pdf-parser.php`). + +Optionally publish configuration: + +```bash +php artisan vendor:publish --tag=pdf-parser-config +``` + +## Screenshot + +![Moox PdfParser](https://github.com/mooxphp/moox/raw/main/art/screenshots/record.jpg) + +## Usage + +Resolve `Moox\PdfParser\PdfParser` from the container: + +```php +use Moox\PdfParser\PdfParser; + +$document = app(PdfParser::class)->parse('/path/to/file.pdf'); + +$document->text; // extracted string +$document->isEmpty(); // trim-aware empty check +$document->lines(); // explode by newline +``` + +For column-faithful extraction (used by layout-sensitive invoice parsers such as a supplier-specific implementation): + +```php +$document = app(PdfParser::class)->parseWithLayout('/path/to/file.pdf'); + +$document->layout; // true +``` + +Missing files throw `InvalidArgumentException` (`PDF file not found: {path}`). + +### E-billing integration + +`moox/e-billing` lists `moox/pdf-parser` as a dependency and orchestrates the invoice pipeline after mail-inbox ingestion. PDF-to-text extraction is not wired inside e-billing itself: the host application binds `InvoiceParserInterface` and typically resolves `PdfParser` from the container, calls `parseWithLayout()` on the attachment path, then passes `$document->text` to `InvoiceParserInterface::parse()`. No Filament or queue code lives in this package. + +## The ParsedDocument DTO + +Class: `Moox\PdfParser\Data\ParsedDocument` + +| Property | Type | Description | +|----------|------|-------------| +| `filePath` | `string` | Source PDF path | +| `text` | `string` | Extracted content | +| `parser` | `string` | Always `spatie/pdf-to-text` from `PdfParser` | +| `layout` | `bool` | `true` when extracted via `parseWithLayout()` (default `false`) | + +| Method | Description | +|--------|-------------| +| `isEmpty()` | `trim($this->text) === ''` | +| `lines()` | `explode("\n", $this->text)` | + +## The PdfParser Service + +Class: `Moox\PdfParser\PdfParser` + +Constructor: `(?string $pdftotextPath = null)`. The service provider binds the singleton with `config('pdf-parser.pdftotext_path')`. + +| Method | Description | +|--------|-------------| +| `parse(string $pdfPath): ParsedDocument` | Plain text via Spatie `Pdf::text()` | +| `parseWithLayout(string $pdfPath): ParsedDocument` | Same with `setOptions(['-layout'])` | + +When `$pdftotextPath` is truthy, Spatie uses that binary; when falsy, Spatie auto-discovers `pdftotext` on common OS paths. + +## Configuration + +File: `config/pdf-parser.php` + +| Key | Env | Default | +|-----|-----|---------| +| `pdftotext_path` | `PDFTOTEXT_PATH` | `storage_path('app/private/pdf-parser/pdftotext')` | + +No other config keys. No migrations, routes, views, or Artisan commands. + +## Service provider + +`Moox\PdfParser\PdfParserServiceProvider` extends `Moox\Core\MooxServiceProvider`: + +- Publishes `pdf-parser` config only +- Registers `PdfParser` singleton in `packageRegistered()` +- Moox metadata: category `billing`, stability `dev`, `released(false)` + +## Running tests + +This package currently ships architecture tests only (`tests/ArchTest.php`). From the monorepo root: + +```bash +php vendor/bin/pest packages/pdf-parser/tests +``` + +There is no `composer test` script in this package’s `composer.json`. + +## See also + +- [Moox EBilling](../e-billing/README.md) — primary consumer (pipeline orchestrator; host-app `InvoiceParserInterface`) +- [Moox documentation](https://moox.org/docs/pdf-parser) +- [Architecture](docs/ARCHITECTURE.md) + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security + +Please review [our security policy](https://github.com/mooxphp/moox/security/policy) on how to report security vulnerabilities. + +## Credits + +Thanks to so many [people for their contributions](https://github.com/mooxphp/moox#contributors) to this package. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/pdf-parser/SECURITY.md b/packages/pdf-parser/SECURITY.md new file mode 100644 index 0000000000..c71831533c --- /dev/null +++ b/packages/pdf-parser/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `Moox PdfParser` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use Github issues, to give us enough time to review and fix the issue, before others can use it, to do stupid things. diff --git a/packages/pdf-parser/banner.jpg b/packages/pdf-parser/banner.jpg new file mode 100644 index 0000000000..d889a2b55d Binary files /dev/null and b/packages/pdf-parser/banner.jpg differ diff --git a/packages/pdf-parser/composer.json b/packages/pdf-parser/composer.json new file mode 100644 index 0000000000..d4e6fa2fe1 --- /dev/null +++ b/packages/pdf-parser/composer.json @@ -0,0 +1,55 @@ +{ + "name": "moox/pdf-parser", + "description": "Moox PDF Parser extracts text content from PDF files for further processing.", + "keywords": [ + "Moox", + "Laravel", + "PDF", + "Parser", + "Moox package", + "Laravel package" + ], + "homepage": "https://moox.org/docs/pdf-parser", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/core": "dev-main", + "spatie/pdf-to-text": "^1.5" + }, + "autoload": { + "psr-4": { + "Moox\\PdfParser\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\PdfParser\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\PdfParser\\PdfParserServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} \ No newline at end of file diff --git a/packages/pdf-parser/config/pdf-parser.php b/packages/pdf-parser/config/pdf-parser.php new file mode 100644 index 0000000000..c431d3e9f8 --- /dev/null +++ b/packages/pdf-parser/config/pdf-parser.php @@ -0,0 +1,32 @@ + env('PDFTOTEXT_PATH', storage_path('app/private/pdf-parser/pdftotext')), + +]; diff --git a/packages/pdf-parser/screenshot/main.jpg b/packages/pdf-parser/screenshot/main.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/pdf-parser/screenshot/main.jpg differ diff --git a/packages/pdf-parser/src/Data/ParsedDocument.php b/packages/pdf-parser/src/Data/ParsedDocument.php new file mode 100644 index 0000000000..478074567e --- /dev/null +++ b/packages/pdf-parser/src/Data/ParsedDocument.php @@ -0,0 +1,25 @@ +text) === ''; + } + + public function lines(): array + { + return explode("\n", $this->text); + } +} diff --git a/packages/pdf-parser/src/PdfParser.php b/packages/pdf-parser/src/PdfParser.php new file mode 100644 index 0000000000..16416233a5 --- /dev/null +++ b/packages/pdf-parser/src/PdfParser.php @@ -0,0 +1,63 @@ +pdftotextPath + ? new Pdf($this->pdftotextPath) + : new Pdf; + + $text = $pdf->setPdf($pdfPath)->text(); + + return new ParsedDocument( + filePath: $pdfPath, + text: $text, + parser: 'spatie/pdf-to-text', + ); + } + + /** + * Extract text with layout preservation (column structure). + */ + public function parseWithLayout(string $pdfPath): ParsedDocument + { + if (! file_exists($pdfPath)) { + throw new \InvalidArgumentException("PDF file not found: {$pdfPath}"); + } + + $pdf = $this->pdftotextPath + ? new Pdf($this->pdftotextPath) + : new Pdf; + + $text = $pdf + ->setPdf($pdfPath) + ->setOptions(['-layout']) + ->text(); + + return new ParsedDocument( + filePath: $pdfPath, + text: $text, + parser: 'spatie/pdf-to-text', + layout: true, + ); + } +} diff --git a/packages/pdf-parser/src/PdfParserServiceProvider.php b/packages/pdf-parser/src/PdfParserServiceProvider.php new file mode 100644 index 0000000000..807c95332a --- /dev/null +++ b/packages/pdf-parser/src/PdfParserServiceProvider.php @@ -0,0 +1,38 @@ +name('pdf-parser') + ->hasConfigFile(); + + $this->getMooxPackage() + ->title('Moox PDF Parser') + ->released(false) + ->stability('dev') + ->category('billing') + ->usedFor([ + 'extracting text from PDF invoices and documents', + ]); + } + + public function packageRegistered(): void + { + parent::packageRegistered(); + + $this->app->singleton(PdfParser::class, function ($app) { + return new PdfParser( + config('pdf-parser.pdftotext_path') + ); + }); + } +} diff --git a/packages/zugferd/.gitignore b/packages/zugferd/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/zugferd/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/zugferd/CHANGELOG.md b/packages/zugferd/CHANGELOG.md new file mode 100644 index 0000000000..d806cfe835 --- /dev/null +++ b/packages/zugferd/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +We currently don't track changes in this package. Please refer to the [Moox Monorepo](https://github.com/mooxphp/moox) for the latest changes. diff --git a/packages/zugferd/LICENSE.md b/packages/zugferd/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/zugferd/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/zugferd/README.md b/packages/zugferd/README.md new file mode 100644 index 0000000000..6e99670a52 --- /dev/null +++ b/packages/zugferd/README.md @@ -0,0 +1,228 @@ +![Moox Zugferd](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox Zugferd + +Moox Zugferd converts invoice data implementing `ZugferdInvoice` into valid ZUGFeRD / XRechnung XML (EN 16931) and can embed that XML into invoice PDFs as PDF/A-3 ZUGFeRD documents. It builds on [horstoeko/zugferd](https://github.com/horstoeko/zugferd). For a visual check of generated XML, see [horstoeko/zugferdvisualizer](https://github.com/horstoeko/zugferdvisualizer). + +## Features + + + +- `ZugferdConverter::convert()` — XML string from any `ZugferdInvoice` implementor +- `convertToFile()` — writes `{output_path}/{invoiceNumber}.xml` +- `mergePdfWithXml()` — PDF/A-3 binary with embedded XML; optional `qpdf` decrypt/re-encrypt +- Contract interfaces for invoices, lines, addresses, bank accounts, and allowance/charges +- Concrete `AllowanceCharge` DTO for tests and simple consumers +- Configurable profile: MINIMUM, BASIC, EN16931, EXTENDED, XRECHNUNG (default) + + + +## Responsibility Boundaries + +- `moox/zugferd` owns XML generation and PDF/A-3 embedding from `ZugferdInvoice` contracts. +- `moox/e-billing` orchestrates the pipeline and adapts `moox/invoice` models via `ZugferdInvoiceAdapter` (`GenerateXmlJob`, `MergeZugferdPdfJob`). +- `moox/kosit-validator` validates XML produced here; validation does not live in this package. +- `moox/pdf-parser` extracts PDF text upstream; this package does not parse PDF text. +- **Composer does not require `moox/e-billing`** — only `moox/core` and `horstoeko/zugferd`. E-billing is an optional peer at runtime. + +## Requirements + +| Package / tool | Purpose | +|----------------|---------| +| `moox/core` | Moox service provider | +| `horstoeko/zugferd` | Document builder and PDF merger | +| `qpdf` (optional) | Decrypt/re-encrypt PDFs when `mail-inbox.zugferd.pdf_password` is set | +| Invoice DTO implementor | e.g. `Moox\EBilling\Adapters\ZugferdInvoiceAdapter` or test fixtures | + +See [Requirements](https://github.com/mooxphp/moox/blob/main/docs/Requirements.md). + +## Installation + +```bash +composer require moox/zugferd +php artisan moox:install +``` + +Curious what the install command does? See [Installation](https://github.com/mooxphp/moox/blob/main/docs/Installation.md). + +Optionally publish configuration: + +```bash +php artisan vendor:publish --tag=zugferd-config +``` + +Set the ZUGFeRD profile: + +```env +ZUGFERD_PROFILE=XRECHNUNG +``` + +## Screenshot + +![Moox Zugferd](https://github.com/mooxphp/moox/raw/main/art/screenshots/record.jpg) + +## Usage + +Resolve `Moox\Zugferd\ZugferdConverter` from the container. + +### Generate XML + +```php +use Moox\Zugferd\ZugferdConverter; + +$xml = app(ZugferdConverter::class)->convert($invoice); +``` + +`$invoice` must implement `Moox\Zugferd\Contracts\ZugferdInvoice`. + +### E-billing adapter + +```php +use Moox\EBilling\Adapters\ZugferdInvoiceAdapter; + +$xml = app(ZugferdConverter::class)->convert(new ZugferdInvoiceAdapter($invoiceModel)); +``` + +`GenerateXmlJob` calls the e-billing gateway, which delegates to `ZugferdConverter` with this adapter. + +### Write XML to disk + +```php +$path = app(ZugferdConverter::class)->convertToFile($invoice); +// Default directory: config('zugferd.output_path') +``` + +### Merge into a ZUGFeRD PDF + +```php +$pdfBinary = app(ZugferdConverter::class)->mergePdfWithXml('/path/to/invoice.pdf', $xml); +``` + +`MergeZugferdPdfJob` uses this after a passed KoSIT validation. + +**Password-protected PDFs:** reads `config('mail-inbox.zugferd.pdf_password')`. When set, runs `qpdf --decrypt` before merge and `qpdf --encrypt` afterward (falls back to unencrypted merge/decrypt on failure). + +## Contract interfaces + +All contracts use PHP 8.4 property hooks (`public type $name { get; }`). Implementors expose the required properties. + +### `ZugferdInvoice` + +Header, parties, totals, `lines`, `bankAccounts`, and `allowanceCharges`. + +**Required for conversion (throws `IncompleteInvoiceException` when missing):** + +- Non-null `supplierAddress` and `customerAddress` +- Non-empty trimmed `supplierEmail` +- Non-empty `bankAccounts` with non-empty IBAN on each account + +**Other notable fields:** `documentType` (credit note when value contains `gutschrift` → type code `381`, else `380`), `paymentMeansCode` (default `58`), `dueDate` / `paymentTerms`, `vatRate`, `netTotal`, `vatAmount`, `grossTotal`. + +### `ZugferdInvoiceLine` + +`position`, `description`, `descriptionDetail`, `articleNumber`, `unitPrice`, `quantity`, `unit`, `lineTotal`, `allowanceCharges`. + +### `ZugferdAddress` + +`street`, `addressLine2`, `addressLine3`, `zip`, `city`, `country` — mapped to horstoeko address lines via `buildAddressLines()`. + +### `ZugferdBankAccount` + +`iban` (required), `bic`, `bankName` (unused by converter), `accountHolder` (falls back to `supplierName`). + +### `ZugferdAllowanceCharge` + +`isCharge`, `amount`, `reasonCode`, `reasonText`. `basisAmount` and `percentage` are on the contract but ignored by the converter. Items with `amount <= 0` are skipped. + +### `AllowanceCharge` data class + +`Moox\Zugferd\Data\AllowanceCharge` is a readonly concrete implementor for tests and simple use cases. + +## The ZugferdConverter Service + +Class: `Moox\Zugferd\ZugferdConverter` (singleton in `ZugferdServiceProvider`). + +| Method | Returns | Description | +|--------|---------|-------------| +| `convert(ZugferdInvoice $invoice): string` | XML string | Builds horstoeko document; `getContentSafely()` mitigates stream-resource warnings | +| `convertToFile(ZugferdInvoice $invoice, ?string $outputPath = null): string` | File path | Writes `{outputPath}/{invoiceNumber}.xml` | +| `mergePdfWithXml(string $pdfPath, string $xml): string` | PDF binary | Optional qpdf decrypt → merge → optional re-encrypt | + +### Profile map + +| Config value | horstoeko constant | +|--------------|-------------------| +| `MINIMUM` | `PROFILE_MINIMUM` | +| `BASIC` | `PROFILE_BASIC` | +| `EN16931` | `PROFILE_EN16931` | +| `EXTENDED` | `PROFILE_EXTENDED` | +| `XRECHNUNG` | `PROFILE_XRECHNUNG_3` | +| *(unknown)* | Falls back to `PROFILE_EN16931` | + +Runtime profile: `config('zugferd.profile')` (default `XRECHNUNG`). This package does **not** read `config/e-billing.php` profile keys. + +### Unit codes (`mapUnitCode`) + +| Input | UN/ECE | +|-------|--------| +| `Stück` | `C62` | +| `Meter` | `MTR` | +| `Liter` | `LTR` | +| `kg` | `KGM` | +| `Satz` | `SET` | +| `Pauschal` | `LS` | +| default | `C62` | + +## Exceptions + +| Class | When | +|-------|------| +| `Moox\Zugferd\Exceptions\IncompleteInvoiceException` | Missing supplier/customer address, supplier email, or valid bank accounts | +| `\RuntimeException` | Failed to read merged or re-encrypted temp PDF | + +## Configuration + +File: `config/zugferd.php` + +| Key | Env | Default | Used by | +|-----|-----|---------|---------| +| `profile` | `ZUGFERD_PROFILE` | `XRECHNUNG` | `buildDocument()` | +| `output_path` | — | `storage/app/private/zugferd` | `convertToFile()` only | + +**Cross-package config:** `mail-inbox.zugferd.pdf_password` — read in `mergePdfWithXml()` only. + +No migrations, routes, or Filament UI. + +## Running tests + +From the monorepo root: + +```bash +php vendor/bin/pest packages/zugferd/tests +``` + +Feature tests cover allowance/charges, address lines, payment means codes, and `IncompleteInvoiceException` for missing supplier address. `mergePdfWithXml()` and `convertToFile()` are not covered in this package (e-billing tests exercise the adapter path). + +## See also + +- [Moox EBilling](../e-billing/README.md) — `ZugferdInvoiceAdapter`, `GenerateXmlJob`, `MergeZugferdPdfJob` +- [Moox KositValidator](../kosit-validator/README.md) — validates generated XML +- [Moox PdfParser](../pdf-parser/README.md) — upstream PDF text extraction +- [Moox documentation](https://moox.org/docs/zugferd) +- [Architecture](docs/ARCHITECTURE.md) + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Security + +Please review [our security policy](https://github.com/mooxphp/moox/security/policy) on how to report security vulnerabilities. + +## Credits + +Thanks to so many [people for their contributions](https://github.com/mooxphp/moox#contributors) to this package. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/zugferd/SECURITY.md b/packages/zugferd/SECURITY.md new file mode 100644 index 0000000000..f0b42360e5 --- /dev/null +++ b/packages/zugferd/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `Moox Zugferd` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use Github issues, to give us enough time to review and fix the issue, before others can use it, to do stupid things. diff --git a/packages/zugferd/banner.jpg b/packages/zugferd/banner.jpg new file mode 100644 index 0000000000..d889a2b55d Binary files /dev/null and b/packages/zugferd/banner.jpg differ diff --git a/packages/zugferd/composer.json b/packages/zugferd/composer.json new file mode 100644 index 0000000000..58cb63229a --- /dev/null +++ b/packages/zugferd/composer.json @@ -0,0 +1,54 @@ +{ + "name": "moox/zugferd", + "description": "ZUGFeRD and XRechnung XML conversion, including PDF/A-3 embedding via horstoeko/zugferd.", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "Moox package", + "Laravel package" + ], + "homepage": "https://moox.org/docs/zugferd", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "horstoeko/zugferd": "^1", + "moox/core": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\Zugferd\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\Zugferd\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\Zugferd\\ZugferdServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} \ No newline at end of file diff --git a/packages/zugferd/config/zugferd.php b/packages/zugferd/config/zugferd.php new file mode 100644 index 0000000000..9644657565 --- /dev/null +++ b/packages/zugferd/config/zugferd.php @@ -0,0 +1,45 @@ + env('ZUGFERD_PROFILE', 'XRECHNUNG'), + + /* + |-------------------------------------------------------------------------- + | Output Directory + |-------------------------------------------------------------------------- + | + | Where generated XML/PDF files are stored. + | + */ + + 'output_path' => storage_path('app/private/zugferd'), + +]; diff --git a/packages/zugferd/screenshot/main.jpg b/packages/zugferd/screenshot/main.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/zugferd/screenshot/main.jpg differ diff --git a/packages/zugferd/src/Contracts/ZugferdAddress.php b/packages/zugferd/src/Contracts/ZugferdAddress.php new file mode 100644 index 0000000000..86baec118f --- /dev/null +++ b/packages/zugferd/src/Contracts/ZugferdAddress.php @@ -0,0 +1,20 @@ + */ + public array $allowanceCharges { get; } + + /** @var list */ + public array $lines { get; } + + /** @var list */ + public array $bankAccounts { get; } +} diff --git a/packages/zugferd/src/Contracts/ZugferdInvoiceLine.php b/packages/zugferd/src/Contracts/ZugferdInvoiceLine.php new file mode 100644 index 0000000000..6ab1bb4b50 --- /dev/null +++ b/packages/zugferd/src/Contracts/ZugferdInvoiceLine.php @@ -0,0 +1,27 @@ + */ + public array $allowanceCharges { get; } +} diff --git a/packages/zugferd/src/Data/AllowanceCharge.php b/packages/zugferd/src/Data/AllowanceCharge.php new file mode 100644 index 0000000000..f444cbe804 --- /dev/null +++ b/packages/zugferd/src/Data/AllowanceCharge.php @@ -0,0 +1,19 @@ + ZugferdProfiles::PROFILE_MINIMUM, + 'BASIC' => ZugferdProfiles::PROFILE_BASIC, + 'EN16931' => ZugferdProfiles::PROFILE_EN16931, + 'EXTENDED' => ZugferdProfiles::PROFILE_EXTENDED, + 'XRECHNUNG' => ZugferdProfiles::PROFILE_XRECHNUNG_3, + ]; + + public function convert(ZugferdInvoice $invoice): string + { + $document = $this->buildDocument($invoice); + + return $this->getContentSafely($document); + } + + public function convertToFile(ZugferdInvoice $invoice, ?string $outputPath = null): string + { + $outputPath ??= config('zugferd.output_path', storage_path('app/private/zugferd')); + + if (! is_dir($outputPath)) { + mkdir($outputPath, 0755, true); + } + + $filename = sprintf('%s/%s.xml', rtrim($outputPath, '/'), $invoice->invoiceNumber); + + $xml = $this->convert($invoice); + file_put_contents($filename, $xml); + + return $filename; + } + + /** + * Merge ZUGFeRD XML into an existing PDF to create a PDF/A-3 compliant file. + * + * @param string $pdfPath Absolute path to the original invoice PDF + * @param string $xml The ZUGFeRD XML string (already generated by convertToXml()) + * @return string The binary content of the resulting ZUGFeRD PDF + */ + public function mergePdfWithXml(string $pdfPath, string $xml): string + { + $tempDir = rtrim(sys_get_temp_dir(), '/'); + $uid = Str::uuid()->toString(); + $decryptedPath = "{$tempDir}/{$uid}_decrypted.pdf"; + $mergedPath = "{$tempDir}/{$uid}_merged.pdf"; + $encryptedPath = "{$tempDir}/{$uid}_encrypted.pdf"; + $passwordRaw = config('mail-inbox.zugferd.pdf_password'); + $password = is_string($passwordRaw) && $passwordRaw !== '' ? $passwordRaw : null; + + try { + $decryptCmd = array_values(array_filter([ + 'qpdf', + '--decrypt', + $password !== null ? "--password={$password}" : null, + $pdfPath, + $decryptedPath, + ])); + + $decryptProcess = new Process($decryptCmd); + $decryptProcess->run(); + + $sourcePath = ($decryptProcess->isSuccessful() && is_file($decryptedPath)) + ? $decryptedPath + : $pdfPath; + + $merger = new ZugferdDocumentPdfMerger($xml, $sourcePath); + $merger->generateDocument()->saveDocument($mergedPath); + + $mergedContent = file_get_contents($mergedPath); + if ($mergedContent === false) { + throw new \RuntimeException('Failed to read generated ZUGFeRD PDF from temp file.'); + } + + if ($password !== null && is_file($mergedPath)) { + $encryptCmd = [ + 'qpdf', + '--encrypt', + '', + $password, + '128', + '--print=full', + '--modify=none', + '--', + $mergedPath, + $encryptedPath, + ]; + + $encryptProcess = new Process($encryptCmd); + $encryptProcess->run(); + + if ($encryptProcess->isSuccessful() && is_file($encryptedPath)) { + $encryptedContent = file_get_contents($encryptedPath); + if ($encryptedContent === false) { + throw new \RuntimeException('Failed to read re-encrypted ZUGFeRD PDF from temp file.'); + } + + return $encryptedContent; + } + } + + return $mergedContent; + } finally { + foreach ([$decryptedPath, $mergedPath, $encryptedPath] as $tmp) { + if (is_file($tmp)) { + @unlink($tmp); + } + } + } + } + + private function getContentSafely(ZugferdDocumentBuilder $document): string + { + $xml = ''; + + $previousHandler = set_error_handler(function (int $severity, string $message) use (&$previousHandler): bool { + if (str_contains($message, 'supplied resource is not a valid stream resource')) { + return true; + } + + return $previousHandler ? $previousHandler($severity, $message) : false; + }); + + try { + $xml = $document->getContent(); + } catch (\TypeError $e) { + if (! str_contains($e->getMessage(), 'stream resource')) { + throw $e; + } + } finally { + restore_error_handler(); + } + + return $xml; + } + + private function buildDocument(ZugferdInvoice $invoice): ZugferdDocumentBuilder + { + $profileKey = config('zugferd.profile', 'EN16931'); + $profile = self::PROFILE_MAP[$profileKey] ?? ZugferdProfiles::PROFILE_EN16931; + + $document = ZugferdDocumentBuilder::createNew($profile); + + $this->setDocumentInfo($document, $invoice); + $this->setSeller($document, $invoice); + $this->setBuyer($document, $invoice); + $this->setPaymentInfo($document, $invoice); + $this->addLineItems($document, $invoice); + $this->addAllowanceCharges($document, $invoice); + $this->setTotals($document, $invoice); + + return $document; + } + + // ─── Document Info ─────────────────────────────────────────── + + private function setDocumentInfo(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + $typeCode = str_contains(mb_strtolower($invoice->documentType), 'gutschrift') ? '381' : '380'; + + $doc->setDocumentInformation( + $invoice->invoiceNumber, + $typeCode, + \DateTime::createFromFormat('Y-m-d', $invoice->invoiceDate) ?: new \DateTime, + $invoice->currency, + ); + + $doc->setDocumentBusinessProcess('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'); + $buyerRef = $invoice->customerReference ?: $invoice->customerNumber ?: 'N/A'; + $doc->setDocumentBuyerReference($buyerRef); + } + + // ─── Seller (BG-4) ────────────────────────────────────────── + + private function setSeller(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + $doc->setDocumentSeller($invoice->supplierName); + + if ($invoice->supplierAddress === null) { + throw new IncompleteInvoiceException('Missing required field: supplierAddress (BG-5 seller postal address).'); + } + + [$s1, $s2, $s3] = $this->buildAddressLines($invoice->supplierAddress); + $doc->setDocumentSellerAddress( + $s1, + $s2, + $s3, + $invoice->supplierAddress->zip ?? '', + $invoice->supplierAddress->city ?? '', + $invoice->supplierAddress->country ?? '', + ); + + if ($invoice->supplierEmail === null || trim($invoice->supplierEmail) === '') { + throw new IncompleteInvoiceException('Missing required field: supplierEmail (BT-34 seller electronic address).'); + } + + $doc->setDocumentSellerContact( + $invoice->agent ?? '', + '', + $invoice->supplierPhone ?? '', + '', + $invoice->supplierEmail, + ); + + $doc->setDocumentSellerCommunication('EM', $invoice->supplierEmail); + + if ($invoice->supplierVatId) { + // Remove spaces from VAT ID (DE 123 456 789 → DE123456789) + $vatId = preg_replace('/\s+/', '', $invoice->supplierVatId); + $doc->addDocumentSellerTaxRegistration('VA', $vatId); + } + if ($invoice->supplierTaxNumber) { + $doc->addDocumentSellerTaxRegistration('FC', $invoice->supplierTaxNumber); + } + } + + // ─── Buyer (BG-7) ─────────────────────────────────────────── + + private function setBuyer(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + $doc->setDocumentBuyer($invoice->customerName); + + if ($invoice->customerAddress === null) { + throw new IncompleteInvoiceException('Missing required field: customerAddress (BG-8 buyer postal address).'); + } + + [$b1, $b2, $b3] = $this->buildAddressLines($invoice->customerAddress); + $doc->setDocumentBuyerAddress( + $b1, + $b2, + $b3, + $invoice->customerAddress->zip ?? '', + $invoice->customerAddress->city ?? '', + $invoice->customerAddress->country ?? '', + ); + + if ($invoice->customerVatId) { + $doc->addDocumentBuyerTaxRegistration('VA', $invoice->customerVatId); + } + + $doc->setDocumentBuyerCommunication('EM', $invoice->customerNumber ?: 'N/A'); + } + + // ─── Payment (BG-16) ──────────────────────────────────────── + + private function setPaymentInfo(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + if ($invoice->bankAccounts === []) { + throw new IncompleteInvoiceException('Missing required field: bankAccounts (at least one payment means bank account).'); + } + + foreach ($invoice->bankAccounts as $account) { + $iban = trim($account->iban); + if ($iban === '') { + throw new IncompleteInvoiceException('Missing required field: bankAccounts[].iban (payment means payee IBAN).'); + } + + $doc->addDocumentPaymentMean( + $invoice->paymentMeansCode ?? '58', + null, + null, + null, + null, + null, + $iban, + $account->accountHolder ?? $invoice->supplierName, + null, + $account->bic, + ); + } + + // Payment terms + if ($invoice->dueDate) { + $dueDate = \DateTime::createFromFormat('Y-m-d', $invoice->dueDate); + $termText = $invoice->paymentTerms ?? ''; + if ($invoice->dueDate) { + $termText .= '; zahlbar ohne Abzug bis zum '.date('d.m.Y', strtotime($invoice->dueDate)); + } + if ($dueDate) { + $doc->addDocumentPaymentTerm($termText, $dueDate); + } + } elseif ($invoice->paymentTerms) { + $doc->addDocumentPaymentTerm($invoice->paymentTerms); + } + } + + // ─── Line Items ────────────────────────────────────────────── + + private function addLineItems(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + foreach ($invoice->lines as $line) { + $doc->addNewPosition((string) $line->position); + $doc->setDocumentPositionProductDetails( + $line->description, + $line->descriptionDetail ?? '', + $line->articleNumber, + ); + $doc->setDocumentPositionNetPrice($line->unitPrice); + $doc->setDocumentPositionQuantity( + $line->quantity, + $this->mapUnitCode($line->unit), + ); + + // Line total WITHOUT surcharges (surcharges go to document level) + $doc->setDocumentPositionLineSummation($line->lineTotal); + + $doc->addDocumentPositionTax('S', 'VAT', $invoice->vatRate); + } + } + + // ─── Allowances & Charges (BG-20/BG-21) ───────────────────── + + private function addAllowanceCharges(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + foreach ($this->collectAllowanceCharges($invoice) as $item) { + if ($item->amount <= 0) { + continue; + } + + $doc->addDocumentAllowanceCharge( + $item->amount, + $item->isCharge, + 'S', + 'VAT', + $invoice->vatRate, + null, + null, + null, + null, + null, + $item->reasonCode, + $item->reasonText, + ); + } + } + + /** + * @return list + */ + private function collectAllowanceCharges(ZugferdInvoice $invoice): array + { + $items = $invoice->allowanceCharges; + + foreach ($invoice->lines as $line) { + foreach ($line->allowanceCharges as $lineItem) { + $items[] = $lineItem; + } + } + + return $items; + } + + // ─── Totals (BG-22) ───────────────────────────────────────── + + private function setTotals(ZugferdDocumentBuilder $doc, ZugferdInvoice $invoice): void + { + $lineTotalSum = 0; + foreach ($invoice->lines as $line) { + $lineTotalSum += $line->lineTotal; + } + + $chargeTotal = 0.0; + $allowanceTotal = 0.0; + + foreach ($this->collectAllowanceCharges($invoice) as $item) { + if ($item->amount <= 0) { + continue; + } + + if ($item->isCharge) { + $chargeTotal += $item->amount; + } else { + $allowanceTotal += $item->amount; + } + } + + $doc->setDocumentSummation( + $invoice->grossTotal, // grandTotalAmount (BT-112) + $invoice->grossTotal, // duePayableAmount (BT-115) + $lineTotalSum, // lineTotalAmount (BT-106) + $chargeTotal, // chargeTotalAmount (BT-108) + $allowanceTotal, // allowanceTotalAmount (BT-107) + $invoice->netTotal, // taxBasisTotalAmount (BT-109) + $invoice->vatAmount, // taxTotalAmount (BT-110) + ); + + $doc->addDocumentTax('S', 'VAT', $invoice->netTotal, $invoice->vatAmount, $invoice->vatRate); + } + + // ─── Helper functions ───────────────────────────────────────── + + private function mapUnitCode(string $unit): string + { + return match ($unit) { + 'Stück' => 'C62', + 'Meter' => 'MTR', + 'Liter' => 'LTR', + 'kg' => 'KGM', + 'Satz' => 'SET', + 'Pauschal' => 'LS', + default => 'C62', + }; + } + + /** + * @return array{0: ?string, 1: ?string, 2: ?string} + */ + private function buildAddressLines(ZugferdAddress $address): array + { + return [$address->street, $address->addressLine2, $address->addressLine3]; + } +} diff --git a/packages/zugferd/src/ZugferdServiceProvider.php b/packages/zugferd/src/ZugferdServiceProvider.php new file mode 100644 index 0000000000..483e97f465 --- /dev/null +++ b/packages/zugferd/src/ZugferdServiceProvider.php @@ -0,0 +1,34 @@ +name('zugferd') + ->hasConfigFile(); + + $this->getMooxPackage() + ->title('Moox ZUGFeRD') + ->released(false) + ->stability('dev') + ->category('billing') + ->usedFor([ + 'generating valid XRechnung and ZUGFeRD e-invoices', + ]); + } + + public function packageRegistered(): void + { + parent::packageRegistered(); + + $this->app->singleton(ZugferdConverter::class); + } +}