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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- 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
|