secretariat 3.2.0 → 3.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fb7cefb10c5f0b91d7b501d0501be8d52094de356e29f4bf0bbacb08bff3f25
4
- data.tar.gz: 5ac439e6180ad5c51a6dcc6b835f28ca7b9ee4d63d4e42734ef186502a0dfd63
3
+ metadata.gz: 4aede1e2eafc1189446e824cb5977b9c3844f71fa4c505214aa4d9b9f79e1101
4
+ data.tar.gz: 533c4f81ae8b50977df60f6c2bc31565cbbfd93066c52d8cf1961215971319fd
5
5
  SHA512:
6
- metadata.gz: 9df593f5068171fb4d6f7aaaf114981eb08edc51e621e1f0f782b8615ce461f16a41e6c80b3557dbac2271805b5e1d22f56807ef35503cb67a07c7ba89d8a75f
7
- data.tar.gz: 2bc606ddd72a5f442a8c2c042859a566a0da3cff6a9e20b34b3b95f31cd9e0516fba1ab8b01b15ce3bf5e29c27bc562bb88a241504ccd34963d085d85fc02a06
6
+ metadata.gz: a3358bd3eec524b4e76fc8f32945dca7cb665d683a256e69350fa31c340db2a75f77f3f64883978da387a52edd9beb4810ffe362d67341c81abe28579a979e1f
7
+ data.tar.gz: 5f347f7253bc8f814a0e1524c968506679d7bb1108c9a4cf03d9716830829ae393e5cde2df2b9d83210a237b075354863ef06af10df2e3d984a699a9c1d758c3
@@ -0,0 +1,83 @@
1
+ =begin
2
+ Copyright Jan Krutisch
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ =end
17
+
18
+
19
+ require 'mime/types'
20
+
21
+ module Secretariat
22
+ Attachment = Struct.new('Attachment',
23
+ :filename,
24
+ :type_code,
25
+ :base64,
26
+
27
+ keyword_init: true
28
+ ) do
29
+ include Versioner
30
+
31
+ def errors
32
+ @errors
33
+ end
34
+
35
+ def valid?
36
+ @errors = []
37
+
38
+ @errors << "the attribute filename needs to be present" if filename.nil? || filename == ''
39
+ @errors << "the attribute type_code needs to be present" if type_code.nil? || type_code == ''
40
+ @errors << "the attribute base64 needs to be present" if base64.nil? || base64 == ''
41
+
42
+ if type_code.to_i != 916
43
+ @errors << "we only support type_code 916"
44
+ return false
45
+ end
46
+
47
+ if mime_code.nil?
48
+ @errors << "cannot determine content type for filename: #{filename}"
49
+ return false
50
+ end
51
+
52
+ if !ALLOWED_MIME_TYPES.include?(mime_code)
53
+ @errors << "the mime_code '#{mime_code}' is not allowed"
54
+ return false
55
+ end
56
+
57
+ return true
58
+ end
59
+
60
+ def mime_code
61
+ type_for = MIME::Types.type_for(filename).first
62
+ return if type_for.nil?
63
+
64
+ type_for.content_type
65
+ end
66
+
67
+ def to_xml(xml, attachment_index, version: 2, validate: true)
68
+ if validate && !valid?
69
+ pp errors
70
+ raise ValidationError.new("Attachment #{attachment_index} is invalid", errors)
71
+ end
72
+
73
+ xml['ram'].AdditionalReferencedDocument do
74
+ xml['ram'].IssuerAssignedID filename
75
+ xml['ram'].TypeCode type_code
76
+ xml['ram'].Name filename
77
+ xml['ram'].AttachmentBinaryObject(mimeCode: mime_code, filename: filename) do
78
+ xml.text(base64)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -15,6 +15,14 @@ limitations under the License.
15
15
  =end
16
16
 
17
17
  module Secretariat
18
+ ALLOWED_MIME_TYPES = [
19
+ "application/pdf",
20
+ "application/vnd.oasis.opendocument.spreadsheet",
21
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
+ "image/jpeg",
23
+ "image/png",
24
+ "text/csv"
25
+ ]
18
26
 
19
27
  TAX_CATEGORY_CODES = {
20
28
  :STANDARDRATE => "S",
@@ -45,15 +53,21 @@ module Secretariat
45
53
  :DEBITADVICE => "31",
46
54
  :CREDITCARD => "48",
47
55
  :DEBIT => "49",
48
- :COMPENSATION => "97",
56
+ :COMPENSATION => "97"
49
57
  }
50
58
 
51
59
  TAX_EXEMPTION_REASONS = {
52
60
  :REVERSECHARGE => 'Reverse Charge',
53
61
  :INTRACOMMUNITY => 'Intra-community transaction',
54
- :EXPORT => 'Export outside the EU'
62
+ :EXPORT => 'Export outside the EU',
63
+ :TAXEXEMPT => 'VAT exempt',
64
+ :UNTAXEDSERVICE => 'Not subject to VAT'
55
65
  }
56
66
 
67
+ # For the background of vertical and horizontal tax calculation see https://hilfe.pacemo.de/de-form/articles/3489851-rundungsfehler-bei-rechnungen
68
+ # The idea of introducing an unknown value is that this could be inferred from the given invoice total and line items by probing both variants and selecting the matching one - or reporting a taxation error if neither matches.
69
+ TAX_CALCULATION_METHODS = %i[HORIZONTAL VERTICAL UNKNOWN].freeze
70
+
57
71
  UNIT_CODES = {
58
72
  :PIECE => "C62",
59
73
  :DAY => "DAY",
@@ -28,6 +28,7 @@ module Secretariat
28
28
  :line_items,
29
29
  :currency_code,
30
30
  :payment_type,
31
+ :payment_reference,
31
32
  :payment_text,
32
33
  :payment_terms_text,
33
34
  :payment_due_date,
@@ -40,7 +41,8 @@ module Secretariat
40
41
  :grand_total_amount,
41
42
  :due_amount,
42
43
  :paid_amount,
43
-
44
+ :tax_calculation_method,
45
+ :attachments,
44
46
  keyword_init: true
45
47
  ) do
46
48
 
@@ -54,21 +56,28 @@ module Secretariat
54
56
  tax_reason || TAX_EXEMPTION_REASONS[tax_category]
55
57
  end
56
58
 
57
- def tax_category_code(version: 2)
59
+ def tax_category_code(tax, version: 2)
58
60
  if version == 1
59
- return TAX_CATEGORY_CODES_1[tax_category] || 'S'
61
+ return TAX_CATEGORY_CODES_1[tax.tax_category || tax_category] || 'S'
60
62
  end
61
- TAX_CATEGORY_CODES[tax_category] || 'S'
63
+ TAX_CATEGORY_CODES[tax.tax_category || tax_category] || 'S'
62
64
  end
63
65
 
64
66
  def taxes
65
67
  taxes = {}
66
68
  line_items.each do |line_item|
67
- taxes[line_item.tax_percent] = Tax.new(tax_percent: BigDecimal(line_item.tax_percent)) if taxes[line_item.tax_percent].nil?
69
+ taxes[line_item.tax_percent] = Tax.new(tax_percent: BigDecimal(line_item.tax_percent), tax_category: line_item.tax_category) if taxes[line_item.tax_percent].nil?
68
70
  taxes[line_item.tax_percent].tax_amount += BigDecimal(line_item.tax_amount)
69
71
  taxes[line_item.tax_percent].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity
70
72
  end
71
- taxes.values
73
+ if tax_calculation_method == :VERTICAL
74
+ taxes.values.map do |tax|
75
+ tax.tax_amount = (tax.base_amount * tax.tax_percent / 100).round(2)
76
+ tax
77
+ end
78
+ else
79
+ taxes.values
80
+ end
72
81
  end
73
82
 
74
83
  def payment_code
@@ -91,7 +100,7 @@ module Secretariat
91
100
  end
92
101
  taxes.each do |tax|
93
102
  calc_tax = tax.base_amount * BigDecimal(tax.tax_percent) / BigDecimal(100)
94
- calc_tax = calc_tax.round(2, :down)
103
+ calc_tax = calc_tax.round(2)
95
104
  if tax.tax_amount != calc_tax
96
105
  @errors << "Tax amount and calculated tax amount deviate for rate #{tax.tax_percent}: #{tax.tax_amount} / #{calc_tax}"
97
106
  return false
@@ -104,7 +113,7 @@ module Secretariat
104
113
  return false
105
114
  end
106
115
  line_item_sum = line_items.inject(BigDecimal(0)) do |m, item|
107
- m + BigDecimal(item.charge_amount)
116
+ m + BigDecimal(item.quantity.negative? ? -item.charge_amount : item.charge_amount)
108
117
  end
109
118
  if line_item_sum != basis
110
119
  @errors << "Line items do not add up to basis amount #{line_item_sum} / #{basis}"
@@ -169,7 +178,7 @@ module Secretariat
169
178
  xml.text(issue_date.strftime("%Y%m%d"))
170
179
  end
171
180
  end
172
-
181
+
173
182
  end
174
183
  transaction = by_version(version, 'SpecifiedSupplyChainTradeTransaction', 'SupplyChainTradeTransaction')
175
184
  xml['rsm'].send(transaction) do
@@ -192,6 +201,13 @@ module Secretariat
192
201
  xml['ram'].BuyerTradeParty do
193
202
  buyer.to_xml(xml, version: version)
194
203
  end
204
+ if version == 2
205
+ if Array(attachments).size > 0
206
+ attachments.each_with_index do |attachment, index|
207
+ attachment.to_xml(xml, index, version: version, validate: validate)
208
+ end
209
+ end
210
+ end
195
211
  end
196
212
 
197
213
  delivery = by_version(version, 'ApplicableSupplyChainTradeDelivery', 'ApplicableHeaderTradeDelivery')
@@ -212,6 +228,9 @@ module Secretariat
212
228
  end
213
229
  trade_settlement = by_version(version, 'ApplicableSupplyChainTradeSettlement', 'ApplicableHeaderTradeSettlement')
214
230
  xml['ram'].send(trade_settlement) do
231
+ if payment_reference && payment_reference != ''
232
+ xml['ram'].PaymentReference payment_reference
233
+ end
215
234
  xml['ram'].InvoiceCurrencyCode currency_code
216
235
  xml['ram'].SpecifiedTradeSettlementPaymentMeans do
217
236
  xml['ram'].TypeCode payment_code
@@ -230,7 +249,7 @@ module Secretariat
230
249
  xml['ram'].ExemptionReason tax_reason_text
231
250
  end
232
251
  Helpers.currency_element(xml, 'ram', 'BasisAmount', tax.base_amount, currency_code, add_currency: version == 1)
233
- xml['ram'].CategoryCode tax_category_code(version: version)
252
+ xml['ram'].CategoryCode tax_category_code(tax, version: version)
234
253
 
235
254
  percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent')
236
255
  xml['ram'].send(percent, Helpers.format(tax.tax_percent))
@@ -271,7 +290,7 @@ module Secretariat
271
290
  end
272
291
  if version == 1
273
292
  line_items.each_with_index do |item, i|
274
- item.to_xml(xml, i + 1, version: version) # one indexed
293
+ item.to_xml(xml, i + 1, version: version, validate: validate) # one indexed
275
294
  end
276
295
  end
277
296
  end
@@ -49,7 +49,7 @@ module Secretariat
49
49
  gross_price = BigDecimal(gross_amount)
50
50
  charge_price = BigDecimal(charge_amount)
51
51
  tax = BigDecimal(tax_amount)
52
- unit_price = net_price * BigDecimal(quantity)
52
+ unit_price = net_price * BigDecimal(quantity.abs)
53
53
 
54
54
  if charge_price != unit_price
55
55
  @errors << "charge price and gross price times quantity deviate: #{charge_price} / #{unit_price}"
@@ -65,6 +65,8 @@ module Secretariat
65
65
  end
66
66
 
67
67
  calculated_tax = charge_price * BigDecimal(tax_percent) / BigDecimal(100)
68
+ calculated_tax = calculated_tax.round(2)
69
+ calculated_tax = -calculated_tax if quantity.negative?
68
70
  if calculated_tax != tax
69
71
  @errors << "Tax and calculated tax deviate: #{tax} / #{calculated_tax}"
70
72
  return false
@@ -84,8 +86,24 @@ module Secretariat
84
86
  end
85
87
 
86
88
  def to_xml(xml, line_item_index, version: 2, validate: true)
89
+ net_price = net_amount && BigDecimal(net_amount)
90
+ gross_price = gross_amount && BigDecimal(gross_amount)
91
+ charge_price = charge_amount && BigDecimal(charge_amount)
92
+
93
+ if net_price&.zero?
94
+ self.tax_percent = 0
95
+ end
96
+
97
+ if net_price&.negative?
98
+ # Zugferd doesn't allow negative amounts at the item level.
99
+ # Instead, a negative quantity is used.
100
+ self.quantity = -quantity
101
+ self.gross_amount = gross_price&.abs
102
+ self.net_amount = net_price&.abs
103
+ self.charge_amount = charge_price&.abs
104
+ end
105
+
87
106
  if validate && !valid?
88
- pp errors
89
107
  raise ValidationError.new("LineItem #{line_item_index} is invalid", errors)
90
108
  end
91
109
 
@@ -159,7 +177,7 @@ module Secretariat
159
177
  end
160
178
  monetary_summation = by_version(version, 'SpecifiedTradeSettlementMonetarySummation', 'SpecifiedTradeSettlementLineMonetarySummation')
161
179
  xml['ram'].send(monetary_summation) do
162
- Helpers.currency_element(xml, 'ram', 'LineTotalAmount', charge_amount, currency_code, add_currency: version == 1)
180
+ Helpers.currency_element(xml, 'ram', 'LineTotalAmount', (quantity.negative? ? -charge_amount : charge_amount), currency_code, add_currency: version == 1)
163
181
  end
164
182
  end
165
183
 
@@ -20,6 +20,7 @@ module Secretariat
20
20
  Tax = Struct.new('Tax',
21
21
  :tax_percent,
22
22
  :tax_amount,
23
+ :tax_category,
23
24
  :base_amount,
24
25
  keyword_init: true
25
26
  ) do
@@ -15,5 +15,5 @@ limitations under the License.
15
15
  =end
16
16
 
17
17
  module Secretariat
18
- VERSION = '3.2.0'
18
+ VERSION = '3.3.0'
19
19
  end
data/lib/secretariat.rb CHANGED
@@ -24,3 +24,4 @@ require_relative 'secretariat/trade_party'
24
24
  require_relative 'secretariat/line_item'
25
25
  require_relative 'secretariat/validator'
26
26
  require_relative 'secretariat/tax'
27
+ require_relative 'secretariat/attachment'
metadata CHANGED
@@ -1,11 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: secretariat
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jan Krutisch
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
10
  date: 2024-11-06 00:00:00.000000000 Z
@@ -38,6 +37,20 @@ dependencies:
38
37
  - - "~>"
39
38
  - !ruby/object:Gem::Version
40
39
  version: '3.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mime-types
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 3.6.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 3.6.0
41
54
  - !ruby/object:Gem::Dependency
42
55
  name: minitest
43
56
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +79,20 @@ dependencies:
66
79
  - - "~>"
67
80
  - !ruby/object:Gem::Version
68
81
  version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: base64
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.2.0
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 0.2.0
69
96
  description: a tool to help generate and validate ZUGFeRD invoice xml files
70
97
  email: jan@krutisch.de
71
98
  executables: []
@@ -75,6 +102,7 @@ files:
75
102
  - README.md
76
103
  - bin/schxslt-cli.jar
77
104
  - lib/secretariat.rb
105
+ - lib/secretariat/attachment.rb
78
106
  - lib/secretariat/constants.rb
79
107
  - lib/secretariat/helpers.rb
80
108
  - lib/secretariat/invoice.rb
@@ -100,7 +128,6 @@ homepage: https://github.com/halfbyte/ruby-secretariat
100
128
  licenses:
101
129
  - Apache-2.0
102
130
  metadata: {}
103
- post_install_message:
104
131
  rdoc_options: []
105
132
  require_paths:
106
133
  - lib
@@ -116,8 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
143
  version: '0'
117
144
  requirements:
118
145
  - To run the validator, Java must be installed
119
- rubygems_version: 3.5.16
120
- signing_key:
146
+ rubygems_version: 3.6.2
121
147
  specification_version: 4
122
148
  summary: A ZUGFeRD xml generator
123
149
  test_files: []