ksef-client-rails 0.1.0 → 1.0.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/NOTICE +40 -0
  4. data/README.md +524 -6
  5. data/examples/fa3_full.xml +143 -0
  6. data/examples/fa3_minimal.xml +65 -0
  7. data/examples/fa3_typical.xml +76 -0
  8. data/lib/generators/ksef/install/install_generator.rb +20 -0
  9. data/lib/generators/ksef/install/templates/ksef.rb +25 -0
  10. data/lib/ksef/auth/coordinator.rb +164 -0
  11. data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
  12. data/lib/ksef/batch/package_builder.rb +125 -0
  13. data/lib/ksef/batch/splitter.rb +99 -0
  14. data/lib/ksef/batch/uploader.rb +114 -0
  15. data/lib/ksef/client.rb +135 -0
  16. data/lib/ksef/configuration.rb +206 -0
  17. data/lib/ksef/crypto/cryptography_service.rb +136 -0
  18. data/lib/ksef/crypto/csr.rb +106 -0
  19. data/lib/ksef/crypto/public_keys.rb +125 -0
  20. data/lib/ksef/environments.rb +61 -0
  21. data/lib/ksef/errors.rb +187 -0
  22. data/lib/ksef/http/connection.rb +432 -0
  23. data/lib/ksef/http/error_parser.rb +194 -0
  24. data/lib/ksef/http/pagination.rb +80 -0
  25. data/lib/ksef/invoices/builder/checks.rb +127 -0
  26. data/lib/ksef/invoices/builder/number.rb +67 -0
  27. data/lib/ksef/invoices/builder/xml.rb +256 -0
  28. data/lib/ksef/invoices/builder.rb +251 -0
  29. data/lib/ksef/invoices/export.rb +241 -0
  30. data/lib/ksef/invoices/sample.rb +78 -0
  31. data/lib/ksef/invoices/schema_outline.rb +120 -0
  32. data/lib/ksef/invoices/validator.rb +200 -0
  33. data/lib/ksef/models/form_code.rb +49 -0
  34. data/lib/ksef/models/ksef_number.rb +74 -0
  35. data/lib/ksef/models/nip.rb +62 -0
  36. data/lib/ksef/polish_time.rb +49 -0
  37. data/lib/ksef/polling.rb +65 -0
  38. data/lib/ksef/qr/code.rb +104 -0
  39. data/lib/ksef/qr/verification_link.rb +215 -0
  40. data/lib/ksef/railtie.rb +20 -0
  41. data/lib/ksef/resources/auth.rb +116 -0
  42. data/lib/ksef/resources/base.rb +120 -0
  43. data/lib/ksef/resources/batch_sessions.rb +43 -0
  44. data/lib/ksef/resources/certificates.rb +82 -0
  45. data/lib/ksef/resources/collective_identifiers.rb +70 -0
  46. data/lib/ksef/resources/exports.rb +47 -0
  47. data/lib/ksef/resources/invoices.rb +67 -0
  48. data/lib/ksef/resources/limits.rb +33 -0
  49. data/lib/ksef/resources/online_sessions.rb +62 -0
  50. data/lib/ksef/resources/peppol.rb +21 -0
  51. data/lib/ksef/resources/permissions.rb +180 -0
  52. data/lib/ksef/resources/security.rb +18 -0
  53. data/lib/ksef/resources/sessions.rb +125 -0
  54. data/lib/ksef/resources/test_data.rb +178 -0
  55. data/lib/ksef/resources/tokens.rb +62 -0
  56. data/lib/ksef/sessions/online.rb +168 -0
  57. data/lib/ksef/sync/incremental.rb +169 -0
  58. data/lib/ksef/testing.rb +153 -0
  59. data/lib/ksef/token_manager.rb +130 -0
  60. data/lib/ksef/version.rb +11 -0
  61. data/lib/ksef/xades/auth_token_request.rb +135 -0
  62. data/lib/ksef/xades/external_signer.rb +58 -0
  63. data/lib/ksef/xades/signer.rb +312 -0
  64. data/lib/ksef-client-rails.rb +3 -2
  65. data/lib/ksef.rb +116 -0
  66. data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
  67. data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
  68. data/schemas/README.md +53 -0
  69. data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
  70. data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
  71. data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
  72. data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
  73. metadata +291 -9
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Http
5
+ # The two KSeF pagination mechanisms, split along "query vs list":
6
+ # {.by_offset} uses `pageOffset` (a 0-based page INDEX, not a record offset) + `hasMore`;
7
+ # {.by_continuation} reads `continuationToken` from the body but sends it in the
8
+ # `x-continuation-token` header. Both return a lazy enumerator, so pages are fetched on demand.
9
+ module Pagination
10
+ # A single page of offset-paginated results; +truncated+ marks the server's record limit.
11
+ OffsetPage = Struct.new(:items, :has_more, :truncated, keyword_init: true)
12
+ # A single page of continuation-token-paginated results.
13
+ ContinuationPage = Struct.new(:items, :continuation_token, keyword_init: true)
14
+ # Empty continuation pages in a row after which iteration gives up.
15
+ MAX_EMPTY_PAGES = 100
16
+
17
+ module_function
18
+
19
+ # Iterates over pages incrementing the page index by 1 while `has_more` is truthy; an empty page
20
+ # ends the iteration too, so a server that keeps answering `hasMore` cannot loop it forever. A
21
+ # truncated page with more behind it raises once its items are consumed: the next page index
22
+ # would not continue the result.
23
+ #
24
+ # @param page_size [Integer] page size
25
+ # @param start_page [Integer] starting page index (defaults to 0)
26
+ # @param truncated_hint [String] how to continue past a truncated result, for the error message
27
+ # @yieldparam page_index [Integer]
28
+ # @yieldparam page_size [Integer]
29
+ # @yieldreturn [OffsetPage]
30
+ # @return [Enumerator::Lazy]
31
+ # @raise [Ksef::ResultTruncated] when iteration goes past a truncated page
32
+ def by_offset(page_size: 10, start_page: 0, truncated_hint: "narrow the query")
33
+ Enumerator.new do |yielder|
34
+ page_index = start_page
35
+ loop do
36
+ page = yield(page_index, page_size)
37
+ items = Array(page.items)
38
+ items.each { |item| yielder << item }
39
+ break if !page.has_more || items.empty?
40
+ if page.truncated
41
+ raise ResultTruncated.new("The result reached the record limit (isTruncated) — #{truncated_hint}",
42
+ last_item: items.last)
43
+ end
44
+
45
+ page_index += 1
46
+ end
47
+ end.lazy
48
+ end
49
+
50
+ # Iterates over pages passing the continuation token for as long as the server returns a new one. A
51
+ # page may be empty and still continue; a token returned before ends the iteration (it would loop
52
+ # forever), and so many empty pages in a row raise.
53
+ #
54
+ # @param page_size [Integer] page size
55
+ # @yieldparam continuation_token [String, nil] nil on the first request
56
+ # @yieldparam page_size [Integer]
57
+ # @yieldreturn [ContinuationPage]
58
+ # @return [Enumerator::Lazy]
59
+ # @raise [Ksef::Error] after {MAX_EMPTY_PAGES} empty pages in a row
60
+ def by_continuation(page_size: 10)
61
+ Enumerator.new do |yielder|
62
+ seen = Set.new
63
+ empty = 0
64
+ token = nil
65
+ loop do
66
+ page = yield(token, page_size)
67
+ items = Array(page.items)
68
+ items.each { |item| yielder << item }
69
+ empty = items.empty? ? empty + 1 : 0
70
+ raise Error, "KSeF returned #{empty} empty pages in a row; stopping" if empty >= MAX_EMPTY_PAGES
71
+
72
+ seen << token if token
73
+ token = page.continuation_token
74
+ break if token.nil? || token.to_s.empty? || seen.include?(token)
75
+ end
76
+ end.lazy
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Invoices
5
+ class Builder
6
+ # The checks {Builder#to_xml} runs before rendering: required values, numbers, codes and text.
7
+ # @api private
8
+ module Checks
9
+ # Rates with VAT, which a foreign-currency invoice would also have to state in PLN (`P_14_xW`).
10
+ VAT_RATES = %w[23 8 5].freeze
11
+
12
+ private
13
+
14
+ def check!
15
+ check_parties!
16
+ raise ConfigurationError, "at least one line is required" if lines.empty?
17
+
18
+ check_required!(number: number, issue_date: issue_date)
19
+ check_codes!
20
+ check_lines!
21
+ check_tax!
22
+ check_correction!
23
+ check_payment!
24
+ check_text!
25
+ end
26
+
27
+ def check_parties!
28
+ raise ConfigurationError, "seller and buyer are required" unless seller && buyer
29
+
30
+ check_required!("seller name": seller.name, "seller address": seller.address,
31
+ "buyer name": buyer.name, "buyer address": buyer.address)
32
+ id_type = buyer.id_type.to_s
33
+ unless id_type.empty? || BUYER_ID_TYPES.include?(id_type)
34
+ raise ConfigurationError, "Unknown buyer id_type: #{id_type}"
35
+ end
36
+ return unless %w[eu other].include?(buyer.identification) && blank?(buyer.country)
37
+
38
+ raise ConfigurationError, "buyer country is required for a buyer identified by id_type " \
39
+ "#{buyer.identification.inspect} (Adres/KodKraju)"
40
+ end
41
+
42
+ def check_required!(**values)
43
+ missing = values.select { |_, value| blank?(value) }.keys
44
+ raise ConfigurationError, "#{missing.join(', ')} required" unless missing.empty?
45
+ end
46
+
47
+ def check_codes!
48
+ unknown = annotations.map(&:to_sym) - ANNOTATIONS.keys
49
+ raise ConfigurationError, "Unknown annotation(s): #{unknown.join(', ')}" unless unknown.empty?
50
+ raise ConfigurationError, "Unknown invoice_type: #{invoice_type}" unless INVOICE_TYPES.include?(invoice_type)
51
+ unless blank?(correction_type) || CORRECTION_TYPES.include?(correction_type.to_s)
52
+ raise ConfigurationError, "Unknown correction_type: #{correction_type}"
53
+ end
54
+ raise ConfigurationError, "Unknown currency: #{currency}" unless CURRENCY.match?(currency.to_s)
55
+
56
+ [seller, buyer].each do |party|
57
+ next if blank?(party.country) || COUNTRY.match?(party.country.to_s)
58
+
59
+ raise ConfigurationError, "Unknown country: #{party.country.inspect} (an ISO 3166 code such as \"DE\")"
60
+ end
61
+ end
62
+
63
+ def check_lines!
64
+ unknown_rates = lines.map(&:rate).uniq - RATES.keys
65
+ raise ConfigurationError, "Unknown VAT rate(s): #{unknown_rates.join(', ')}" unless unknown_rates.empty?
66
+
67
+ lines.each.with_index(1) do |line, index|
68
+ line.net
69
+ next if blank?(line.gtu) || GTU_CODES.include?(line.gtu.to_s)
70
+
71
+ raise ConfigurationError, "Unknown GTU code: #{line.gtu} (GTU_01 … GTU_13)"
72
+ rescue ConfigurationError => e
73
+ raise ConfigurationError, "line #{index}: #{e.message}"
74
+ end
75
+ end
76
+
77
+ def check_tax!
78
+ if exempt? && blank?(exemption_reason)
79
+ raise ConfigurationError, "exemption_reason is required for a zw line — the legal basis of the " \
80
+ "exemption (P_19A), e.g. \"art. 113 ust. 1 ustawy o VAT\""
81
+ end
82
+ return if currency == "PLN" || lines.none? { |line| VAT_RATES.include?(line.rate) }
83
+
84
+ raise ConfigurationError, "a #{currency} invoice with a 23/8/5% line must also state the VAT in PLN " \
85
+ "(P_14_xW), which the builder does not write — issue it in PLN"
86
+ end
87
+
88
+ def check_correction!
89
+ return unless correction?
90
+
91
+ check_required!(corrected_date: corrected_date, corrected_number: corrected_number)
92
+ end
93
+
94
+ def check_payment!
95
+ payment_code
96
+ flag = paid_flag
97
+ raise ConfigurationError, "paid_date is required when paid is true" if flag && blank?(paid_date)
98
+ raise ConfigurationError, "paid_date is given but paid is not true" if !flag && !blank?(paid_date)
99
+ raise ConfigurationError, "bank_name needs bank_account" if blank?(bank_account) && !blank?(bank_name)
100
+ end
101
+
102
+ # Every String that ends up in the XML: valid UTF-8, no character XML or KSeF refuses.
103
+ def check_text!
104
+ texts = to_h.except(:seller, :buyer, :lines).map { |name, value| [name.to_s, value] }
105
+ { "seller" => seller, "buyer" => buyer }.each do |role, party|
106
+ texts.concat(party.to_h.map { |name, value| ["#{role} #{name}", value] })
107
+ end
108
+ lines.each.with_index(1) do |line, index|
109
+ texts.concat(line.to_h.map { |name, value| ["line #{index} #{name}", value] })
110
+ end
111
+ texts.each { |label, value| check_string!(label, value) if value.is_a?(String) }
112
+ end
113
+
114
+ def check_string!(label, value)
115
+ unless [Encoding::UTF_8, Encoding::US_ASCII].include?(value.encoding) && value.valid_encoding?
116
+ raise ConfigurationError, "#{label} is not a UTF-8 String (#{value.encoding}) — " \
117
+ "convert it with String#encode(\"UTF-8\")"
118
+ end
119
+ bad = value[XML_DISALLOWED] || value[Validator::DISALLOWED_CHARACTERS]
120
+ return unless bad
121
+
122
+ raise ConfigurationError, "#{label} contains #{format('U+%04X', bad.ord)}, a character KSeF refuses"
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Ksef
6
+ module Invoices
7
+ class Builder
8
+ # Reads and writes the builder's numbers exactly — never through Float.
9
+ # @api private
10
+ module Number
11
+ DECIMAL = /\A[+-]?\d+(?:[.,]\d+)?\z/
12
+ # Thousands grouped by a space, a no-break space or a narrow no-break space: "1 234,56".
13
+ GROUPED = /\A[+-]?\d{1,3}(?:[   ]\d{3})+(?:[.,]\d+)?\z/
14
+
15
+ module_function
16
+
17
+ # @param value [Integer, BigDecimal, Float, String, nil]
18
+ # @param field [String] the name used in the error message
19
+ # @param places [Integer] the most decimal places allowed
20
+ # @return [BigDecimal]
21
+ # @raise [Ksef::ConfigurationError] for a blank, malformed or non-finite value, or more decimal places
22
+ def parse(value, field, places:)
23
+ text = canonical(value)
24
+ raise ConfigurationError, "#{field} is required" if text.nil?
25
+ raise ConfigurationError, "#{field} is not a decimal number: #{value.inspect}" unless DECIMAL.match?(text)
26
+
27
+ number = BigDecimal(text.tr(",", "."))
28
+ unless number.round(places) == number
29
+ raise ConfigurationError, "#{field} has more than #{places} decimal places: #{value.inspect}"
30
+ end
31
+
32
+ number
33
+ end
34
+
35
+ # @param value [BigDecimal]
36
+ # @return [String] the amount with 2 decimal places, rounded half up ("100.00", "-0.50")
37
+ def amount(value)
38
+ rounded = value.round(2, BigDecimal::ROUND_HALF_UP)
39
+ rounded = BigDecimal("0") if rounded.zero?
40
+ whole, fraction = rounded.to_s("F").split(".")
41
+ "#{whole}.#{fraction.ljust(2, '0')}"
42
+ end
43
+
44
+ # @param value [BigDecimal]
45
+ # @return [String] the number without a trailing ".0" ("2", "1.5")
46
+ def plain(value)
47
+ value.to_s("F").delete_suffix(".0")
48
+ end
49
+
50
+ def canonical(value)
51
+ case value
52
+ when nil then nil
53
+ when Integer then value.to_s
54
+ when BigDecimal, Float then value.finite? ? BigDecimal(value.to_s).to_s("F") : value.to_s
55
+ when String
56
+ text = value.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
57
+ return nil if text.empty?
58
+
59
+ GROUPED.match?(text) ? text.delete("   ") : text
60
+ else value.inspect
61
+ end
62
+ end
63
+ private_class_method :canonical
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "forwardable"
5
+ require "nokogiri"
6
+
7
+ module Ksef
8
+ module Invoices
9
+ class Builder
10
+ # Writes a {Builder}'s invoice as FA(3) XML, element by element in the schema's sequence.
11
+ # @api private
12
+ class Xml
13
+ extend Forwardable
14
+
15
+ def_delegators :@invoice, *(ATTRIBUTES - %i[annotations]), :totals_by_rate, :gross_total, :correction?,
16
+ :annotation?, :exempt?, :payment_code, :paid_flag
17
+
18
+ # @param invoice [Builder]
19
+ def initialize(invoice)
20
+ @invoice = invoice
21
+ end
22
+
23
+ # @param created_at [Time]
24
+ # @return [String]
25
+ def render(created_at)
26
+ Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
27
+ xml.Faktura(xmlns: FormCode::FA3.namespace) do
28
+ header(xml, created_at)
29
+ seller_element(xml)
30
+ buyer_element(xml)
31
+ invoice_body(xml)
32
+ footer_element(xml)
33
+ end
34
+ end.to_xml
35
+ end
36
+
37
+ private
38
+
39
+ def header(xml, created_at)
40
+ xml.Naglowek do
41
+ xml.KodFormularza("FA", kodSystemowy: FormCode::FA3.system_code, wersjaSchemy: FormCode::FA3.schema_version)
42
+ xml.WariantFormularza "3"
43
+ xml.DataWytworzeniaFa created_at.getutc.strftime("%Y-%m-%dT%H:%M:%SZ")
44
+ xml.SystemInfo system_info
45
+ end
46
+ end
47
+
48
+ def seller_element(xml)
49
+ xml.Podmiot1 do
50
+ xml.DaneIdentyfikacyjne do
51
+ xml.NIP Nip.normalize!(seller.nip, name: "seller NIP")
52
+ xml.Nazwa seller.name
53
+ end
54
+ address(xml, seller)
55
+ contact(xml, seller)
56
+ end
57
+ end
58
+
59
+ def buyer_element(xml)
60
+ xml.Podmiot2 do
61
+ xml.DaneIdentyfikacyjne do
62
+ buyer_identifier(xml, buyer)
63
+ xml.Nazwa buyer.name
64
+ end
65
+ address(xml, buyer)
66
+ contact(xml, buyer)
67
+ xml.JST "2"
68
+ xml.GV "2"
69
+ end
70
+ end
71
+
72
+ # A choice: NIP | KodUE + NrVatUE | KodKraju + NrID | BrakID.
73
+ def buyer_identifier(xml, party)
74
+ case party.identification
75
+ when "nip" then xml.NIP Nip.normalize!(party.nip, name: "buyer NIP")
76
+ when "eu"
77
+ xml.KodUE party.eu_code.to_s.strip.upcase
78
+ xml.NrVatUE party.vat_eu.to_s.strip
79
+ when "other"
80
+ xml.KodKraju party.country
81
+ xml.NrID party.id_number.to_s.strip
82
+ else xml.BrakID "1"
83
+ end
84
+ end
85
+
86
+ # Without a country, a party identified by a NIP or by nothing is in Poland.
87
+ def address(xml, party)
88
+ xml.Adres do
89
+ xml.KodKraju blank?(party.country) ? "PL" : party.country
90
+ xml.AdresL1 party.address
91
+ end
92
+ end
93
+
94
+ def contact(xml, party)
95
+ email = party.email.to_s.strip
96
+ phone = party.phone.to_s.strip
97
+ return if email.empty? && phone.empty?
98
+
99
+ xml.DaneKontaktowe do
100
+ xml.Email email unless email.empty?
101
+ xml.Telefon phone unless phone.empty?
102
+ end
103
+ end
104
+
105
+ def invoice_body(xml)
106
+ xml.Fa do
107
+ xml.KodWaluty currency
108
+ xml.P_1 iso(issue_date, "issue_date")
109
+ xml.P_1M place unless blank?(place)
110
+ xml.P_2 number
111
+ xml.P_6 iso(sale_date, "sale_date") if sale_date
112
+ summary(xml)
113
+ annotations_element(xml)
114
+ xml.RodzajFaktury invoice_type
115
+ correction(xml) if correction?
116
+ lines.each_with_index { |line, i| invoice_line(xml, line, i + 1) }
117
+ payment(xml)
118
+ end
119
+ end
120
+
121
+ def summary(xml)
122
+ totals = totals_by_rate
123
+ RATES.each do |rate, fields|
124
+ next unless totals[rate]
125
+
126
+ xml.public_send(fields[:net], amount(totals[rate][:net]))
127
+ xml.public_send(fields[:vat], amount(totals[rate][:vat])) if fields[:vat]
128
+ end
129
+ xml.P_15 amount(gross_total)
130
+ end
131
+
132
+ def annotations_element(xml)
133
+ flag = ->(key) { annotation?(key) ? "1" : "2" }
134
+ xml.Adnotacje do
135
+ %i[cash_method self_billing reverse_charge split_payment].each do |key|
136
+ xml.public_send(ANNOTATIONS[key], flag.call(key))
137
+ end
138
+ exemption(xml)
139
+ xml.NoweSrodkiTransportu { xml.P_22N "1" }
140
+ xml.P_23 flag.call(:triangular)
141
+ xml.PMarzy { xml.P_PMarzyN "1" }
142
+ end
143
+ end
144
+
145
+ def exemption(xml)
146
+ xml.Zwolnienie do
147
+ if exempt?
148
+ xml.P_19 "1"
149
+ xml.P_19A exemption_reason
150
+ else
151
+ xml.P_19N "1"
152
+ end
153
+ end
154
+ end
155
+
156
+ # `DaneFaKorygowanej` holds a choice: NrKSeF=1 + the KSeF number, or NrKSeFN=1 (issued outside KSeF).
157
+ def correction(xml)
158
+ xml.PrzyczynaKorekty correction_reason unless blank?(correction_reason)
159
+ xml.TypKorekty correction_type unless blank?(correction_type)
160
+ xml.DaneFaKorygowanej do
161
+ xml.DataWystFaKorygowanej iso(corrected_date, "corrected_date")
162
+ xml.NrFaKorygowanej corrected_number
163
+ if blank?(corrected_ksef_number)
164
+ xml.NrKSeFN "1"
165
+ else
166
+ xml.NrKSeF "1"
167
+ xml.NrKSeFFaKorygowanej corrected_ksef_number.strip
168
+ end
169
+ end
170
+ end
171
+
172
+ def invoice_line(xml, line, index)
173
+ xml.FaWiersz do
174
+ xml.NrWierszaFa index
175
+ xml.P_7 line.name
176
+ optional(xml, Indeks: line.index, GTIN: line.gtin, PKWiU: line.pkwiu, CN: line.cn, P_8A: line.unit)
177
+ xml.P_8B Number.plain(line.number(:quantity))
178
+ xml.P_9A amount(line.number(:unit_price))
179
+ xml.P_10 amount(line.number(:discount)) if line.discount?
180
+ xml.P_11 amount(line.net)
181
+ xml.P_12 line.rate
182
+ xml.GTU line.gtu unless blank?(line.gtu)
183
+ end
184
+ end
185
+
186
+ # Written when any payment field is given. `Zaplacono` + `DataZaplaty` is the first branch of an
187
+ # optional choice, ahead of the due date.
188
+ def payment(xml)
189
+ code = payment_code
190
+ return if code.nil? && !paid_flag && due_date.nil? && blank?(bank_account)
191
+
192
+ xml.Platnosc do
193
+ if paid_flag
194
+ xml.Zaplacono "1"
195
+ xml.DataZaplaty iso(paid_date, "paid_date")
196
+ end
197
+ xml.TerminPlatnosci { xml.Termin iso(due_date, "due_date") } if due_date
198
+ xml.FormaPlatnosci code if code
199
+ bank_account_element(xml)
200
+ end
201
+ end
202
+
203
+ def bank_account_element(xml)
204
+ return if blank?(bank_account)
205
+
206
+ xml.RachunekBankowy do
207
+ xml.NrRB bank_account.delete(" ")
208
+ xml.NazwaBanku bank_name unless blank?(bank_name)
209
+ end
210
+ end
211
+
212
+ def footer_element(xml)
213
+ registers = { KRS: krs, REGON: regon, BDO: bdo }.reject { |_, v| blank?(v) }
214
+ return if blank?(footer) && registers.empty?
215
+
216
+ xml.Stopka do
217
+ xml.Informacje { xml.StopkaFaktury footer } unless blank?(footer)
218
+ xml.Rejestry { optional(xml, **registers) } if registers.any?
219
+ end
220
+ end
221
+
222
+ # Emits each non-blank field in the given (schema) order.
223
+ def optional(xml, **fields)
224
+ fields.each { |tag, value| xml.public_send(tag, value.to_s.strip) unless blank?(value) }
225
+ end
226
+
227
+ def blank?(value)
228
+ value.to_s.strip.empty?
229
+ end
230
+
231
+ # Only a Date or "YYYY-MM-DD": a Time or a timestamp String is refused, as its calendar day depends
232
+ # on its zone.
233
+ def iso(date, name)
234
+ return nil if date.nil?
235
+
236
+ if date.is_a?(String)
237
+ raise Date::Error unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
238
+
239
+ return Date.iso8601(date).strftime("%Y-%m-%d")
240
+ end
241
+ unless date.is_a?(Date) && !date.is_a?(DateTime)
242
+ raise ConfigurationError, "#{name} must be a Date or \"YYYY-MM-DD\", not a #{date.class}"
243
+ end
244
+
245
+ date.strftime("%Y-%m-%d")
246
+ rescue Date::Error
247
+ raise ConfigurationError, "#{name} is not a valid date (\"YYYY-MM-DD\"): #{date.inspect}"
248
+ end
249
+
250
+ def amount(value)
251
+ Number.amount(value)
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end