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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ksef
6
+ module Invoices
7
+ # The element tree of a packaged form schema — the fields an invoice may carry, with cardinality
8
+ # and type ({Validator#outline}). The schemas are large, so it renders a subtree to a given depth.
9
+ # @api private
10
+ class SchemaOutline
11
+ # @param schema_path [String] packaged XSD file
12
+ def initialize(schema_path)
13
+ @doc = Nokogiri::XML(File.read(schema_path))
14
+ @doc.remove_namespaces!
15
+ @types = @doc.xpath("/schema/complexType").to_h { |node| [node["name"], node] }
16
+ end
17
+
18
+ # @param path [String] slash-separated element path, e.g. "Faktura/Fa/Adnotacje"
19
+ # @param depth [Integer] levels below +path+
20
+ # @param descriptions [Boolean] add each element's description from the schema on its own line
21
+ # @return [String] one line per element: name (indented), cardinality, type
22
+ # @raise [Ksef::ConfigurationError] when the path does not exist in the schema
23
+ def render(path, depth, descriptions: false)
24
+ @descriptions = descriptions
25
+ lines = []
26
+ walk(resolve(path), depth, "", lines)
27
+ lines.join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ def walk(element, depth, indent, lines)
33
+ type = complex_type(element)
34
+ return if type.nil? || depth.zero?
35
+
36
+ group_elements(type).each do |child, occurrence, branch|
37
+ note = cardinality(child, occurrence) + (branch ? " | #{branch}" : "")
38
+ lines << format("%<name>-44s %<note>-20s %<type>s",
39
+ name: indent + child["name"], note: note, type: child["type"].to_s.split(":").last).rstrip
40
+ text = @descriptions && description(child)
41
+ lines << "#{indent} — #{text}" if text
42
+ walk(child, depth - 1, "#{indent} ", lines)
43
+ end
44
+ end
45
+
46
+ # The element's `xsd:documentation`, or its named type's, on one line.
47
+ def description(element)
48
+ node = element.at_xpath("./annotation/documentation") ||
49
+ @types[element["type"].to_s.split(":").last]&.at_xpath("./annotation/documentation")
50
+ text = node&.text.to_s.gsub(/\s+/, " ").strip
51
+ text.empty? ? nil : text
52
+ end
53
+
54
+ # The complexType behind an element: inline, or looked up by its type name.
55
+ def complex_type(element)
56
+ element.at_xpath("./complexType") || @types[element["type"].to_s.split(":").last]
57
+ end
58
+
59
+ # Elements of a model group with the occurrence of their enclosing groups (an optional or repeated
60
+ # sequence/choice makes its elements so) and their choice branch: "choice" for a single-element
61
+ # branch, "choice: A+B" for a sequence one. An extension starts with its base type's elements.
62
+ def group_elements(node, occurrence = { optional: false, max: nil }, branch = nil, out = [])
63
+ node.element_children.each do |child|
64
+ case child.name
65
+ when "element" then out << [child, occurrence, branch]
66
+ when "sequence", "all" then group_elements(child, within(child, occurrence), branch, out)
67
+ when "complexContent", "restriction" then group_elements(child, occurrence, branch, out)
68
+ when "extension"
69
+ base = @types[child["base"].to_s.split(":").last]
70
+ group_elements(base, occurrence, branch, out) if base
71
+ group_elements(child, occurrence, branch, out)
72
+ when "choice" then choice_elements(child, within(child, occurrence), out)
73
+ end
74
+ end
75
+ out
76
+ end
77
+
78
+ def choice_elements(choice, occurrence, out)
79
+ choice.element_children.each do |alternative|
80
+ names = alternative.name == "element" ? [] : group_elements(alternative).map { |element, *| element["name"] }
81
+ label = names.size > 1 ? "choice: #{names.join('+')}" : "choice"
82
+ case alternative.name
83
+ when "element" then out << [alternative, occurrence, label]
84
+ when "choice" then choice_elements(alternative, within(alternative, occurrence), out)
85
+ else group_elements(alternative, within(alternative, occurrence), label, out)
86
+ end
87
+ end
88
+ end
89
+
90
+ def within(group, occurrence)
91
+ max = group["maxOccurs"]
92
+ { optional: occurrence[:optional] || group["minOccurs"] == "0",
93
+ max: occurrence[:max] || (max && max != "1" ? max : nil) }
94
+ end
95
+
96
+ def cardinality(element, occurrence)
97
+ min = occurrence[:optional] ? "0" : element["minOccurs"] || "1"
98
+ max = element["maxOccurs"] || "1"
99
+ max = occurrence[:max] if max == "1" && occurrence[:max]
100
+ return "required" if [min, max] == %w[1 1]
101
+ return "optional" if min == "0" && max == "1"
102
+
103
+ "#{min}..#{max}"
104
+ end
105
+
106
+ def resolve(path)
107
+ names = path.to_s.split("/")
108
+ node = @doc.xpath("/schema/element").find { |element| element["name"] == names.first }
109
+ raise ConfigurationError, "No top-level element #{names.first.inspect} in the schema" unless node
110
+
111
+ names.drop(1).each do |name|
112
+ type = complex_type(node)
113
+ node = type && group_elements(type).map(&:first).find { |element| element["name"] == name }
114
+ raise ConfigurationError, "#{name} not found under #{path}" unless node
115
+ end
116
+ node
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ksef
6
+ module Invoices
7
+ # Validates an invoice locally against the form XSD KSeF applies, plus KSeF's XML rules (UTF-8 without
8
+ # a BOM, no processing instructions, no discouraged characters): what fails here fails on KSeF too, in
9
+ # libxml2's wording rather than KSeF's. Schemas ship with the gem (FA(2), FA(3), FA_RR(1)); not PEF (UBL).
10
+ class Validator
11
+ # Directory holding the schemas packaged with the gem.
12
+ SCHEMA_ROOT = File.expand_path("../../../schemas", __dir__)
13
+
14
+ # Form `systemCode` → schema file. The version lives in the file name, so a new
15
+ # `schemaVersion` means replacing the file plus the FormCode constant (schemas/README.md).
16
+ SCHEMAS = {
17
+ "FA (2)" => "FA/schemat_FA(2)_v1-0E.xsd",
18
+ "FA (3)" => "FA/schemat_FA(3)_v1-0E.xsd",
19
+ "FA_RR (1)" => "RR/schemat_FA_RR(1)_v1-1E.xsd"
20
+ }.freeze
21
+
22
+ # Code points KSeF refuses anywhere in an invoice (beyond what XML 1.0 itself forbids): C1 controls
23
+ # except U+0085, and the Unicode noncharacters.
24
+ DISALLOWED_CHARACTERS = Regexp.new(
25
+ "[\\u007F-\\u0084\\u0086-\\u009F\\uFDD0-\\uFDEF" \
26
+ "#{(0..16).map { |plane| format('\\u{%<p>XFFFE}\\u{%<p>XFFFF}', p: plane) }.join}]"
27
+ )
28
+
29
+ # One violation: `path` names the element (e.g. "Faktura/Podmiot2/DaneIdentyfikacyjne/NIP"), nil for
30
+ # a document that is not well-formed and for KSeF's document-level rules.
31
+ Issue = Struct.new(:path, :line, :message, keyword_init: true)
32
+
33
+ @schemas = {}
34
+ @schemas_lock = Mutex.new
35
+
36
+ # @return [String] systemCode of the form this instance validates against
37
+ attr_reader :system_code
38
+
39
+ # @param form_code [Ksef::FormCode, String] invoice form (object or systemCode)
40
+ # @raise [Ksef::ConfigurationError] when no packaged schema exists for the form
41
+ def initialize(form_code: FormCode::FA3)
42
+ @system_code = form_code.respond_to?(:system_code) ? form_code.system_code : form_code.to_s
43
+ @schema = self.class.schema(@system_code)
44
+ end
45
+
46
+ # Path to the schema for the given `systemCode`.
47
+ #
48
+ # @param system_code [String]
49
+ # @return [String]
50
+ # @raise [Ksef::ConfigurationError]
51
+ def self.schema_path(system_code)
52
+ relative = SCHEMAS[system_code]
53
+ if relative.nil?
54
+ raise ConfigurationError, "no packaged schema for form #{system_code.inspect} " \
55
+ "(available: #{SCHEMAS.keys.join(', ')})"
56
+ end
57
+
58
+ File.join(SCHEMA_ROOT, relative)
59
+ end
60
+
61
+ # The parsed schema, loaded once per process — parsing the XSD set costs far more than a
62
+ # validation, and a parsed schema is safe to share between threads.
63
+ #
64
+ # The schema is loaded as a document WITH the file URL — without it the relative
65
+ # `../bazowe/...` import has nothing to resolve against and libxml tries to fetch the schema
66
+ # over the network (which it refuses: "Attempt to load network entity").
67
+ #
68
+ # @param system_code [String]
69
+ # @return [Nokogiri::XML::Schema]
70
+ # @raise [Ksef::ConfigurationError]
71
+ def self.schema(system_code)
72
+ @schemas_lock.synchronize do
73
+ @schemas[system_code] ||= begin
74
+ path = schema_path(system_code)
75
+ Nokogiri::XML::Schema.from_document(Nokogiri::XML(File.read(path), path))
76
+ end
77
+ end
78
+ end
79
+
80
+ # Validates the invoice and returns the validator messages — a document that is not well-formed
81
+ # XML yields its parse error instead.
82
+ #
83
+ # @param xml [String] invoice XML
84
+ # @return [Array<String>] empty when the invoice matches the schema and KSeF's XML rules
85
+ def validate(xml)
86
+ errors(xml).map(&:message)
87
+ end
88
+
89
+ # Like {#validate}, with the element path and line of each violation — e.g. to show it next to a
90
+ # form field.
91
+ #
92
+ # @param xml [String] invoice XML
93
+ # @return [Array<Issue>] empty when the invoice matches the schema and KSeF's XML rules
94
+ def errors(xml)
95
+ rules = document_issues(xml.to_s)
96
+ doc = parse(xml)
97
+ namespace = doc.errors.select { |error| error.error? || error.fatal? }.map { |error| issue(error, nil) }
98
+ return rules + namespace unless namespace.empty?
99
+
100
+ rules + processing_instructions(doc) + @schema.validate(doc).map { |error| issue(error, doc) }
101
+ rescue Nokogiri::XML::SyntaxError => e
102
+ rules + [issue(e, nil)]
103
+ end
104
+
105
+ # The form's field tree — what an invoice may carry, with cardinality and type.
106
+ #
107
+ # @param path [String] slash-separated element path, e.g. "Faktura/Fa/Adnotacje"
108
+ # @param depth [Integer, nil] levels to show; nil — 2 for "Faktura", the whole subtree otherwise
109
+ # @param descriptions [Boolean] add each element's description from the schema (Polish) below it
110
+ # @return [String] one line per element: name (indented by depth), cardinality, type
111
+ # @raise [Ksef::ConfigurationError] when the path does not exist in the schema
112
+ def outline(path = "Faktura", depth: nil, descriptions: false)
113
+ depth ||= path == "Faktura" ? 2 : 99
114
+ SchemaOutline.new(self.class.schema_path(system_code)).render(path, depth, descriptions: descriptions)
115
+ end
116
+
117
+ # @param xml [String]
118
+ # @return [Boolean] whether the invoice matches the schema
119
+ def valid?(xml)
120
+ validate(xml).empty?
121
+ end
122
+
123
+ # Validates and raises when the invoice does not match.
124
+ #
125
+ # @param xml [String]
126
+ # @return [true]
127
+ # @raise [Ksef::SchemaValidationError]
128
+ def validate!(xml)
129
+ issues = errors(xml)
130
+ return true if issues.empty?
131
+
132
+ raise SchemaValidationError.new("invoice does not match schema #{system_code}",
133
+ messages: issues.map(&:message), errors: issues, system_code: system_code)
134
+ end
135
+
136
+ private
137
+
138
+ # KSeF's checks of the document itself (faktury/weryfikacja-faktury.md), before its schema.
139
+ def document_issues(xml)
140
+ bytes = xml.b
141
+ issues = []
142
+ issues << rule("the document starts with a byte order mark; KSeF requires UTF-8 without one") if
143
+ bytes.start_with?("\xEF\xBB\xBF".b)
144
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
145
+ # UTF-16 text is valid UTF-8 byte for byte only as long as it holds NULs, which XML never may.
146
+ return issues << rule("the document is not valid UTF-8") if !text.valid_encoding? || text.include?("\0")
147
+
148
+ declaration = text[/\A\uFEFF?<\?xml\s[^>]*\?>/].to_s
149
+ encoding = declaration[/encoding\s*=\s*["']([^"']+)["']/, 1]
150
+ issues << rule("the XML declaration names encoding #{encoding}; KSeF requires UTF-8") if
151
+ encoding && !encoding.casecmp?("UTF-8")
152
+ version = declaration[/version\s*=\s*["']([^"']+)["']/, 1]
153
+ issues << rule("the document declares XML #{version}; KSeF requires XML 1.0") if version && version != "1.0"
154
+ bad = text[DISALLOWED_CHARACTERS]
155
+ issues << rule("the document contains #{format('U+%04X', bad.ord)}, a character KSeF refuses") if bad
156
+ issues
157
+ end
158
+
159
+ def processing_instructions(doc)
160
+ doc.xpath("//processing-instruction()").map do |node|
161
+ Issue.new(path: nil, line: node.line, message: "processing instruction <?#{node.name}?>: KSeF refuses them")
162
+ end
163
+ end
164
+
165
+ def rule(message)
166
+ Issue.new(path: nil, line: nil, message: message)
167
+ end
168
+
169
+ def issue(error, doc)
170
+ Issue.new(path: element_path(error, doc), line: error.line, message: error.message.strip)
171
+ end
172
+
173
+ # libxml2 reports a positional path ("/*/*[3]/*[1]", "/*/f:P_2"); this walks it step by step by
174
+ # local name and position — the prefixes need not be declared on the root — and names each step.
175
+ def element_path(error, doc)
176
+ xpath = error.respond_to?(:path) ? error.path : nil
177
+ return nil if doc.nil? || xpath.nil? || !xpath.start_with?("/")
178
+
179
+ node = xpath.delete_prefix("/").split("/").inject(doc) { |parent, step| parent && child_at(parent, step) }
180
+ return nil unless node.is_a?(Nokogiri::XML::Element)
181
+
182
+ [*node.ancestors.reverse.grep(Nokogiri::XML::Element), node].map(&:name).join("/")
183
+ end
184
+
185
+ def child_at(parent, step)
186
+ name, index = step.match(/\A(?:[^:\[]+:)?([^\[]+)(?:\[(\d+)\])?\z/)&.captures
187
+ return nil unless name
188
+
189
+ candidates = parent.element_children.select { |child| name == "*" || child.name == name }
190
+ candidates[(index || "1").to_i - 1]
191
+ end
192
+
193
+ # Strict, so that a document KSeF would reject as malformed fails here too (the default RECOVER
194
+ # mode silently repairs it); no network access, no entity expansion.
195
+ def parse(xml)
196
+ Nokogiri::XML(xml) { |config| config.strict.nonet }
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # Invoice schema code. The { systemCode, schemaVersion, value } object
5
+ # passed when opening a session.
6
+ #
7
+ # FA(2) is available on TEST only — the gem defaults to FA(3).
8
+ class FormCode
9
+ # @return [String] system code (e.g. "FA (3)")
10
+ attr_reader :system_code
11
+ # @return [String] schema version (e.g. "1-0E")
12
+ attr_reader :schema_version
13
+ # @return [String] value (e.g. "FA")
14
+ attr_reader :value
15
+ # @return [String, nil] XML namespace of the invoice document (the schema's targetNamespace);
16
+ # nil for PEF, whose UBL schemas are not packaged
17
+ attr_reader :namespace
18
+
19
+ # @param system_code [String]
20
+ # @param schema_version [String]
21
+ # @param value [String]
22
+ # @param namespace [String, nil]
23
+ def initialize(system_code:, schema_version:, value:, namespace: nil)
24
+ @system_code = system_code
25
+ @schema_version = schema_version
26
+ @value = value
27
+ @namespace = namespace
28
+ end
29
+
30
+ # @return [Hash] JSON representation matching the API contract
31
+ def to_h
32
+ { "systemCode" => system_code, "schemaVersion" => schema_version, "value" => value }
33
+ end
34
+
35
+ # FA(2) — TEST environment only.
36
+ FA2 = new(system_code: "FA (2)", schema_version: "1-0E", value: "FA",
37
+ namespace: "http://crd.gov.pl/wzor/2023/06/29/12648/")
38
+ # FA(3) — the default production schema.
39
+ FA3 = new(system_code: "FA (3)", schema_version: "1-0E", value: "FA",
40
+ namespace: "http://crd.gov.pl/wzor/2025/06/25/13775/")
41
+ # PEF(3) — PEF invoices.
42
+ PEF3 = new(system_code: "PEF (3)", schema_version: "2-1", value: "PEF")
43
+ # PEF_KOR(3) — PEF corrections.
44
+ PEF_KOR3 = new(system_code: "PEF_KOR (3)", schema_version: "2-1", value: "PEF")
45
+ # FA_RR(1) — RR invoices (flat-rate farmers).
46
+ FA_RR1 = new(system_code: "FA_RR (1)", schema_version: "1-1E", value: "FA_RR",
47
+ namespace: "http://crd.gov.pl/wzor/2026/03/06/14189/")
48
+ end
49
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # KSeF number value object, e.g. `5555555555-20250826-0100001AF629-72`.
5
+ #
6
+ # The trailing 2 HEX characters are CRC-8 (poly 0x07, init 0x00) over everything before the
7
+ # last "-", hyphens included. The pattern also accepts the 36-character form (with a "-" inside the
8
+ # 12-character hex part).
9
+ class KsefNumber
10
+ CRC8_POLYNOMIAL = 0x07
11
+ CRC8_INIT = 0x00
12
+ # `KsefNumber` in the KSeF contract: seller NIP, date (YYYYMMDD), uppercase hex, CRC.
13
+ PATTERN = /\A[1-9](\d[1-9]|[1-9]\d)\d{7}-(20[2-9]\d|2[1-9]\d{2}|[3-9]\d{3})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])-
14
+ [0-9A-F]{6}-?[0-9A-F]{6}-[0-9A-F]{2}\z/x
15
+
16
+ # @return [String] raw number value
17
+ attr_reader :value
18
+
19
+ # @param value [String]
20
+ def initialize(value)
21
+ @value = value.to_s
22
+ end
23
+
24
+ # @return [Boolean] whether the number matches the contract pattern and its CRC-8 checksum
25
+ def valid?
26
+ return false unless @value.match?(PATTERN)
27
+
28
+ data, _sep, checksum = @value.rpartition("-")
29
+ checksum == format("%02X", self.class.crc8(data))
30
+ end
31
+
32
+ # Validates and returns the object, or raises.
33
+ # @return [self]
34
+ # @raise [Ksef::ConfigurationError] when the number is invalid
35
+ def validate!
36
+ raise ConfigurationError, "Invalid KSeF number: #{@value.inspect}" unless valid?
37
+
38
+ self
39
+ end
40
+
41
+ # @return [String] seller tax identification number (the first 10 characters)
42
+ def seller_nip
43
+ @value[0, 10]
44
+ end
45
+
46
+ def to_s
47
+ @value
48
+ end
49
+
50
+ # Equal to another KsefNumber with the same value (not to a String, so that equality stays symmetric).
51
+ def ==(other)
52
+ other.is_a?(KsefNumber) && value == other.value
53
+ end
54
+ alias eql? ==
55
+
56
+ def hash
57
+ value.hash
58
+ end
59
+
60
+ # Computes CRC-8 (poly 0x07, init 0x00, no reflection or xorout) over ASCII bytes.
61
+ # @param string [String]
62
+ # @return [Integer] value 0–255
63
+ def self.crc8(string)
64
+ crc = CRC8_INIT
65
+ string.each_byte do |byte|
66
+ crc ^= byte
67
+ 8.times do
68
+ crc = crc.anybits?(0x80) ? ((crc << 1) ^ CRC8_POLYNOMIAL) & 0xFF : (crc << 1) & 0xFF
69
+ end
70
+ end
71
+ crc
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # Polish tax identification number (NIP): the contract's pattern (no leading zero, digits 2–3 not
5
+ # both zero) plus the mod-11 checksum — weights [6,5,7,2,3,4,5,6,7], a remainder of 10 is invalid.
6
+ class Nip
7
+ WEIGHTS = [6, 5, 7, 2, 3, 4, 5, 6, 7].freeze
8
+ # `Nip` in the KSeF contract.
9
+ PATTERN = /\A[1-9]((\d[1-9])|([1-9]\d))\d{7}\z/
10
+
11
+ # @return [String] digits only (separators and the "PL" prefix stripped)
12
+ attr_reader :value
13
+
14
+ # Normalizes a NIP given to the gem, refusing an invalid one before it reaches KSeF or a QR link.
15
+ # @param value [String, Nip] tax id; spaces (no-break ones too), dashes and a "PL" prefix are stripped
16
+ # @param name [String] argument name for the error message
17
+ # @return [String] the 10 digits
18
+ # @raise [Ksef::ConfigurationError] when the number is invalid
19
+ def self.normalize!(value, name: "NIP")
20
+ nip = value.is_a?(Nip) ? value : new(value)
21
+ raise ConfigurationError, "#{name} is not a valid NIP: #{value.to_s.dump}" unless nip.valid?
22
+
23
+ nip.value
24
+ end
25
+
26
+ # @param value [String] tax id; spaces (no-break ones too), dashes and a "PL" prefix are stripped
27
+ def initialize(value)
28
+ @value = value.to_s.gsub(/[[:space:]\p{Pd}]/, "").sub(/\APL/i, "")
29
+ end
30
+
31
+ # @return [Boolean] whether it matches the contract pattern and has a valid check digit
32
+ def valid?
33
+ return false unless @value.match?(PATTERN)
34
+
35
+ digits = @value.chars.map(&:to_i)
36
+ checksum = WEIGHTS.each_with_index.sum { |w, i| w * digits[i] } % 11
37
+ checksum != 10 && checksum == digits[9]
38
+ end
39
+
40
+ # @return [self]
41
+ # @raise [Ksef::ConfigurationError] when the number is invalid
42
+ def validate!
43
+ raise ConfigurationError, "Invalid tax identification number: #{@value.inspect}" unless valid?
44
+
45
+ self
46
+ end
47
+
48
+ def to_s
49
+ @value
50
+ end
51
+
52
+ # Equal to another Nip with the same digits (not to a String, so that equality stays symmetric).
53
+ def ==(other)
54
+ other.is_a?(Nip) && value == other.value
55
+ end
56
+ alias eql? ==
57
+
58
+ def hash
59
+ value.hash
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Ksef
6
+ # The Europe/Warsaw calendar, which KSeF dates follow (e.g. an invoice's `P_1`), without tzinfo: CET
7
+ # (+01:00), and CEST (+02:00) from 01:00 UTC on the last Sunday of March to 01:00 UTC on the last
8
+ # Sunday of October — the EU rule.
9
+ module PolishTime
10
+ module_function
11
+
12
+ # @param now [Time]
13
+ # @return [Date] the current calendar day in Poland
14
+ def today(now = Time.now)
15
+ at(now).to_date
16
+ end
17
+
18
+ # @param time [Time]
19
+ # @return [Time] the same instant with the Polish UTC offset
20
+ def at(time)
21
+ utc = time.getutc
22
+ utc.getlocal(offset(utc))
23
+ end
24
+
25
+ # @param date [Date]
26
+ # @return [Time] midnight in Poland at the start of the day
27
+ def midnight(date)
28
+ utc = Time.utc(date.year, date.month, date.day)
29
+ offset = offset(utc - 3600)
30
+ (utc - offset).getlocal(offset)
31
+ end
32
+
33
+ # @param utc [Time]
34
+ # @return [Integer] the Polish UTC offset in seconds at that instant
35
+ def offset(utc)
36
+ summer_start = switch(utc.year, 3)
37
+ summer_end = switch(utc.year, 10)
38
+ utc >= summer_start && utc < summer_end ? 7200 : 3600
39
+ end
40
+
41
+ # 01:00 UTC on the last Sunday of the month.
42
+ def switch(year, month)
43
+ last = Date.new(year, month, -1)
44
+ sunday = last - last.wday
45
+ Time.utc(year, month, sunday.day, 1)
46
+ end
47
+ private_class_method :switch
48
+ end
49
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # Polling helper for async operations: auth, sessions, export, enrollment, permissions.
5
+ # The block returns {.pending} to keep waiting (backoff + Retry-After); any other value is the
6
+ # result. Backoff is a deliberate improvement over both SDKs, which poll only in their tests.
7
+ module Polling
8
+ # "Operation in progress" signal — optionally carries retry_after (seconds) from the server.
9
+ Pending = Struct.new(:retry_after, keyword_init: true)
10
+
11
+ module_function
12
+
13
+ # @param retry_after [Numeric, nil] suggested delay before the next attempt (from Retry-After)
14
+ # @return [Pending]
15
+ def pending(retry_after: nil)
16
+ Pending.new(retry_after: retry_after)
17
+ end
18
+
19
+ # Polls with exponential backoff until the block returns a value other than {.pending}.
20
+ #
21
+ # @param timeout [Numeric] maximum total wait time (s)
22
+ # @param interval [Numeric] initial delay between attempts (s)
23
+ # @param max_interval [Numeric] upper bound for the delay (s)
24
+ # @param backoff [Numeric] delay growth multiplier
25
+ # @param logger [Logger, nil]
26
+ # @param description [String] operation description for logs and the timeout message
27
+ # @yieldreturn [Pending, Object] {.pending} to continue, anything else is the final result
28
+ # @return [Object] the value returned by the block
29
+ # @raise [Ksef::PollingTimeout] once +timeout+ is exceeded
30
+ def run(timeout: 300, interval: 2.0, max_interval: 30.0, backoff: 1.5, logger: nil, description: "operation")
31
+ start = clock
32
+ current = interval.to_f
33
+ last = nil
34
+ loop do
35
+ result = yield
36
+ return result unless result.is_a?(Pending)
37
+
38
+ last = result
39
+ elapsed = clock - start
40
+ if elapsed >= timeout
41
+ raise PollingTimeout.new("Wait limit exceeded (#{timeout}s) for: #{description}",
42
+ last_status: describe(last))
43
+ end
44
+
45
+ wait = wait_seconds(result, current, timeout - elapsed)
46
+ logger&.debug("[ksef] polling #{description}: waiting #{wait.round(1)}s (elapsed #{elapsed.round(1)}s)")
47
+ sleep(wait) if wait.positive?
48
+ current = [current * backoff, max_interval.to_f].min
49
+ end
50
+ end
51
+
52
+ def wait_seconds(pending, current, remaining)
53
+ wait = (pending.retry_after || current).to_f
54
+ [wait, remaining].min
55
+ end
56
+
57
+ def clock
58
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ end
60
+
61
+ def describe(pending)
62
+ pending.retry_after ? "pending(retry_after=#{pending.retry_after})" : "pending"
63
+ end
64
+ end
65
+ end