zipdatev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b14ed5e1f68c6aae3b564a967bc36dd5ff0d4fdec083d682452161ceb565bcd
4
+ data.tar.gz: 6c98b7e93719825e69ef0c0483134a256c864fbb734978cc5102f4b7272b82fe
5
+ SHA512:
6
+ metadata.gz: ff15c712b6e8661f54f28b5ac32f9d575e160d5973e8c43dd2330bebe174c1b430457ad20f6d8695e39c79299f87f6937cfcc81142416cc07ac09105ea443ea2
7
+ data.tar.gz: 8b9ff68c49b67c4059797ccc73bd68bc66a28299cafae8ad8b5b18a16f9634f85ee2b404cf5c58bade0ab42df38d2ef1314513dd7e34169f4320bb4a650d2126
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dexter Technologies GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+
6
+ module ZipDatev
7
+ # Constants for DATEV validation including ISO codes and validation patterns.
8
+ #
9
+ # Currency codes are from ISO 4217, country codes from ISO 3166,
10
+ # both as specified in the DATEV XSD schemas.
11
+ module Constants
12
+ # ISO 4217 currency codes supported by DATEV
13
+ # Extracted from Belegverwaltung_online_ledger_types_v060.xsd (p1 type)
14
+ CURRENCY_CODES = %w[
15
+ AED AFA AFN ALL AMD ANG AOA AON AOR ARS ATS AUD AWG AZM AZN
16
+ BAM BBD BDT BEF BGL BGN BHD BIF BMD BND BOB BRL BSD BTN BWP BYB BYN BYR BZD
17
+ CAD CDF CHF CLP CMG CNH CNY COP CRC CSD CUP CVE CYP CZK
18
+ DEM DJF DJV DKK DOP DZD
19
+ ECS EEK EGP ERN ESP ETB EUR
20
+ FIM FJD FKP FRF
21
+ GBP GEL GHC GHS GIP GMD GNF GRD GTQ GWP GYD
22
+ HKD HNL HRK HTG HUF
23
+ IDR IEP ILS INR IQD IRR ISK ITL
24
+ JMD JOD JPY
25
+ KES KGS KHR KMF KPW KRW KWD KYD KZT
26
+ LAK LBP LKR LRD LSL LTL LUF LVL LYD
27
+ MAD MDL MGA MGF MKD MMK MNT MOP MRO MRU MTL MUR MVR MWK MXN MYR MZM MZN
28
+ NAD NGN NIO NLG NOK NPR NZD
29
+ OMR
30
+ PAB PEN PGK PHP PKR PLN PLZ PTE PYG
31
+ QAR
32
+ ROL RON RSD RUB RUR RWF
33
+ SAR SBD SCR SDD SDG SEK SGD SHP SIT SKK SLL SOS SRD SRG SSP STD SVC SYP SZL
34
+ THB TJR TJS TMM TMT TND TOP TRL TRY TTD TWD TZS
35
+ UAH UGX USD UYU UZS
36
+ VEB VEF VES VND VUV
37
+ WST
38
+ XAF XCD XEU XOF XPF
39
+ YER YUM
40
+ ZAR ZMK ZMW ZRN ZWD ZWR
41
+ ].freeze
42
+
43
+ # ISO 3166 two-letter country codes supported by DATEV
44
+ # Extracted from Belegverwaltung_online_ledger_types_v060.xsd (p10014 type)
45
+ COUNTRY_CODES = %w[
46
+ AD AE AF AG AI AL AM AN AO AQ AR AS AT AU AW AX AZ
47
+ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
48
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CS CU CV CW CX CY CZ
49
+ DE DJ DK DM DO DZ
50
+ EC EE EG EH ER ES ET
51
+ FI FJ FK FM FO FR
52
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
53
+ HK HM HN HR HT HU
54
+ ID IE IL IM IN IO IQ IR IS IT
55
+ JE JM JO JP
56
+ KE KG KH KI KM KN KP KR KW KY KZ
57
+ LA LB LC LI LK LR LS LT LU LV LY
58
+ MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
59
+ NA NC NE NF NG NI NL NO NP NR NU NZ
60
+ OM
61
+ PA PE PF PG PH PK PL PM PN PR PS PT PW PY
62
+ QA
63
+ RE RO RS RU RW
64
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
65
+ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
66
+ UA UG UM US UY UZ
67
+ VA VC VE VG VI VN VU
68
+ WF WS
69
+ XI XK
70
+ YE YT
71
+ ZA ZM ZW
72
+ ].freeze
73
+
74
+ # Pattern for invoice ID: alphanumeric + $%&*+-/
75
+ # From XSD p10040: [a-zA-Z0-9$%&*+-/]{0,36}
76
+ INVOICE_ID_PATTERN = %r{\A[a-zA-Z0-9$%&*+\-/]{0,36}\z}
77
+
78
+ # Pattern for internal invoice ID: alphanumeric + $%&*+-/
79
+ # From XSD p10: [a-zA-Z0-9$%&*+-/]{1,12}
80
+ INTERNAL_INVOICE_ID_PATTERN = %r{\A[a-zA-Z0-9$%&*+\-/]{1,12}\z}
81
+
82
+ # Pattern for order ID: alphanumeric + $%&*+-./ (includes dot)
83
+ # From XSD p13: [a-zA-Z0-9$%&*+-.\/]{1,30}
84
+ ORDER_ID_PATTERN = %r{\A[a-zA-Z0-9$%&*+\-./]{1,30}\z}
85
+
86
+ # Pattern for IBAN: 2 uppercase letters + 2 digits + 11-30 alphanumeric
87
+ # From XSD p10010: [A-Z]{2}\d\d([A-Za-z0-9]){11,30}
88
+ IBAN_PATTERN = /\A[A-Z]{2}\d{2}[A-Za-z0-9]{11,30}\z/
89
+
90
+ # Pattern for SWIFT/BIC: 4 uppercase + 2 uppercase + 2 alphanumeric + 0-3 alphanumeric
91
+ # From XSD p10030: [A-Z]{4}[A-Z]{2}([A-Z0-9]){2}([A-Z0-9]){0,3}
92
+ SWIFT_PATTERN = /\A[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}[A-Z0-9]{0,3}\z/
93
+
94
+ # Pattern for VAT ID: alphanumeric + space, dot, underscore
95
+ # From XSD p10027: [0-9a-zA-Z. _]{1,15}
96
+ VAT_ID_PATTERN = /\A[0-9a-zA-Z. _]{1,15}\z/
97
+
98
+ # DATEV date range
99
+ # From XSD p10029: 1753-01-01 to 9999-12-31
100
+ MIN_DATE = Date.new(1753, 1, 1)
101
+ MAX_DATE = Date.new(9999, 12, 31)
102
+
103
+ # Amount constraints
104
+ # From XSD p7: 12 total digits, 2 fraction digits
105
+ MIN_AMOUNT = BigDecimal("-9999999999.99")
106
+ MAX_AMOUNT = BigDecimal("9999999999.99")
107
+
108
+ # Tax percentage constraints
109
+ # From XSD p33: 0.00 to 99.99
110
+ MIN_TAX = BigDecimal("0.00")
111
+ MAX_TAX = BigDecimal("99.99")
112
+
113
+ # Exchange rate constraints
114
+ # From XSD p31: 0.000001 to 9999.999999
115
+ MIN_EXCHANGE_RATE = BigDecimal("0.000001")
116
+ MAX_EXCHANGE_RATE = BigDecimal("9999.999999")
117
+
118
+ # Discount amount constraints
119
+ # From XSD p34: 0.01 to 99999999.99
120
+ MIN_DISCOUNT_AMOUNT = BigDecimal("0.01")
121
+ MAX_DISCOUNT_AMOUNT = BigDecimal("99999999.99")
122
+
123
+ # Payment conditions ID range
124
+ # From XSD p23: 9 to 999
125
+ MIN_PAYMENT_CONDITIONS_ID = 9
126
+ MAX_PAYMENT_CONDITIONS_ID = 999
127
+
128
+ # Account number constraints
129
+ # From XSD p10039: 1 to 999999999
130
+ MIN_ACCOUNT_NO = 1
131
+ MAX_ACCOUNT_NO = 999_999_999
132
+
133
+ # Business partner account number constraints
134
+ # From XSD p11: 10000 to 999999999
135
+ MIN_BP_ACCOUNT_NO = 10_000
136
+ MAX_BP_ACCOUNT_NO = 999_999_999
137
+
138
+ # BU code constraints
139
+ # From XSD p10033: 0 to 9999
140
+ MIN_BU_CODE = 0
141
+ MAX_BU_CODE = 9999
142
+
143
+ # String length constraints
144
+ INVOICE_ID_MAX_LENGTH = 36
145
+ INTERNAL_INVOICE_ID_MAX_LENGTH = 12
146
+ ORDER_ID_MAX_LENGTH = 30
147
+ BOOKING_TEXT_MAX_LENGTH = 60
148
+ INFORMATION_MAX_LENGTH = 120
149
+ SUPPLIER_NAME_MAX_LENGTH = 50
150
+ SUPPLIER_CITY_MAX_LENGTH = 30
151
+ ACCOUNT_NAME_MAX_LENGTH = 40
152
+ PARTY_ID_MAX_LENGTH = 15
153
+ VAT_ID_MAX_LENGTH = 15
154
+ IBAN_MAX_LENGTH = 34
155
+ SWIFT_MAX_LENGTH = 11
156
+ BANK_CODE_MAX_LENGTH = 10
157
+ BANK_ACCOUNT_MAX_LENGTH = 30
158
+ COST_CATEGORY_ID_MAX_LENGTH = 36
159
+ end
160
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module ZipDatev
6
+ # Represents a document entry in the DATEV package.
7
+ #
8
+ # A document bundles an invoice with its associated attachment files
9
+ # and metadata (invoice month, folder name). Each document becomes
10
+ # a <document> element in the generated document.xml.
11
+ #
12
+ # @example Creating a document with an invoice and PDF attachment
13
+ # document = ZipDatev::Document.new(
14
+ # invoice: invoice,
15
+ # attachments: ["/path/to/invoice.pdf"],
16
+ # invoice_month: "2023-01",
17
+ # folder_name: "Eingangsrechnungen"
18
+ # )
19
+ class Document
20
+ include ActiveModel::Model
21
+ include ActiveModel::Validations
22
+
23
+ attr_accessor :invoice, :attachments, :invoice_month, :folder_name, :repository
24
+
25
+ # Validations
26
+ validates :invoice, presence: true
27
+ validate :invoice_must_be_valid
28
+ validate :attachments_must_exist
29
+
30
+ def initialize(invoice: nil, attachments: [], invoice_month: nil, folder_name: nil, repository: nil)
31
+ @invoice = invoice
32
+ @attachments = attachments || []
33
+ @invoice_month = invoice_month
34
+ @folder_name = folder_name
35
+ @repository = repository
36
+ end
37
+
38
+ private
39
+
40
+ def invoice_must_be_valid
41
+ return if invoice.blank?
42
+ return if invoice.valid?
43
+
44
+ invoice.errors.each do |error|
45
+ errors.add(:invoice, "#{error.attribute} #{error.message}")
46
+ end
47
+ end
48
+
49
+ def attachments_must_exist
50
+ return if attachments.blank?
51
+
52
+ attachments.each do |path|
53
+ next if path.nil?
54
+ next if File.exist?(path)
55
+
56
+ errors.add(:attachments, "file not found: #{path}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipDatev
4
+ # Base error class for all ZipDatev errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when validation fails on models (Invoice, LineItem, Document)
8
+ class ValidationError < Error
9
+ attr_reader :errors
10
+
11
+ def initialize(message = nil, errors: [])
12
+ @errors = errors
13
+ super(message || default_message)
14
+ end
15
+
16
+ private
17
+
18
+ def default_message
19
+ return "Validation failed" if errors.empty?
20
+
21
+ "Validation failed: #{errors.join(", ")}"
22
+ end
23
+ end
24
+
25
+ # Raised when package creation fails
26
+ class PackageError < Error; end
27
+
28
+ # Raised when XSD schema validation fails
29
+ class SchemaValidationError < Error
30
+ attr_reader :schema_errors
31
+
32
+ def initialize(message = nil, schema_errors: [])
33
+ @schema_errors = schema_errors
34
+ super(message || default_message)
35
+ end
36
+
37
+ private
38
+
39
+ def default_message
40
+ return "Schema validation failed" if schema_errors.empty?
41
+
42
+ "Schema validation failed: #{schema_errors.join(", ")}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module ZipDatev
7
+ module Generators
8
+ # Base class for XML generators with shared helper methods.
9
+ #
10
+ # Provides utilities for formatting dates, decimals, and converting
11
+ # Ruby naming conventions to DATEV XML naming conventions.
12
+ class Base
13
+ # DATEV fixed values
14
+ VERSION = "6.0"
15
+ XML_DATA = "Kopie nur zur Verbuchung berechtigt nicht zum Vorsteuerabzug"
16
+
17
+ # Document XML namespaces
18
+ DOCUMENT_XMLNS = "http://xml.datev.de/bedi/tps/document/v06.0"
19
+ DOCUMENT_XSI_SCHEMA_LOCATION = "http://xml.datev.de/bedi/tps/document/v06.0 Document_v060.xsd"
20
+
21
+ # Ledger XML namespaces
22
+ LEDGER_XMLNS = "http://xml.datev.de/bedi/tps/ledger/v060"
23
+ LEDGER_XSI_SCHEMA_LOCATION =
24
+ "http://xml.datev.de/bedi/tps/ledger/v060 Belegverwaltung_online_ledger_import_v060.xsd"
25
+
26
+ # XSI namespace (shared)
27
+ XSI_XMLNS = "http://www.w3.org/2001/XMLSchema-instance"
28
+
29
+ # Element order for accountsPayableLedger (must match XSD sequence)
30
+ ACCOUNTS_PAYABLE_LEDGER_ELEMENTS = %i[
31
+ date amount discount_amount account_no bu_code cost_amount
32
+ cost_category_id cost_category_id2 tax information
33
+ currency_code invoice_id booking_text type_of_receivable
34
+ own_vat_id ship_from_country party_id paid_at internal_invoice_id
35
+ vat_id ship_to_country exchange_rate bank_code bank_account
36
+ bank_country iban swift_code account_name payment_conditions_id
37
+ payment_order discount_percentage discount_payment_date
38
+ discount_amount_2 discount_percentage_2 discount_payment_date_2
39
+ due_date bp_account_no delivery_date order_id
40
+ supplier_name supplier_city
41
+ ].freeze
42
+
43
+ # Fields that should be formatted as decimals with 2 decimal places
44
+ DECIMAL_FIELDS = %i[
45
+ amount discount_amount cost_amount tax discount_percentage
46
+ discount_amount_2 discount_percentage_2
47
+ ].freeze
48
+
49
+ # Fields that should be formatted as decimals with 6 decimal places
50
+ EXCHANGE_RATE_FIELDS = %i[exchange_rate].freeze
51
+
52
+ # Fields that are dates
53
+ DATE_FIELDS = %i[
54
+ date paid_at discount_payment_date discount_payment_date_2
55
+ due_date delivery_date
56
+ ].freeze
57
+
58
+ # Fields that are booleans
59
+ BOOLEAN_FIELDS = %i[payment_order].freeze
60
+
61
+ private
62
+
63
+ # Format a date for XML output (YYYY-MM-DD)
64
+ #
65
+ # @param date [Date, nil] The date to format
66
+ # @return [String, nil] Formatted date string or nil if date is nil
67
+ def format_date(date)
68
+ return nil if date.nil?
69
+
70
+ date.strftime("%Y-%m-%d")
71
+ end
72
+
73
+ # Format a datetime for XML output (YYYY-MM-DDTHH:MM:SS)
74
+ #
75
+ # @param datetime [DateTime, Time, nil] The datetime to format
76
+ # @return [String, nil] Formatted datetime string or nil if datetime is nil
77
+ def format_datetime(datetime)
78
+ return nil if datetime.nil?
79
+
80
+ datetime.strftime("%Y-%m-%dT%H:%M:%S")
81
+ end
82
+
83
+ # Format a decimal value for XML output with specified precision
84
+ #
85
+ # @param value [BigDecimal, Numeric, nil] The value to format
86
+ # @param precision [Integer] Number of decimal places (default: 2)
87
+ # @return [String, nil] Formatted decimal string or nil if value is nil
88
+ def format_decimal(value, precision: 2)
89
+ return nil if value.nil?
90
+
91
+ format("%.#{precision}f", value)
92
+ end
93
+
94
+ # Convert a Ruby snake_case symbol to XML camelCase element name
95
+ #
96
+ # @param ruby_name [Symbol, String] The Ruby attribute name
97
+ # @return [String] The XML element name in camelCase
98
+ def to_xml_name(ruby_name)
99
+ # Handle special cases
100
+ name = ruby_name.to_s
101
+ case name
102
+ when "cost_category_id2"
103
+ "costCategoryId2"
104
+ when "discount_amount_2"
105
+ "discountAmount2"
106
+ when "discount_percentage_2"
107
+ "discountPercentage2"
108
+ when "discount_payment_date_2"
109
+ "discountPaymentDate2"
110
+ else
111
+ name.camelize(:lower)
112
+ end
113
+ end
114
+
115
+ # Format a field value for XML based on its type
116
+ #
117
+ # @param field [Symbol] The field name
118
+ # @param value [Object] The field value
119
+ # @return [String, nil] Formatted value for XML output
120
+ def format_field_value(field, value)
121
+ return nil if value.nil?
122
+
123
+ if DATE_FIELDS.include?(field)
124
+ format_date(value)
125
+ elsif DECIMAL_FIELDS.include?(field)
126
+ format_decimal(value, precision: 2)
127
+ elsif EXCHANGE_RATE_FIELDS.include?(field)
128
+ format_decimal(value, precision: 6)
129
+ elsif BOOLEAN_FIELDS.include?(field)
130
+ value ? "true" : "false"
131
+ else
132
+ value.to_s
133
+ end
134
+ end
135
+
136
+ # Generate a safe ASCII filename from an invoice ID
137
+ #
138
+ # @param invoice_id [String] The invoice ID
139
+ # @return [String] ASCII-safe filename for the ledger XML
140
+ def ledger_filename(invoice_id)
141
+ safe_id = invoice_id.to_s.parameterize(preserve_case: true)
142
+ "Rechnungsdaten_RE_#{safe_id}.xml"
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ZipDatev
6
+ module Generators
7
+ # Generates the DATEV document.xml administrative file.
8
+ #
9
+ # The document.xml file is the manifest that lists all invoices and
10
+ # attachments in the package. It contains references to ledger XML files
11
+ # and optional PDF/image attachments.
12
+ #
13
+ # @example Generate document.xml for a package
14
+ # generator = ZipDatev::Generators::DocumentXml.new(
15
+ # package: package,
16
+ # created_at: Time.now
17
+ # )
18
+ # xml_doc = generator.generate
19
+ class DocumentXml < Base
20
+ # Default folder name for accounts payable ledger
21
+ DEFAULT_FOLDER_NAME = "Eingangsrechnungen"
22
+
23
+ # @return [Package] The package to generate document.xml for
24
+ attr_reader :package
25
+
26
+ # @return [Time] The creation timestamp
27
+ attr_reader :created_at
28
+
29
+ # Initialize a new document XML generator.
30
+ #
31
+ # @param package [Package] The package to generate document.xml for
32
+ # @param created_at [Time] The creation timestamp (default: current time)
33
+ def initialize(package:, created_at: nil)
34
+ super()
35
+ @package = package
36
+ @created_at = created_at || Time.now
37
+ end
38
+
39
+ # Generate the document.xml document.
40
+ #
41
+ # @return [Nokogiri::XML::Document] The generated XML document
42
+ def generate
43
+ Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
44
+ xml.archive(archive_attributes) do
45
+ generate_header(xml)
46
+ generate_content(xml)
47
+ end
48
+ end.doc
49
+ end
50
+
51
+ private
52
+
53
+ # Attributes for the archive root element
54
+ def archive_attributes
55
+ {
56
+ "xmlns" => DOCUMENT_XMLNS,
57
+ "xmlns:xsi" => XSI_XMLNS,
58
+ "xsi:schemaLocation" => DOCUMENT_XSI_SCHEMA_LOCATION,
59
+ "version" => Base::VERSION,
60
+ "generatingSystem" => package.generating_system
61
+ }
62
+ end
63
+
64
+ # Generate the header element
65
+ def generate_header(xml)
66
+ xml.header do
67
+ xml.date format_datetime(created_at)
68
+ end
69
+ end
70
+
71
+ # Generate the content element with all documents
72
+ def generate_content(xml)
73
+ xml.content do
74
+ package.documents.each do |document|
75
+ generate_document(xml, document)
76
+ end
77
+ end
78
+ end
79
+
80
+ # Generate a document element for a single document
81
+ def generate_document(xml, document)
82
+ xml.document do
83
+ # Generate ledger extension (accountsPayableLedger)
84
+ generate_ledger_extension(xml, document)
85
+
86
+ # Generate file extensions for attachments
87
+ document.attachments.each do |attachment_path|
88
+ generate_file_extension(xml, attachment_path)
89
+ end
90
+
91
+ # Generate optional repository structure
92
+ generate_repository(xml, document) if document.repository
93
+ end
94
+ end
95
+
96
+ # Generate the accountsPayableLedger extension element
97
+ def generate_ledger_extension(xml, document)
98
+ datafile = ledger_filename(
99
+ document.invoice.consolidated_invoice_id || document.invoice.invoice_id
100
+ )
101
+
102
+ xml.extension("xsi:type" => "accountsPayableLedger", "datafile" => datafile) do
103
+ # Property key 1: Invoice month (YYYY-MM format)
104
+ # Use provided value or derive from invoice date
105
+ invoice_month = document.invoice_month || derive_invoice_month(document.invoice)
106
+ xml.property("value" => invoice_month, "key" => "1")
107
+
108
+ # Property key 3: Folder name
109
+ # Use provided value or default to "Eingangsrechnungen"
110
+ folder_name = document.folder_name || DEFAULT_FOLDER_NAME
111
+ xml.property("value" => folder_name, "key" => "3")
112
+ end
113
+ end
114
+
115
+ # Derive invoice month from invoice date in YYYY-MM format
116
+ #
117
+ # @param invoice [Invoice] The invoice
118
+ # @return [String] The invoice month in YYYY-MM format
119
+ def derive_invoice_month(invoice)
120
+ date = invoice.consolidated_date || invoice.date
121
+ return Time.now.strftime("%Y-%m") unless date
122
+
123
+ date.strftime("%Y-%m")
124
+ end
125
+
126
+ # Generate a File extension element for an attachment
127
+ def generate_file_extension(xml, attachment_path)
128
+ filename = File.basename(attachment_path)
129
+ xml.extension("xsi:type" => "File", "name" => filename)
130
+ end
131
+
132
+ # Generate the repository element for filing structure
133
+ def generate_repository(xml, document)
134
+ repo = document.repository
135
+ xml.repository do
136
+ xml.level("id" => "1", "name" => repo.level1) if repo.level1
137
+ xml.level("id" => "2", "name" => repo.level2) if repo.level2
138
+ xml.level("id" => "3", "name" => repo.level3) if repo.level3
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end