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,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
|
data/lib/ksef/polling.rb
ADDED
|
@@ -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
|