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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "nokogiri"
6
+ require "ksef/invoices/builder/checks"
7
+ require "ksef/invoices/builder/number"
8
+
9
+ module Ksef
10
+ module Invoices
11
+ # Builds an FA(3) invoice: parties, lines with their VAT rates, payment, annotations, a correction,
12
+ # footer. Elements follow the schema sequence; the summary per rate is computed from the lines, and
13
+ # input the builder cannot render correctly is refused with ConfigurationError rather than guessed.
14
+ class Builder
15
+ include Checks
16
+
17
+ # Rate (the `P_12` value) → its summary fields and percentage, in schema order. Rates without VAT
18
+ # have no `P_14_x` field.
19
+ RATES = {
20
+ "23" => { net: "P_13_1", vat: "P_14_1", percent: 23 },
21
+ "8" => { net: "P_13_2", vat: "P_14_2", percent: 8 },
22
+ "5" => { net: "P_13_3", vat: "P_14_3", percent: 5 },
23
+ "0 KR" => { net: "P_13_6_1", percent: 0 },
24
+ "0 WDT" => { net: "P_13_6_2", percent: 0 },
25
+ "0 EX" => { net: "P_13_6_3", percent: 0 },
26
+ "zw" => { net: "P_13_7", percent: 0 },
27
+ "np I" => { net: "P_13_8", percent: 0 },
28
+ "np II" => { net: "P_13_9", percent: 0 },
29
+ "oo" => { net: "P_13_10", percent: 0 }
30
+ }.freeze
31
+
32
+ # `FormaPlatnosci` codes; a symbol may stand for the code.
33
+ PAYMENT_METHODS = { cash: "1", card: "2", voucher: "3", cheque: "4", credit: "5", transfer: "6",
34
+ mobile: "7" }.freeze
35
+
36
+ # `Adnotacje` flags set to 1 by {#annotations}; every other one is 2. `reverse_charge` is also set
37
+ # by an `oo` or `np II` line, and `P_19` (exemption) by a `zw` line.
38
+ ANNOTATIONS = { cash_method: "P_16", self_billing: "P_17", reverse_charge: "P_18", split_payment: "P_18A",
39
+ triangular: "P_23" }.freeze
40
+
41
+ # Rates whose tax the buyer settles (`P_18` = 1).
42
+ REVERSE_CHARGE_RATES = ["oo", "np II"].freeze
43
+
44
+ INVOICE_TYPES = %w[VAT KOR].freeze
45
+ CORRECTION_TYPES = %w[1 2 3].freeze
46
+ GTU_CODES = (1..13).map { |i| format("GTU_%02d", i) }.freeze
47
+
48
+ # Code formats; the schema's enumerations are checked when the XML is validated.
49
+ COUNTRY = /\A[A-Z]{2}\z/
50
+ CURRENCY = /\A[A-Z]{3}\z/
51
+ # Characters XML 1.0 does not allow.
52
+ XML_DISALLOWED = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/
53
+
54
+ # `Podmiot2/DaneIdentyfikacyjne`: a Polish NIP, an EU VAT number, another tax ID, or none (a consumer).
55
+ BUYER_ID_TYPES = %w[nip eu other none].freeze
56
+
57
+ PARTY_FIELDS = %i[nip name address country email phone id_type eu_code vat_eu id_number].freeze
58
+ LINE_FIELDS = %i[name unit quantity unit_price rate index gtin pkwiu cn gtu discount].freeze
59
+
60
+ # A seller or buyer. `id_type` (one of {BUYER_ID_TYPES}) applies to the buyer; without it a party
61
+ # with a NIP is identified by it, one without by none.
62
+ Party = Struct.new(*PARTY_FIELDS, keyword_init: true) do
63
+ # @return [String] one of {BUYER_ID_TYPES}
64
+ def identification
65
+ return id_type.to_s if BUYER_ID_TYPES.include?(id_type.to_s)
66
+
67
+ nip.to_s.strip.empty? ? "none" : "nip"
68
+ end
69
+ end
70
+
71
+ # An invoice line. `quantity` and `unit_price` are required; numbers are Integers, BigDecimals or
72
+ # Strings with a comma or a dot ("1234,56", "1 234,56"). Prices and discounts take at most 2 decimal
73
+ # places, quantities 6; anything else raises ConfigurationError.
74
+ Line = Struct.new(*LINE_FIELDS, keyword_init: true) do
75
+ # @return [BigDecimal] quantity × net price, minus a discount not already in the price (`P_10`)
76
+ # @raise [Ksef::ConfigurationError] for a missing or malformed number
77
+ def net
78
+ value = number(:quantity) * number(:unit_price)
79
+ value -= number(:discount) if discount?
80
+ value.round(2, BigDecimal::ROUND_HALF_UP)
81
+ end
82
+
83
+ # @return [Boolean] whether the line has a discount (`P_10`)
84
+ def discount?
85
+ !discount.nil? && !discount.to_s.strip.empty?
86
+ end
87
+
88
+ # @api private
89
+ # @param field [Symbol] :quantity, :unit_price or :discount
90
+ # @return [BigDecimal]
91
+ # @raise [Ksef::ConfigurationError]
92
+ def number(field)
93
+ Number.parse(public_send(field), field.to_s, places: field == :quantity ? 6 : 2)
94
+ end
95
+ end
96
+
97
+ ATTRIBUTES = %i[number issue_date sale_date place currency seller buyer lines payment_method due_date
98
+ bank_account bank_name paid paid_date exemption_reason invoice_type correction_reason
99
+ correction_type corrected_date corrected_number corrected_ksef_number annotations footer
100
+ krs regon bdo system_info].freeze
101
+
102
+ # @!attribute [rw] number
103
+ # @return [String] invoice number (`P_2`); KSeF refuses a second invoice with the same seller NIP,
104
+ # invoice type and number
105
+ # @!attribute [rw] seller
106
+ # @return [Party] `Podmiot1` — the session's context needs the permission to invoice for its NIP
107
+ # @!attribute [rw] lines
108
+ # @return [Array<Line>]
109
+ attr_accessor(*(ATTRIBUTES - %i[seller buyer lines]))
110
+ attr_reader :seller, :buyer, :lines
111
+
112
+ # @param attributes [Hash] any of {ATTRIBUTES}: `seller:`/`buyer:` as a Hash or {Party}, `lines:` as
113
+ # Hashes or {Line}s, dates as Date or "YYYY-MM-DD", `annotations:` as keys of {ANNOTATIONS},
114
+ # `payment_method:` as a code or a key of {PAYMENT_METHODS}, `invoice_type:` "VAT" (default) or "KOR".
115
+ # A KOR's lines are the change: the totals are summed from them, so give difference rows (e.g.
116
+ # quantity 2 at -10.00 for a 20.00 reduction) or the original row negated plus the corrected row.
117
+ # @raise [Ksef::ConfigurationError] for an unknown attribute
118
+ def initialize(**attributes)
119
+ unknown = attributes.keys - ATTRIBUTES
120
+ raise ConfigurationError, "Unknown invoice attribute(s): #{unknown.join(', ')}" unless unknown.empty?
121
+
122
+ attributes.each { |name, value| public_send(:"#{name}=", value) }
123
+ self.currency = "PLN" if blank?(currency)
124
+ self.lines ||= []
125
+ self.annotations ||= []
126
+ self.invoice_type = "VAT" if blank?(invoice_type)
127
+ self.system_info ||= "ksef-client-rails"
128
+ end
129
+
130
+ # @param value [Hash, Party]
131
+ def seller=(value)
132
+ @seller = party(value)
133
+ end
134
+
135
+ # @param value [Hash, Party]
136
+ def buyer=(value)
137
+ @buyer = party(value)
138
+ end
139
+
140
+ # @param value [Array<Hash, Line>]
141
+ def lines=(value)
142
+ @lines = Array(value).map { |line| line.is_a?(Line) ? line : line(line) }
143
+ end
144
+
145
+ # @return [Hash{String=>Hash}] rate => { net:, vat: } for the rates in use
146
+ def totals_by_rate
147
+ lines.group_by(&:rate).to_h do |rate, group|
148
+ net = group.sum(BigDecimal("0"), &:net)
149
+ [rate, { net: net, vat: (net * rate_fields(rate)[:percent] / 100).round(2, BigDecimal::ROUND_HALF_UP) }]
150
+ end
151
+ end
152
+
153
+ # @return [BigDecimal]
154
+ def net_total = totals_by_rate.values.sum(BigDecimal("0")) { |t| t[:net] }
155
+ # @return [BigDecimal]
156
+ def vat_total = totals_by_rate.values.sum(BigDecimal("0")) { |t| t[:vat] }
157
+ # @return [BigDecimal]
158
+ def gross_total = net_total + vat_total
159
+
160
+ # @return [Boolean]
161
+ def correction? = invoice_type == "KOR"
162
+
163
+ # @param key [Symbol, String] a key of {ANNOTATIONS}
164
+ # @return [Boolean] whether the flag is 1 — given in {#annotations}, or `reverse_charge` implied by a line
165
+ def annotation?(key)
166
+ return true if annotations.map(&:to_sym).include?(key.to_sym)
167
+
168
+ key.to_sym == :reverse_charge && lines.any? { |line| REVERSE_CHARGE_RATES.include?(line.rate.to_s) }
169
+ end
170
+
171
+ # @return [Boolean] whether a line is VAT-exempt (`zw`), which sets `P_19`
172
+ def exempt?
173
+ lines.any? { |line| line.rate == "zw" }
174
+ end
175
+
176
+ # @return [Hash{Symbol=>Object}] every attribute, e.g. to start another invoice from this one
177
+ def to_h
178
+ ATTRIBUTES.to_h { |name| [name, public_send(name)] }
179
+ end
180
+
181
+ # @param created_at [Time] `DataWytworzeniaFa`
182
+ # @return [String] FA(3) XML
183
+ # @raise [Ksef::ConfigurationError] for a missing required value, a malformed number, an unknown code
184
+ # (NIP, rate, GTU, country, currency, payment method, buyer identification, annotation, invoice or
185
+ # correction type), a date that is not a Date / "YYYY-MM-DD", text KSeF refuses, or an invoice the
186
+ # builder cannot express (foreign-currency VAT, a `zw` line without `exemption_reason`)
187
+ # @raise [Ksef::SchemaValidationError] when the result still does not match the FA(3) schema (e.g. a
188
+ # value over its length limit)
189
+ def to_xml(created_at: Time.now)
190
+ check!
191
+ xml = Xml.new(self).render(created_at)
192
+ Validator.new(form_code: FormCode::FA3).validate!(xml)
193
+ xml
194
+ end
195
+
196
+ # @api private
197
+ # @return [String, nil] the `FormaPlatnosci` code
198
+ # @raise [Ksef::ConfigurationError] for an unknown method
199
+ def payment_code
200
+ return nil if blank?(payment_method)
201
+
202
+ code = PAYMENT_METHODS.fetch(payment_method.to_s.to_sym, payment_method.to_s)
203
+ raise ConfigurationError, "Unknown payment_method: #{payment_method}" unless PAYMENT_METHODS.value?(code)
204
+
205
+ code
206
+ end
207
+
208
+ # @api private
209
+ # @return [Boolean, nil] the `paid` flag: true, false, or nil when not given
210
+ # @raise [Ksef::ConfigurationError] for a value that is not a boolean, "1"/"0" or "true"/"false"
211
+ def paid_flag
212
+ return nil if blank?(paid)
213
+ return paid if [true, false].include?(paid)
214
+
215
+ { "1" => true, "true" => true, "0" => false, "false" => false }.fetch(paid.to_s.strip.downcase) do
216
+ raise ConfigurationError, "paid must be true or false, not #{paid.inspect}"
217
+ end
218
+ end
219
+
220
+ private
221
+
222
+ def party(value)
223
+ return value if value.nil? || value.is_a?(Party)
224
+
225
+ attrs = value.to_h.transform_keys(&:to_sym)
226
+ unknown = attrs.keys - PARTY_FIELDS
227
+ raise ConfigurationError, "Unknown party field(s): #{unknown.join(', ')}" unless unknown.empty?
228
+
229
+ Party.new(**attrs)
230
+ end
231
+
232
+ def line(value)
233
+ attrs = value.to_h.transform_keys(&:to_sym)
234
+ unknown = attrs.keys - LINE_FIELDS
235
+ raise ConfigurationError, "Unknown line field(s): #{unknown.join(', ')}" unless unknown.empty?
236
+
237
+ Line.new(**attrs)
238
+ end
239
+
240
+ def rate_fields(rate)
241
+ RATES.fetch(rate.to_s) { raise ConfigurationError, "Unknown VAT rate: #{rate}" }
242
+ end
243
+
244
+ def blank?(value)
245
+ value.to_s.strip.empty?
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ require "ksef/invoices/builder/xml"
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+ require "stringio"
7
+
8
+ module Ksef
9
+ module Invoices
10
+ # Invoice package export orchestrator: init → polling → download parts from presigned URLs
11
+ # (hash-checked while ENCRYPTED) → decrypt each part separately → a ZIP or tar.gz package.
12
+ # An expired ready package comes back as code `210`, not 410.
13
+ class Export
14
+ # Export status: "finished and ready to download".
15
+ STATUS_COMPLETED = 200
16
+ # Error code for an expired ready package (distinct from 410).
17
+ CODE_PACKAGE_EXPIRED = 210
18
+ # Package compression types (`compressionType`).
19
+ COMPRESSION_TYPES = %w[Zip TarGz].freeze
20
+ # The package file holding `{"invoices": [InvoiceMetadata…]}`; invoices are `{ksefNumber}.xml`.
21
+ METADATA_FILE = "_metadata.json"
22
+ # `package.compressionType` → the format of the decrypted package.
23
+ FORMATS = { "Zip" => :zip, "TarGz" => :tar_gz }.freeze
24
+ # Largest file the package may hold once unpacked, in bytes (`_metadata.json` of 10,000 invoices
25
+ # included); a larger one is refused rather than inflated.
26
+ MAX_ENTRY_SIZE = 100_000_000
27
+
28
+ # Export result: the decrypted package bytes + the package description from the status.
29
+ Result = Struct.new(:bytes, :package, keyword_init: true) do
30
+ # The package size and counts — never its bytes or the presigned part URLs.
31
+ # @return [String]
32
+ def inspect
33
+ info = package || {}
34
+ "#<#{self.class.name} bytes=#{bytes.to_s.bytesize} invoiceCount=#{info['invoiceCount'].inspect} " \
35
+ "parts=#{Array(info['parts']).size} isTruncated=#{info['isTruncated'].inspect}>"
36
+ end
37
+ alias_method :to_s, :inspect
38
+
39
+ # `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
40
+ def pretty_print(printer)
41
+ printer.text(inspect)
42
+ end
43
+
44
+ # Read from the leading bytes and checked against the status's `package.compressionType`.
45
+ # @return [Symbol, nil] :zip, :tar_gz, or nil for an empty package
46
+ # @raise [Ksef::Error] for an unknown format, or one other than the status declares
47
+ def compression
48
+ return nil if bytes.empty?
49
+
50
+ detected = if bytes.start_with?("\x1F\x8B".b) then :tar_gz
51
+ elsif bytes.start_with?("PK".b) then :zip
52
+ else raise Error, "Unrecognized export package format (neither ZIP nor gzip)"
53
+ end
54
+ declared = FORMATS[(package || {})["compressionType"]]
55
+ return detected if declared.nil? || declared == detected
56
+
57
+ raise Error, "Export package is #{detected}, the status declares #{declared}"
58
+ end
59
+
60
+ # Streams the package one file at a time (only the current file is held in memory). Names are
61
+ # given without a leading "./", whichever tool built the archive.
62
+ # @yieldparam name [String] file name inside the package — never absolute, never with a ".." segment
63
+ # @yieldparam content [String] file bytes
64
+ # @return [Enumerator, nil] an Enumerator without a block
65
+ # @raise [Ksef::Error] for an absolute or climbing name, a file over {MAX_ENTRY_SIZE}, or a package
66
+ # that cannot be read
67
+ def each_entry(&block)
68
+ return enum_for(:each_entry) unless block
69
+
70
+ case compression
71
+ when :zip then each_zip_entry(&block)
72
+ when :tar_gz then each_tar_entry(&block)
73
+ end
74
+ nil
75
+ end
76
+
77
+ # @return [Hash{String=>String}] every file in the package, including `_metadata.json`
78
+ def entries
79
+ each_entry.to_h
80
+ end
81
+
82
+ # @return [Array<Hash>] the InvoiceMetadata entries of `_metadata.json` (empty when absent)
83
+ def metadata
84
+ json = read(METADATA_FILE)
85
+ return [] unless json
86
+
87
+ data = JSON.parse(json)
88
+ data.is_a?(Array) ? data : Array(data["invoices"])
89
+ rescue JSON::ParserError => e
90
+ raise Error, "#{METADATA_FILE} is not valid JSON: #{e.message}"
91
+ end
92
+
93
+ # @param name [String] file name inside the package
94
+ # @return [String, nil] that file's bytes (a ZIP is read through its central directory)
95
+ def read(name)
96
+ if compression == :zip
97
+ entry = zip.find_entry(name) || zip.find_entry("./#{name}")
98
+ return zip.read(entry) if entry
99
+ return nil if name.ascii_only? # a non-ASCII name may be stored without its UTF-8 flag
100
+ end
101
+ each_entry { |entry_name, content| return content if entry_name == name }
102
+ nil
103
+ end
104
+
105
+ private
106
+
107
+ def zip
108
+ require "zip"
109
+ @zip ||= Zip::File.open_buffer(StringIO.new(bytes))
110
+ end
111
+
112
+ def each_zip_entry
113
+ zip.each do |entry|
114
+ next unless entry.file?
115
+
116
+ name = entry_name(entry.name)
117
+ content = zip.get_input_stream(entry) { |io| io.read(MAX_ENTRY_SIZE + 1) } || "".b
118
+ yield(name, checked_size(name, content.bytesize) && content)
119
+ end
120
+ rescue Zip::Error => e
121
+ raise Error, "the export package is not a readable ZIP: #{e.message}"
122
+ end
123
+
124
+ # Archive names arrive as bytes; KSeF's are ASCII, a batch package's may be UTF-8.
125
+ def entry_name(raw)
126
+ name = raw.dup.force_encoding(Encoding::UTF_8)
127
+ name = (name.valid_encoding? ? name : raw).delete_prefix("./")
128
+ return name unless name.start_with?("/", "\\") || name.split(%r{[/\\]}).include?("..")
129
+
130
+ raise Error, "unexpected file name in the export package: #{name.inspect}"
131
+ end
132
+
133
+ def checked_size(name, size)
134
+ return true if size <= MAX_ENTRY_SIZE
135
+
136
+ raise Error, "#{name} in the export package is over #{MAX_ENTRY_SIZE} bytes"
137
+ end
138
+
139
+ def each_tar_entry
140
+ Ksef.require_optional("minitar", "Unpacking a tar.gz package")
141
+ require "zlib"
142
+ Zlib::GzipReader.wrap(StringIO.new(bytes)) do |gz|
143
+ Minitar::Reader.open(gz) do |tar|
144
+ tar.each_entry do |entry|
145
+ next unless entry.file?
146
+
147
+ name = entry_name(entry.full_name)
148
+ checked_size(name, entry.size)
149
+ # GzipReader yields binary data; an empty file reads as nil.
150
+ yield(name, entry.read || "".b)
151
+ end
152
+ end
153
+ end
154
+ rescue Zlib::Error => e
155
+ raise Error, "the export package is not a readable tar.gz: #{e.message}"
156
+ end
157
+ end
158
+
159
+ # @param client [Ksef::Client]
160
+ def initialize(client)
161
+ @client = client
162
+ end
163
+
164
+ # Runs the full export and returns the assembled package.
165
+ #
166
+ # @param filters [Hash] query filters
167
+ # @param only_metadata [Boolean] the package then holds `_metadata.json` alone
168
+ # @param compression_type [String, nil] "Zip" or "TarGz" (tar.gz needs the `minitar` gem to unpack);
169
+ # nil leaves it to the API (Zip)
170
+ # @param poll [Hash] polling options
171
+ # @return [Result]
172
+ def call(filters:, only_metadata: false, compression_type: "Zip", poll: {})
173
+ unless compression_type.nil? || COMPRESSION_TYPES.include?(compression_type)
174
+ raise ConfigurationError, "compression_type must be one of #{COMPRESSION_TYPES.join('/')}"
175
+ end
176
+
177
+ encryption = @client.crypto.encryption_data
178
+ init = @client.public_keys.with_rotation_retry do |retrying|
179
+ encryption = @client.crypto.rewrap(encryption) if retrying
180
+ @client.exports.create(encryption_info: encryption.encryption_info, filters: filters,
181
+ only_metadata: only_metadata, compression_type: compression_type)
182
+ end
183
+ status = wait_for_package(init["referenceNumber"], poll)
184
+ package = status["package"]
185
+ Result.new(bytes: assemble(package, encryption), package: package)
186
+ end
187
+
188
+ private
189
+
190
+ def wait_for_package(reference_number, poll)
191
+ Polling.run(description: "export #{reference_number}", logger: @client.config.logger, **poll) do
192
+ response = @client.exports.status(reference_number)
193
+ interpret_status(response)
194
+ end
195
+ end
196
+
197
+ def interpret_status(response)
198
+ status = response["status"] || {}
199
+ case status["code"]
200
+ when STATUS_COMPLETED
201
+ response["package"] ? response : Polling.pending
202
+ when CODE_PACKAGE_EXPIRED
203
+ raise Gone.new("Export package expired", codes: [CODE_PACKAGE_EXPIRED], body: response)
204
+ when nil, 100, 150
205
+ Polling.pending
206
+ else
207
+ raise ApiError.new(status["description"] || "Export error", codes: [status["code"]].compact, body: response)
208
+ end
209
+ end
210
+
211
+ # Downloads parts in order, verifies each ciphertext hash, decrypts each part separately and
212
+ # appends the plaintext to one buffer (no second copy of the package).
213
+ def assemble(package, encryption)
214
+ parts = Array(package["parts"]).sort_by { |p| p["ordinalNumber"] }
215
+ parts.each_with_object(String.new(encoding: Encoding::BINARY)) do |part, buffer|
216
+ buffer << download_and_decrypt(part, encryption)
217
+ end
218
+ end
219
+
220
+ def download_and_decrypt(part, encryption)
221
+ method = part["method"] || "GET"
222
+ response = @client.connection.presigned_transfer(method, part["url"])
223
+ verify_encrypted_part!(response.raw_body, part)
224
+ @client.crypto.decrypt_aes(response.raw_body, encryption.key, encryption.iv)
225
+ end
226
+
227
+ # Verifies the integrity of the ENCRYPTED part: SHA-256 of the downloaded bytes ==
228
+ # encryptedPartHash. A part without a hash is refused rather than trusted.
229
+ def verify_encrypted_part!(bytes, part)
230
+ expected = part["encryptedPartHash"].to_s
231
+ raise CryptoError, "Export part #{part['ordinalNumber']} has no encryptedPartHash" if expected.empty?
232
+
233
+ actual = Base64.strict_encode64(Digest::SHA256.digest(bytes))
234
+ return if actual == expected
235
+
236
+ raise CryptoError,
237
+ "Export part digest mismatch #{part['ordinalNumber']}: expected #{expected}, got #{actual}"
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Ksef
6
+ module Invoices
7
+ # Three FA(3) invoices built with {Builder}, each accepted on KSeF TEST: a starting point to copy or
8
+ # to adapt. `examples/fa3_{minimal,typical,full}.xml` are their XML.
9
+ module Sample
10
+ # Placeholder parties; the buyer's NIP is valid but belongs to no one in particular.
11
+ SELLER = { name: "Seller Ltd.", address: "1 Test Street, 00-001 Warsaw", country: "PL" }.freeze
12
+ BUYER = { nip: "1111111111", name: "Buyer Inc.", address: "2 Example Street, 00-002 Warsaw",
13
+ country: "PL" }.freeze
14
+
15
+ module_function
16
+
17
+ # One 23% line — the fields every invoice needs.
18
+ # @param seller_nip [String] the session's context NIP
19
+ # @param invoice_number [String] `P_2`, unique per seller
20
+ # @param issue_date [Date] `P_1`, the calendar day in Poland: KSeF refuses a later day and marks an
21
+ # earlier one as issued offline
22
+ # @return [Builder]
23
+ def minimal(seller_nip:, invoice_number:, issue_date: PolishTime.today)
24
+ Builder.new(
25
+ number: invoice_number, issue_date: issue_date, currency: "PLN",
26
+ seller: SELLER.merge(nip: seller_nip), buyer: BUYER,
27
+ lines: [{ name: "Demo service", unit: "szt.", quantity: "1", unit_price: "100.00", rate: "23" }]
28
+ )
29
+ end
30
+
31
+ # {minimal} plus the sale date, the place of issue and a bank transfer due in 14 days.
32
+ # @param (see minimal)
33
+ # @return [Builder]
34
+ def typical(seller_nip:, invoice_number:, issue_date: PolishTime.today)
35
+ minimal(seller_nip: seller_nip, invoice_number: invoice_number, issue_date: issue_date).tap do |invoice|
36
+ invoice.sale_date = issue_date
37
+ invoice.place = "Warsaw"
38
+ invoice.payment_method = :transfer
39
+ invoice.due_date = issue_date + 14
40
+ invoice.bank_account = "17100000000000000000000001"
41
+ end
42
+ end
43
+
44
+ # A VAT invoice with most fields {Builder} supports: contact data, five rates (23, 8, 5, 0 domestic,
45
+ # exempt), line details and a discount, a transfer with the bank's name, the footer and registers.
46
+ # Not shown: a payment already made, a buyer identified other than by NIP, a correction, annotations
47
+ # and the other five rates.
48
+ # @param (see minimal)
49
+ # @return [Builder]
50
+ def full(seller_nip:, invoice_number:, issue_date: PolishTime.today)
51
+ typical(seller_nip: seller_nip, invoice_number: invoice_number, issue_date: issue_date).tap do |invoice|
52
+ invoice.seller = SELLER.merge(nip: seller_nip, email: "invoices@seller.example", phone: "+48 22 000 00 00")
53
+ invoice.buyer = BUYER.merge(email: "accounts@buyer.example")
54
+ invoice.lines = full_lines
55
+ invoice.exemption_reason = "Art. 43 ust. 1 pkt 37 ustawy o VAT"
56
+ invoice.bank_name = "Example Bank"
57
+ invoice.footer = "Thank you for your business."
58
+ invoice.krs = "0000123456"
59
+ invoice.regon = "123456785"
60
+ invoice.bdo = "000012345"
61
+ end
62
+ end
63
+
64
+ def full_lines
65
+ [
66
+ { name: "Software licence", unit: "szt.", quantity: "2", unit_price: "1000.00", rate: "23",
67
+ index: "LIC-01", gtin: "5901234123457", pkwiu: "58.29.50.0", cn: "8523", gtu: "GTU_12",
68
+ discount: "100.00" },
69
+ { name: "Printed manual", unit: "szt.", quantity: "1", unit_price: "50.00", rate: "5" },
70
+ { name: "Catering", unit: "szt.", quantity: "3", unit_price: "40.00", rate: "8" },
71
+ { name: "Zero-rated supply", unit: "szt.", quantity: "1", unit_price: "20.00", rate: "0 KR" },
72
+ { name: "Insurance", unit: "szt.", quantity: "1", unit_price: "30.00", rate: "zw" }
73
+ ]
74
+ end
75
+ private_class_method :full_lines
76
+ end
77
+ end
78
+ end