zipdatev 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93a524d3a6c114e4ba449add730c39e4db83007ff043ef1b0e734786b4f56c1f
4
- data.tar.gz: be5eb61e54fb13aca8fc308286c6ca02b5302f1663217b5470770e4e7fe786be
3
+ metadata.gz: 16ae34c9ade5ebebaf3ec979f10d8816b5ab871f68aa06070051ca9b41d2b4c0
4
+ data.tar.gz: ef6f9b9345a13d8b2fc1aff5a0e67d7d932a4a244cdbc384a2b79fceb25267f8
5
5
  SHA512:
6
- metadata.gz: 3637481aa9773fcdc5458ecbdced141d9fa817fc6098df825b6e395fcee5fa0f92ce33ab18d6bef5d8d499f037a19f691f5f4873b44bee233c4280393c6c973b
7
- data.tar.gz: 87d7e721150ac9771e7ec9bc20d1c7a2569a2c9736cd30ff9b8b071e269571b30f8c8dda538436155b06f54d0eb1889523fb09ed8adbd85d06cadb8fa806148e
6
+ metadata.gz: b47bb6b5874e07f1a0a85e3a0f6ad4b28e39124bdf78c322e3772584ea04f3c6f924c5ea5f5b2c3f9ca206245de1fcb86e126188021806b9808ec0dc6a64819d
7
+ data.tar.gz: d691a9ee6263c1cbb54aa8679df7caac69e70c4b7e53db18b3831b5a0395fdecc134f4b6ac0f10bbb26319b37ec6ab61f9bdceb0296953f030916bca1c492c1a
@@ -71,6 +71,10 @@ module ZipDatev
71
71
  ZA ZM ZW
72
72
  ].freeze
73
73
 
74
+ # Pattern for GUID/UUID: 8-4-4-4-12 hex digits (RFC 4122)
75
+ # From XSD p10037: [0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}
76
+ GUID_PATTERN = /\A[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\z/
77
+
74
78
  # Pattern for invoice ID: alphanumeric + $%&*+-/
75
79
  # From XSD p10040: [a-zA-Z0-9$%&*+-/]{0,36}
76
80
  INVOICE_ID_PATTERN = %r{\A[a-zA-Z0-9$%&*+\-/]{0,36}\z}
@@ -105,6 +109,11 @@ module ZipDatev
105
109
  MIN_AMOUNT = BigDecimal("-9999999999.99")
106
110
  MAX_AMOUNT = BigDecimal("9999999999.99")
107
111
 
112
+ # Cost amount (KOST-Menge) constraints
113
+ # From XSD p37: 12 integer + 4 fraction digits, min 0.0001
114
+ MIN_COST_AMOUNT = BigDecimal("0.0001")
115
+ MAX_COST_AMOUNT = BigDecimal("999999999999.9999")
116
+
108
117
  # Tax percentage constraints
109
118
  # From XSD p33: 0.00 to 99.99
110
119
  MIN_TAX = BigDecimal("0.00")
@@ -143,6 +152,14 @@ module ZipDatev
143
152
  # String length constraints
144
153
  INVOICE_ID_MAX_LENGTH = 36
145
154
  INTERNAL_INVOICE_ID_MAX_LENGTH = 12
155
+ TYPE_OF_RECEIVABLE_MAX_LENGTH = 10
156
+
157
+ # Document metadata length constraints (from Document_types_v060.xsd)
158
+ DOCUMENT_DESCRIPTION_MAX_LENGTH = 40 # p10052: Bemerkung 040
159
+ DOCUMENT_KEYWORDS_MAX_LENGTH = 255 # p10051: Bemerkung 255
160
+ DOCUMENT_PROPERTY_MAX_LENGTH = 255 # p20: Dateiname/property value max 255
161
+ HEADER_DESCRIPTION_MAX_LENGTH = 255 # p10051: Bemerkung 255
162
+ CLIENT_NAME_MAX_LENGTH = 36 # p17: Name Mandant
146
163
  ORDER_ID_MAX_LENGTH = 30
147
164
  BOOKING_TEXT_MAX_LENGTH = 60
148
165
  INFORMATION_MAX_LENGTH = 120
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_model"
4
+ require_relative "constants"
4
5
 
5
6
  module ZipDatev
6
7
  # Represents a document entry in the DATEV package.
@@ -16,23 +17,66 @@ module ZipDatev
16
17
  # invoice_month: "2023-01",
17
18
  # folder_name: "Eingangsrechnungen"
18
19
  # )
20
+ #
21
+ # @example Creating a document with type, processID, description, and keywords
22
+ # document = ZipDatev::Document.new(
23
+ # invoice: invoice,
24
+ # type: 1,
25
+ # process_id: 2,
26
+ # description: "January invoice",
27
+ # keywords: "supplier; january; 2023"
28
+ # )
29
+ #
30
+ # @example Creating a document with documentintern properties
31
+ # document = ZipDatev::Document.new(
32
+ # invoice: invoice,
33
+ # properties: [{ key: "CustomField", value: "MyValue" }]
34
+ # )
19
35
  class Document
20
36
  include ActiveModel::Model
21
37
  include ActiveModel::Validations
22
38
 
23
- attr_accessor :invoice, :attachments, :invoice_month, :folder_name, :repository
39
+ attr_accessor :invoice, :attachments, :invoice_month, :folder_name, :repository, :guid,
40
+ :type, :process_id, :description, :keywords, :properties
24
41
 
25
42
  # Validations
26
43
  validates :invoice, presence: true
44
+ validates :type, inclusion: { in: [1, 2] }, allow_nil: true
45
+ validates :process_id, inclusion: { in: [1, 2] }, allow_nil: true
46
+ validates :description, length: { maximum: Constants::DOCUMENT_DESCRIPTION_MAX_LENGTH }, allow_nil: true
47
+ validates :keywords, length: { maximum: Constants::DOCUMENT_KEYWORDS_MAX_LENGTH }, allow_nil: true
27
48
  validate :invoice_must_be_valid
28
49
  validate :attachments_must_exist
50
+ validate :guid_must_be_valid_format
51
+ validate :properties_must_be_valid
29
52
 
30
- def initialize(invoice: nil, attachments: [], invoice_month: nil, folder_name: nil, repository: nil)
53
+ # @param invoice [Invoice] The invoice (required)
54
+ # @param attachments [Array<String>] Paths to attachment files
55
+ # @param invoice_month [String] Invoice month in "YYYY-MM" format
56
+ # @param folder_name [String] Folder name for the accountsPayableLedger extension
57
+ # @param repository [Repository, nil] Optional filing structure for this document
58
+ # @param guid [String, nil] Optional GUID for the document element (RFC 4122)
59
+ # @param type [Integer, nil] Document type: 1 = incoming invoice, 2 = outgoing invoice
60
+ # @param process_id [Integer, nil] Processing flag: 1 = booking-relevant, 2 = archiving-relevant
61
+ # @param description [String, nil] Document description (max 40 chars)
62
+ # @param keywords [String, nil] Keywords/notes for the document (max 255 chars)
63
+ # @param properties [Array<Hash>, nil] Array of {key:, value:} pairs for documentintern element
64
+ # rubocop:disable Metrics/ParameterLists
65
+ def initialize(invoice: nil, attachments: [], invoice_month: nil, folder_name: nil,
66
+ repository: nil, guid: nil, type: nil, process_id: nil,
67
+ description: nil, keywords: nil, properties: nil)
68
+ # rubocop:enable Metrics/ParameterLists
31
69
  @invoice = invoice
32
70
  @attachments = attachments || []
33
71
  @invoice_month = invoice_month
34
72
  @folder_name = folder_name
35
73
  @repository = repository
74
+ @guid = guid
75
+ @type = type
76
+ @process_id = process_id
77
+ @description = description
78
+ @keywords = keywords
79
+ @properties = properties || []
36
80
  end
37
81
 
38
82
  private
@@ -56,5 +100,33 @@ module ZipDatev
56
100
  errors.add(:attachments, "file not found: #{path}")
57
101
  end
58
102
  end
103
+
104
+ def guid_must_be_valid_format
105
+ return if guid.blank?
106
+ return if guid.match?(Constants::GUID_PATTERN)
107
+
108
+ errors.add(:guid, "must be a valid GUID (RFC 4122 format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)")
109
+ end
110
+
111
+ def properties_must_be_valid
112
+ return if properties.blank?
113
+
114
+ properties.each_with_index { |prop, idx| validate_property_entry(prop, idx) }
115
+ end
116
+
117
+ def validate_property_entry(prop, idx)
118
+ unless prop.is_a?(Hash) && prop[:key].present? && prop[:value].present?
119
+ errors.add(:properties, "entry #{idx + 1} must have :key and :value")
120
+ return
121
+ end
122
+
123
+ validate_property_lengths(prop, idx)
124
+ end
125
+
126
+ def validate_property_lengths(prop, idx)
127
+ max = Constants::DOCUMENT_PROPERTY_MAX_LENGTH
128
+ errors.add(:properties, "entry #{idx + 1} key exceeds #{max} chars") if prop[:key].to_s.length > max
129
+ errors.add(:properties, "entry #{idx + 1} value exceeds #{max} chars") if prop[:value].to_s.length > max
130
+ end
59
131
  end
60
132
  end
@@ -43,10 +43,14 @@ module ZipDatev
43
43
 
44
44
  # Fields that should be formatted as decimals with 2 decimal places
45
45
  DECIMAL_FIELDS = %i[
46
- amount discount_amount cost_amount tax discount_percentage
46
+ amount discount_amount tax discount_percentage
47
47
  discount_amount_2 discount_percentage_2
48
48
  ].freeze
49
49
 
50
+ # Fields that should be formatted as decimals with 4 decimal places
51
+ # cost_amount (KOST-Menge) uses p37 type: 12 integer + 4 fraction digits
52
+ COST_AMOUNT_FIELDS = %i[cost_amount].freeze
53
+
50
54
  # Fields that should be formatted as decimals with 6 decimal places
51
55
  EXCHANGE_RATE_FIELDS = %i[exchange_rate].freeze
52
56
 
@@ -131,6 +135,8 @@ module ZipDatev
131
135
  format_date(value)
132
136
  elsif DECIMAL_FIELDS.include?(field)
133
137
  format_decimal(value, precision: 2)
138
+ elsif COST_AMOUNT_FIELDS.include?(field)
139
+ format_decimal(value, precision: 4)
134
140
  elsif EXCHANGE_RATE_FIELDS.include?(field)
135
141
  format_decimal(value, precision: 6)
136
142
  elsif BOOLEAN_FIELDS.include?(field)
@@ -52,22 +52,33 @@ module ZipDatev
52
52
 
53
53
  # Attributes for the archive root element
54
54
  def archive_attributes
55
- {
55
+ attrs = {
56
56
  "xmlns" => DOCUMENT_XMLNS,
57
57
  "xmlns:xsi" => XSI_XMLNS,
58
58
  "xsi:schemaLocation" => DOCUMENT_XSI_SCHEMA_LOCATION,
59
59
  "version" => Base::VERSION,
60
60
  "generatingSystem" => package.generating_system
61
61
  }
62
+ attrs["guid"] = package.archive_guid if package.archive_guid.present?
63
+ attrs
62
64
  end
63
65
 
64
66
  # Generate the header element
65
67
  def generate_header(xml)
66
68
  xml.header do
67
69
  xml.date format_datetime(created_at)
70
+ generate_header_optional_fields(xml)
68
71
  end
69
72
  end
70
73
 
74
+ # Generate optional header fields (description, consultant/client metadata)
75
+ def generate_header_optional_fields(xml)
76
+ xml.description package.description if package.description.present?
77
+ xml.consultantNumber package.consultant_number.to_s if package.consultant_number
78
+ xml.clientNumber package.client_number.to_s if package.client_number
79
+ xml.clientName package.client_name if package.client_name.present?
80
+ end
81
+
71
82
  # Generate the content element with all documents
72
83
  def generate_content(xml)
73
84
  xml.content do
@@ -79,20 +90,25 @@ module ZipDatev
79
90
 
80
91
  # Generate a document element for a single document
81
92
  def generate_document(xml, document)
82
- xml.document do
83
- # Generate ledger extension (accountsPayableLedger)
93
+ xml.document(build_document_attributes(document)) do
94
+ xml.description document.description if document.description.present?
95
+ xml.keywords document.keywords if document.keywords.present?
84
96
  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
97
+ document.attachments.each { |path| generate_file_extension(xml, path) }
92
98
  generate_repository(xml, document) if document.repository
99
+ generate_documentintern(xml, document) if document.properties.any?
93
100
  end
94
101
  end
95
102
 
103
+ # Build the attribute hash for a document element
104
+ def build_document_attributes(document)
105
+ attrs = {}
106
+ attrs["guid"] = document.guid if document.guid.present?
107
+ attrs["type"] = document.type.to_s if document.type
108
+ attrs["processID"] = document.process_id.to_s if document.process_id
109
+ attrs
110
+ end
111
+
96
112
  # Generate the accountsPayableLedger extension element
97
113
  def generate_ledger_extension(xml, document)
98
114
  datafile = ledger_filename(
@@ -138,6 +154,18 @@ module ZipDatev
138
154
  xml.level("id" => "3", "name" => repo.level3) if repo.level3
139
155
  end
140
156
  end
157
+
158
+ # Generate the documentintern element with property key/value pairs
159
+ #
160
+ # @param xml [Nokogiri::XML::Builder] The XML builder
161
+ # @param document [Document] The document with properties
162
+ def generate_documentintern(xml, document)
163
+ xml.documentintern do
164
+ document.properties.each do |prop|
165
+ xml.property("key" => prop[:key].to_s, "value" => prop[:value].to_s)
166
+ end
167
+ end
168
+ end
141
169
  end
142
170
  end
143
171
  end
@@ -45,6 +45,7 @@ module ZipDatev
45
45
  # discount_payment_date_2: Date.new(2023, 1, 22),
46
46
  # due_date: Date.new(2023, 1, 26)
47
47
  # )
48
+ # rubocop:disable Metrics/ClassLength
48
49
  class Invoice
49
50
  include ActiveModel::Model
50
51
  include ActiveModel::Attributes
@@ -104,6 +105,10 @@ module ZipDatev
104
105
  validates :exchange_rate, decimal_precision: { places: 6 }, allow_nil: true
105
106
 
106
107
  # Cost amount validations
108
+ validates :cost_amount, numericality: {
109
+ greater_than_or_equal_to: Constants::MIN_COST_AMOUNT,
110
+ less_than_or_equal_to: Constants::MAX_COST_AMOUNT
111
+ }, allow_nil: true
107
112
  validates :cost_amount, decimal_precision: { places: 4 }, allow_nil: true
108
113
 
109
114
  # Account number validations
@@ -148,6 +153,8 @@ module ZipDatev
148
153
  validates :bank_account, length: { maximum: Constants::BANK_ACCOUNT_MAX_LENGTH }, allow_nil: true
149
154
  validates :cost_category_id, length: { maximum: Constants::COST_CATEGORY_ID_MAX_LENGTH }, allow_nil: true
150
155
  validates :cost_category_id2, length: { maximum: Constants::COST_CATEGORY_ID_MAX_LENGTH }, allow_nil: true
156
+ validates :own_vat_id, length: { maximum: Constants::VAT_ID_MAX_LENGTH }, allow_nil: true
157
+ validates :type_of_receivable, length: { maximum: Constants::TYPE_OF_RECEIVABLE_MAX_LENGTH }, allow_nil: true
151
158
 
152
159
  # Format validations (codes and patterns)
153
160
  validates :currency_code, inclusion: { in: Constants::CURRENCY_CODES }, allow_nil: true
@@ -160,6 +167,7 @@ module ZipDatev
160
167
  validates :iban, format: { with: Constants::IBAN_PATTERN }, allow_blank: true
161
168
  validates :swift_code, format: { with: Constants::SWIFT_PATTERN }, allow_blank: true
162
169
  validates :vat_id, format: { with: Constants::VAT_ID_PATTERN }, allow_blank: true
170
+ validates :own_vat_id, format: { with: Constants::VAT_ID_PATTERN }, allow_blank: true
163
171
 
164
172
  # Date range validations
165
173
  validates :date, date_range: true, allow_nil: true
@@ -336,4 +344,5 @@ module ZipDatev
336
344
  attributes.compact.transform_keys(&:to_sym)
337
345
  end
338
346
  end
347
+ # rubocop:enable Metrics/ClassLength
339
348
  end
@@ -73,6 +73,10 @@ module ZipDatev
73
73
  validates :exchange_rate, decimal_precision: { places: 6 }, allow_nil: true
74
74
 
75
75
  # Cost amount validations
76
+ validates :cost_amount, numericality: {
77
+ greater_than_or_equal_to: Constants::MIN_COST_AMOUNT,
78
+ less_than_or_equal_to: Constants::MAX_COST_AMOUNT
79
+ }, allow_nil: true
76
80
  validates :cost_amount, decimal_precision: { places: 4 }, allow_nil: true
77
81
 
78
82
  # Account number validations
@@ -117,6 +121,8 @@ module ZipDatev
117
121
  validates :bank_account, length: { maximum: Constants::BANK_ACCOUNT_MAX_LENGTH }, allow_nil: true
118
122
  validates :cost_category_id, length: { maximum: Constants::COST_CATEGORY_ID_MAX_LENGTH }, allow_nil: true
119
123
  validates :cost_category_id2, length: { maximum: Constants::COST_CATEGORY_ID_MAX_LENGTH }, allow_nil: true
124
+ validates :own_vat_id, length: { maximum: Constants::VAT_ID_MAX_LENGTH }, allow_nil: true
125
+ validates :type_of_receivable, length: { maximum: Constants::TYPE_OF_RECEIVABLE_MAX_LENGTH }, allow_nil: true
120
126
 
121
127
  # Format validations (codes and patterns)
122
128
  validates :currency_code, inclusion: { in: Constants::CURRENCY_CODES }, allow_nil: true
@@ -129,6 +135,7 @@ module ZipDatev
129
135
  validates :iban, format: { with: Constants::IBAN_PATTERN }, allow_blank: true
130
136
  validates :swift_code, format: { with: Constants::SWIFT_PATTERN }, allow_blank: true
131
137
  validates :vat_id, format: { with: Constants::VAT_ID_PATTERN }, allow_blank: true
138
+ validates :own_vat_id, format: { with: Constants::VAT_ID_PATTERN }, allow_blank: true
132
139
 
133
140
  # Date range validations
134
141
  validates :date, date_range: true, allow_nil: true
@@ -27,16 +27,33 @@ module ZipDatev
27
27
  # Size limits as specified in DATEV documentation
28
28
  MAX_PACKAGE_SIZE = 465 * 1024 * 1024 # 465 MB
29
29
  MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
30
- MAX_DOCUMENTS = 5000
30
+ # XSD Document_v060.xsd specifies maxOccurs="4999" for content/document elements
31
+ MAX_DOCUMENTS = 4999
31
32
 
32
33
  # Pattern for valid ASCII filenames (no special characters that cause issues)
33
34
  ASCII_FILENAME_PATTERN = /\A[\x20-\x7E]+\z/
34
35
 
35
- attr_reader :generator_info, :generating_system, :documents
36
-
37
- def initialize(generator_info:, generating_system:)
36
+ attr_reader :generator_info, :generating_system, :documents,
37
+ :archive_guid, :description, :consultant_number, :client_number, :client_name
38
+
39
+ # @param generator_info [String] Name of company/system creating the data (required)
40
+ # @param generating_system [String] Software system identifier (required)
41
+ # @param archive_guid [String, nil] Optional GUID for the archive element (RFC 4122)
42
+ # @param description [String, nil] Optional description for the archive header (max 255 chars)
43
+ # @param consultant_number [Integer, nil] Optional DATEV Beraternummer (1000–9999999)
44
+ # @param client_number [Integer, nil] Optional DATEV Mandantennummer (0–99999)
45
+ # @param client_name [String, nil] Optional client/company name (max 36 chars)
46
+ # rubocop:disable Metrics/ParameterLists
47
+ def initialize(generator_info:, generating_system:, archive_guid: nil,
48
+ description: nil, consultant_number: nil, client_number: nil, client_name: nil)
49
+ # rubocop:enable Metrics/ParameterLists
38
50
  @generator_info = generator_info
39
51
  @generating_system = generating_system
52
+ @archive_guid = archive_guid
53
+ @description = description
54
+ @consultant_number = consultant_number
55
+ @client_number = client_number
56
+ @client_name = client_name
40
57
  @documents = []
41
58
  end
42
59
 
@@ -45,16 +62,32 @@ module ZipDatev
45
62
  # @param invoice [Invoice] The invoice data
46
63
  # @param attachments [Array<String>] Paths to attachment files
47
64
  # @param invoice_month [String] Invoice month in "YYYY-MM" format
48
- # @param folder_name [String] Folder name for filing
49
- # @param repository [Repository, nil] Optional repository structure
65
+ # @param folder_name [String] Folder name for the accountsPayableLedger extension
66
+ # @param repository [Repository, nil] Optional filing structure for the document
67
+ # @param guid [String, nil] Optional GUID (RFC 4122) for the document element
68
+ # @param type [Integer, nil] Document type: 1 = incoming invoice, 2 = outgoing invoice
69
+ # @param process_id [Integer, nil] Processing flag: 1 = booking-relevant, 2 = archiving-relevant
70
+ # @param description [String, nil] Document description (max 40 chars)
71
+ # @param keywords [String, nil] Keywords/notes (max 255 chars)
72
+ # @param properties [Array<Hash>, nil] documentintern key/value pairs
50
73
  # @return [Document] The created document
51
- def add_document(invoice:, attachments: [], invoice_month: nil, folder_name: nil, repository: nil)
74
+ # rubocop:disable Metrics/ParameterLists
75
+ def add_document(invoice:, attachments: [], invoice_month: nil, folder_name: nil,
76
+ repository: nil, guid: nil, type: nil, process_id: nil,
77
+ description: nil, keywords: nil, properties: nil)
78
+ # rubocop:enable Metrics/ParameterLists
52
79
  document = Document.new(
53
80
  invoice: invoice,
54
81
  attachments: attachments,
55
82
  invoice_month: invoice_month,
56
83
  folder_name: folder_name,
57
- repository: repository
84
+ repository: repository,
85
+ guid: guid,
86
+ type: type,
87
+ process_id: process_id,
88
+ description: description,
89
+ keywords: keywords,
90
+ properties: properties
58
91
  )
59
92
  @documents << document
60
93
  document
@@ -62,7 +62,7 @@ module ZipDatev
62
62
 
63
63
  record.errors.add(
64
64
  :base,
65
- "All line items must have the same date, found: #{dates.map(&:to_s).join(", ")}"
65
+ "All line items must have the same date, found: #{dates.join(", ")}"
66
66
  )
67
67
  end
68
68
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZipDatev
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zipdatev
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dexter Team