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 +7 -0
- data/LICENSE +21 -0
- data/lib/zipdatev/constants.rb +160 -0
- data/lib/zipdatev/document.rb +60 -0
- data/lib/zipdatev/errors.rb +45 -0
- data/lib/zipdatev/generators/base.rb +146 -0
- data/lib/zipdatev/generators/document_xml.rb +143 -0
- data/lib/zipdatev/generators/ledger_xml.rb +144 -0
- data/lib/zipdatev/invoice.rb +339 -0
- data/lib/zipdatev/line_item.rb +203 -0
- data/lib/zipdatev/package.rb +267 -0
- data/lib/zipdatev/repository.rb +42 -0
- data/lib/zipdatev/schema_validator.rb +151 -0
- data/lib/zipdatev/schemas/Belegverwaltung_online_ledger_import_v060.xsd +546 -0
- data/lib/zipdatev/schemas/Belegverwaltung_online_ledger_types_v060.xsd +1181 -0
- data/lib/zipdatev/schemas/Document_types_v060.xsd +410 -0
- data/lib/zipdatev/schemas/Document_v060.xsd +567 -0
- data/lib/zipdatev/validators/consolidation_validator.rb +70 -0
- data/lib/zipdatev/validators/date_range_validator.rb +42 -0
- data/lib/zipdatev/validators/decimal_precision_validator.rb +55 -0
- data/lib/zipdatev/validators/discount_dates_validator.rb +74 -0
- data/lib/zipdatev/validators/payment_conditions_validator.rb +49 -0
- data/lib/zipdatev/version.rb +5 -0
- data/lib/zipdatev.rb +55 -0
- metadata +110 -0
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
|