zipdatev 0.1.2 → 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: d6ce4539843ac8f2840887a89f59845cd0be78851e3716319542599dd49277ba
4
- data.tar.gz: 4585e899c3328ce71283cc56f617b1bb5e5bb60d95dab358a5e730f52a263810
3
+ metadata.gz: 16ae34c9ade5ebebaf3ec979f10d8816b5ab871f68aa06070051ca9b41d2b4c0
4
+ data.tar.gz: ef6f9b9345a13d8b2fc1aff5a0e67d7d932a4a244cdbc384a2b79fceb25267f8
5
5
  SHA512:
6
- metadata.gz: bc7772d543e0fab287ec9123946a95f0785512320f5d90916d7d1e64419f4b4547633e68e02c755fe8fefee22eb7c99e6603bf0ef1d5513552dc184b043738c0
7
- data.tar.gz: 37f4b06a40a5ba78d4a14d6f0dd5a1d2dbdd506db055fa3cbcc9fb92b51747d565caa216bb557eb84f30e56225d680cdfcea36ccacbee631a237a298f808eb97
6
+ metadata.gz: b47bb6b5874e07f1a0a85e3a0f6ad4b28e39124bdf78c322e3772584ea04f3c6f924c5ea5f5b2c3f9ca206245de1fcb86e126188021806b9808ec0dc6a64819d
7
+ data.tar.gz: d691a9ee6263c1cbb54aa8679df7caac69e70c4b7e53db18b3831b5a0395fdecc134f4b6ac0f10bbb26319b37ec6ab61f9bdceb0296953f030916bca1c492c1a
@@ -109,6 +109,11 @@ module ZipDatev
109
109
  MIN_AMOUNT = BigDecimal("-9999999999.99")
110
110
  MAX_AMOUNT = BigDecimal("9999999999.99")
111
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
+
112
117
  # Tax percentage constraints
113
118
  # From XSD p33: 0.00 to 99.99
114
119
  MIN_TAX = BigDecimal("0.00")
@@ -147,6 +152,14 @@ module ZipDatev
147
152
  # String length constraints
148
153
  INVOICE_ID_MAX_LENGTH = 36
149
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
150
163
  ORDER_ID_MAX_LENGTH = 30
151
164
  BOOKING_TEXT_MAX_LENGTH = 60
152
165
  INFORMATION_MAX_LENGTH = 120
@@ -17,25 +17,66 @@ module ZipDatev
17
17
  # invoice_month: "2023-01",
18
18
  # folder_name: "Eingangsrechnungen"
19
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
+ # )
20
35
  class Document
21
36
  include ActiveModel::Model
22
37
  include ActiveModel::Validations
23
38
 
24
- attr_accessor :invoice, :attachments, :invoice_month, :folder_name, :repository, :guid
39
+ attr_accessor :invoice, :attachments, :invoice_month, :folder_name, :repository, :guid,
40
+ :type, :process_id, :description, :keywords, :properties
25
41
 
26
42
  # Validations
27
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
28
48
  validate :invoice_must_be_valid
29
49
  validate :attachments_must_exist
30
50
  validate :guid_must_be_valid_format
51
+ validate :properties_must_be_valid
31
52
 
32
- def initialize(invoice: nil, attachments: [], invoice_month: nil, folder_name: nil, repository: nil, guid: 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
33
69
  @invoice = invoice
34
70
  @attachments = attachments || []
35
71
  @invoice_month = invoice_month
36
72
  @folder_name = folder_name
37
73
  @repository = repository
38
74
  @guid = guid
75
+ @type = type
76
+ @process_id = process_id
77
+ @description = description
78
+ @keywords = keywords
79
+ @properties = properties || []
39
80
  end
40
81
 
41
82
  private
@@ -66,5 +107,26 @@ module ZipDatev
66
107
 
67
108
  errors.add(:guid, "must be a valid GUID (RFC 4122 format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)")
68
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
69
131
  end
70
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,23 +90,25 @@ module ZipDatev
79
90
 
80
91
  # Generate a document element for a single document
81
92
  def generate_document(xml, document)
82
- attrs = {}
83
- attrs["guid"] = document.guid if document.guid.present?
84
-
85
- xml.document(attrs) do
86
- # 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?
87
96
  generate_ledger_extension(xml, document)
88
-
89
- # Generate file extensions for attachments
90
- document.attachments.each do |attachment_path|
91
- generate_file_extension(xml, attachment_path)
92
- end
93
-
94
- # Generate optional repository structure
97
+ document.attachments.each { |path| generate_file_extension(xml, path) }
95
98
  generate_repository(xml, document) if document.repository
99
+ generate_documentintern(xml, document) if document.properties.any?
96
100
  end
97
101
  end
98
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
+
99
112
  # Generate the accountsPayableLedger extension element
100
113
  def generate_ledger_extension(xml, document)
101
114
  datafile = ledger_filename(
@@ -141,6 +154,18 @@ module ZipDatev
141
154
  xml.level("id" => "3", "name" => repo.level3) if repo.level3
142
155
  end
143
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
144
169
  end
145
170
  end
146
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,18 +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
50
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
51
73
  # @return [Document] The created document
52
- def add_document(invoice:, attachments: [], invoice_month: nil, folder_name: nil, repository: nil, guid: 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
53
79
  document = Document.new(
54
80
  invoice: invoice,
55
81
  attachments: attachments,
56
82
  invoice_month: invoice_month,
57
83
  folder_name: folder_name,
58
84
  repository: repository,
59
- guid: guid
85
+ guid: guid,
86
+ type: type,
87
+ process_id: process_id,
88
+ description: description,
89
+ keywords: keywords,
90
+ properties: properties
60
91
  )
61
92
  @documents << document
62
93
  document
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZipDatev
4
- VERSION = "0.1.2"
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.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dexter Team