wfirma 0.1.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.
@@ -0,0 +1,254 @@
1
+ module Wfirma
2
+ module Drivers
3
+ # Dev/test driver - no HTTP at all. Returns realistic wFirma-shaped
4
+ # responses so the full payload-mapping and Result-parsing code paths
5
+ # run exactly as in production.
6
+ #
7
+ # It keeps an in-memory contractor catalogue, so the find -> add/edit flow
8
+ # behaves in dev the way it does against the real CRM: the same customer
9
+ # resolves to one record across invoices.
10
+ #
11
+ # Failure simulation:
12
+ # - Magic contractor NIPs (Stripe-test-card style) drive scenarios from
13
+ # the UI or system tests; all-zeros NIPs are checksum-invalid so no
14
+ # real customer can trigger them. They apply to any call carrying a
15
+ # contractor (invoices/add, contractors/add, contractors/edit).
16
+ # - Polish postal codes are validated the way wFirma validates them, so
17
+ # the malformed-zip rejection can be exercised without a network.
18
+ # - fail_next!/fail_always! for unit tests and console; these apply to
19
+ # every action, download included.
20
+ class Fake
21
+ include DeepDup
22
+
23
+ Request = Struct.new(:module_name, :action, :payload, :params, :id)
24
+
25
+ MAGIC_NIPS = {
26
+ "0000000000" => :validation_error,
27
+ "0000000001" => :auth_error,
28
+ "0000000002" => :connection_error
29
+ }.freeze
30
+
31
+ PL_ZIP = /\A\d{2}-\d{3}\z/
32
+
33
+ # wFirma accepts Polish postal codes only as XX-XXX and rejects the
34
+ # whole contractor otherwise. Message and field taken from a rejection
35
+ # observed in the reference integration.
36
+ ZIP_ERROR = { "field" => "zip", "message" => "Niepoprawny format kodu pocztowego." }.freeze
37
+
38
+ MAGIC_NIP_ERROR = {
39
+ "field" => "nip",
40
+ "message" => "Symulowany błąd walidacji (magic NIP 0000000000)"
41
+ }.freeze
42
+
43
+ # Smallest thing that still passes a "%PDF-" magic-bytes check.
44
+ FAKE_PDF = "%PDF-1.4\n% Wfirma::Drivers::Fake\n%%EOF\n".b.freeze
45
+
46
+ attr_reader :requests
47
+
48
+ def initialize
49
+ reset!
50
+ end
51
+
52
+ def reset!
53
+ @requests = []
54
+ @next_id = 0
55
+ @next_contractor_id = 0
56
+ @fail_next = nil
57
+ @fail_always = nil
58
+ @contractors = Catalogue.new
59
+ end
60
+
61
+ def fail_next!(code: "ERROR", errors: [])
62
+ @fail_next = { code: code, errors: errors }
63
+ end
64
+
65
+ def fail_always!(code: "ERROR", errors: [])
66
+ @fail_always = { code: code, errors: errors }
67
+ end
68
+
69
+ def call(module_name, action, payload, params = {}, id = nil)
70
+ record(module_name, action, payload, params, id)
71
+
72
+ failure = consume_failure
73
+ return simulate_failure(failure, payload) if failure
74
+
75
+ raise_magic_nip_error(payload)
76
+ rejection = contractor_rejection(payload)
77
+ return rejection if rejection
78
+
79
+ return contractors_response(action, payload, id) if module_name == "contractors"
80
+
81
+ ok_response(payload)
82
+ end
83
+
84
+ # Mirrors Drivers::Http#download: raw bytes on success, an exception
85
+ # otherwise - a fake PDF is enough to exercise storage/serving code.
86
+ def download(module_name, action, payload, params = {}, id = nil)
87
+ record(module_name, action, payload, params, id)
88
+
89
+ failure = consume_failure
90
+ simulate_download_failure(failure) if failure
91
+
92
+ FAKE_PDF.dup
93
+ end
94
+
95
+ private
96
+
97
+ def record(module_name, action, payload, params, id)
98
+ @requests << Request.new(module_name, action, deep_dup(payload), deep_dup(params), id)
99
+ end
100
+
101
+ def consume_failure
102
+ failure = @fail_next || @fail_always
103
+ @fail_next = nil
104
+ failure
105
+ end
106
+
107
+ # The buyer, wherever this payload carries them.
108
+ def contractor_from(payload)
109
+ inline = invoice_from(payload)["contractor"]
110
+ return inline if inline.is_a?(Hash)
111
+
112
+ own = payload.dig("contractors", "contractor") if payload.is_a?(Hash)
113
+ own.is_a?(Hash) ? own : {}
114
+ end
115
+
116
+ def raise_magic_nip_error(payload)
117
+ case MAGIC_NIPS[contractor_from(payload)["nip"].to_s]
118
+ when :auth_error
119
+ raise AuthError, "wFirma authentication failed (simulated by magic NIP 0000000001)"
120
+ when :connection_error
121
+ raise ConnectionError, "wFirma connection failed (simulated by magic NIP 0000000002)"
122
+ end
123
+ end
124
+
125
+ # Validation wFirma performs on write, in the order it would reject.
126
+ def contractor_rejection(payload)
127
+ contractor = contractor_from(payload)
128
+ return nil if contractor.empty?
129
+
130
+ if MAGIC_NIPS[contractor["nip"].to_s] == :validation_error
131
+ return validation_error_response(payload, [MAGIC_NIP_ERROR])
132
+ end
133
+
134
+ invalid_zip?(contractor) ? validation_error_response(payload, [ZIP_ERROR]) : nil
135
+ end
136
+
137
+ def invalid_zip?(contractor)
138
+ zip = contractor["zip"].to_s
139
+ country = contractor["country"].to_s
140
+ return false if zip.empty?
141
+ return false unless country.empty? || country.casecmp("PL").zero?
142
+
143
+ !zip.match?(PL_ZIP)
144
+ end
145
+
146
+ # An armed code that aborts the request raises here exactly as it does
147
+ # over HTTP, so arming one exercises the path production takes.
148
+ def armed_error(failure)
149
+ Status.error_for(failure[:code], message: "simulated", errors: failure[:errors])
150
+ end
151
+
152
+ def simulate_failure(failure, payload)
153
+ error = armed_error(failure)
154
+ raise error if error
155
+
156
+ nodes = failure[:errors].map { |message| { "message" => message } }
157
+ error_response(payload, nodes, code: failure[:code])
158
+ end
159
+
160
+ def simulate_download_failure(failure)
161
+ error = armed_error(failure)
162
+ raise error if error
163
+
164
+ raise ApiError.new(
165
+ "wFirma returned no PDF (simulated, status #{failure[:code]}): #{failure[:errors].join("; ")}",
166
+ status_code: failure[:code], errors: failure[:errors]
167
+ )
168
+ end
169
+
170
+ def contractors_response(action, payload, id)
171
+ case action
172
+ when "find" then found_contractors(payload)
173
+ when "add" then collection_response("contractors", "contractor", @contractors.add(contractor_from(payload)))
174
+ when "edit" then edited_contractor(id, contractor_from(payload))
175
+ else status_response("OK")
176
+ end
177
+ end
178
+
179
+ def found_contractors(payload)
180
+ condition = payload.dig("contractors", "parameters", "conditions", "condition") || {}
181
+ { "contractors" => @contractors.find(condition), "status" => { "code" => "OK" } }
182
+ end
183
+
184
+ def edited_contractor(id, attrs)
185
+ edited = @contractors.edit(id, attrs)
186
+ return status_response("NOT FOUND", message: "Nie znaleziono kontrahenta #{id}") unless edited
187
+
188
+ collection_response("contractors", "contractor", edited)
189
+ end
190
+
191
+ def ok_response(payload)
192
+ return status_response("OK") unless invoice_payload?(payload)
193
+
194
+ invoice = invoice_from(payload).merge(
195
+ "id" => (@next_id += 1),
196
+ "fullnumber" => "FV #{@next_id}/#{Time.now.year}"
197
+ )
198
+ collection_response("invoices", "invoice", invoice)
199
+ end
200
+
201
+ def error_response(payload, error_nodes, code: "ERROR")
202
+ unless invoice_payload?(payload)
203
+ messages = error_nodes.filter_map { |node| node["message"] }
204
+ return status_response(code, message: messages.join("; "))
205
+ end
206
+
207
+ invoice = invoice_from(payload).merge("errors" => wrap_errors(error_nodes))
208
+ collection_response("invoices", "invoice", invoice, code)
209
+ end
210
+
211
+ # Verified against the live API: wFirma reports a rejected buyer under
212
+ # the contractor that failed, not at the top of the document. Mirror
213
+ # that, so the Result parsing exercised in dev is the one production
214
+ # hits - on an invoice the errors nest under invoice.contractor, on a
215
+ # contractors call they sit on the contractor record itself.
216
+ def validation_error_response(payload, error_nodes, code: "ERROR")
217
+ contractor = contractor_from(payload).merge("errors" => wrap_errors(error_nodes))
218
+
219
+ if invoice_payload?(payload)
220
+ invoice = invoice_from(payload).merge("contractor" => contractor)
221
+ collection_response("invoices", "invoice", invoice, code)
222
+ else
223
+ collection_response("contractors", "contractor", contractor, code)
224
+ end
225
+ end
226
+
227
+ def wrap_errors(error_nodes)
228
+ error_nodes.map { |node| { "error" => node } }
229
+ end
230
+
231
+ def collection_response(collection, entity, record, code = "OK")
232
+ { collection => { "0" => { entity => record } },
233
+ "status" => { "code" => code } }
234
+ end
235
+
236
+ # Actions without a record body (invoices/send) answer with the bare
237
+ # status envelope, so there is nothing to echo back.
238
+ def status_response(code, message: nil)
239
+ status = { "code" => code }
240
+ status["message"] = message unless message.to_s.empty?
241
+ { "status" => status }
242
+ end
243
+
244
+ def invoice_payload?(payload)
245
+ payload.is_a?(Hash) && payload.dig("invoices", "invoice").is_a?(Hash)
246
+ end
247
+
248
+ def invoice_from(payload)
249
+ invoice = payload.dig("invoices", "invoice") if payload.is_a?(Hash)
250
+ invoice.is_a?(Hash) ? deep_dup(invoice) : {}
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,111 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Wfirma
6
+ module Drivers
7
+ # Real transport. POSTs JSON to api2.wfirma.pl with API Key auth
8
+ # headers (accessKey / secretKey / appKey) and parses the response.
9
+ class Http
10
+ DEFAULT_BASE_URL = "https://api2.wfirma.pl".freeze
11
+ PDF_MAGIC = "%PDF-".freeze
12
+
13
+ def initialize(access_key:, secret_key:, app_key:, company_id:,
14
+ base_url: DEFAULT_BASE_URL, open_timeout: 5, read_timeout: 30)
15
+ @access_key = access_key
16
+ @secret_key = secret_key
17
+ @app_key = app_key
18
+ @company_id = company_id
19
+ @base_url = base_url
20
+ @open_timeout = open_timeout
21
+ @read_timeout = read_timeout
22
+ end
23
+
24
+ # id appends a record id to the path, as required by the per-record
25
+ # actions (invoices/send/123, invoices/download/123, invoices/get/123).
26
+ def call(module_name, action, payload, params = {}, id = nil)
27
+ uri = build_uri(module_name, action, params, id)
28
+ parse(perform(uri, payload))
29
+ end
30
+
31
+ # Binary actions (invoices/download) answer with the raw file; only
32
+ # failures come back as JSON. Returns the file bytes as a binary String.
33
+ def download(module_name, action, payload, params = {}, id = nil)
34
+ uri = build_uri(module_name, action, params, id)
35
+ response = perform(uri, payload, accept: "application/pdf")
36
+ body = response.body.to_s.dup.force_encoding(Encoding::BINARY)
37
+ return body if body.start_with?(PDF_MAGIC)
38
+
39
+ raise_download_error(body, response)
40
+ end
41
+
42
+ private
43
+
44
+ def build_uri(module_name, action, params, id = nil)
45
+ path = [module_name, action, id].compact.join("/")
46
+ uri = URI.parse("#{@base_url}/#{path}")
47
+ query = {
48
+ "inputFormat" => "json",
49
+ "outputFormat" => "json",
50
+ "company_id" => @company_id
51
+ }.merge(params)
52
+ uri.query = URI.encode_www_form(query)
53
+ uri
54
+ end
55
+
56
+ def perform(uri, payload, accept: "application/json")
57
+ http = Net::HTTP.new(uri.host, uri.port)
58
+ http.use_ssl = uri.scheme == "https"
59
+ http.open_timeout = @open_timeout
60
+ http.read_timeout = @read_timeout
61
+
62
+ request = Net::HTTP::Post.new(uri)
63
+ request["accessKey"] = @access_key
64
+ request["secretKey"] = @secret_key
65
+ request["appKey"] = @app_key
66
+ request["Content-Type"] = "application/json"
67
+ request["Accept"] = accept
68
+ request.body = JSON.generate(payload) if payload
69
+
70
+ http.request(request)
71
+ rescue Timeout::Error, SystemCallError, SocketError, IOError,
72
+ OpenSSL::SSL::SSLError => e
73
+ raise ConnectionError, "wFirma request failed: #{e.class}: #{e.message}"
74
+ end
75
+
76
+ def parse(response)
77
+ body = JSON.parse(response.body)
78
+ unless body.is_a?(Hash)
79
+ raise ConnectionError, "wFirma returned an unexpected JSON shape (HTTP #{response.code})"
80
+ end
81
+
82
+ error = Status.error_for(body.dig("status", "code"), message: body.dig("status", "message"))
83
+ raise error if error
84
+
85
+ body
86
+ rescue JSON::ParserError
87
+ raise ConnectionError, "wFirma returned a non-JSON response (HTTP #{response.code})"
88
+ end
89
+
90
+ def raise_download_error(body, response)
91
+ parsed = JSON.parse(body)
92
+ unless parsed.is_a?(Hash)
93
+ raise ConnectionError, "wFirma returned an unexpected JSON shape (HTTP #{response.code})"
94
+ end
95
+
96
+ result = Result.new(parsed)
97
+ error = Status.error_for(result.status_code, message: result.message, errors: result.errors)
98
+ raise error if error
99
+
100
+ # A record-level code (ERROR, NOT FOUND): the reason is on the record,
101
+ # but there is still no file to hand back.
102
+ raise ApiError.new(
103
+ "wFirma returned no PDF (status #{result.status_code || "?"}): #{result.errors.join("; ")}",
104
+ status_code: result.status_code, errors: result.errors
105
+ )
106
+ rescue JSON::ParserError
107
+ raise ConnectionError, "wFirma returned neither a PDF nor JSON (HTTP #{response.code})"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,42 @@
1
+ module Wfirma
2
+ class Error < StandardError; end
3
+
4
+ # Network failure, timeout, or a response that is not parseable at all.
5
+ class ConnectionError < Error; end
6
+
7
+ # A wFirma status code that aborts the request, and the answer a binary
8
+ # action (invoices/download) gives when it returns a JSON error instead of
9
+ # the file. Record-level outcomes flow into Result instead - see Status.
10
+ class ApiError < Error
11
+ attr_reader :status_code, :errors
12
+
13
+ def initialize(message, status_code: nil, errors: [])
14
+ super(message)
15
+ @status_code = status_code
16
+ @errors = errors
17
+ end
18
+ end
19
+
20
+ # AUTH, AUTH FAILED LIMIT WAIT 5 MINUTES. The keys are missing, wrong, or
21
+ # locked out after too many failed attempts.
22
+ class AuthError < ApiError; end
23
+
24
+ # ACCESS DENIED, DENIED SCOPE REQUESTED. The keys are accepted, but this
25
+ # account - or this OAuth scope - may not perform the action.
26
+ class AccessDeniedError < ApiError; end
27
+
28
+ # ACTION NOT FOUND, COMPANY ID REQUIRED, INPUT ERROR. The request itself is
29
+ # wrong; sending it again unchanged fails the same way.
30
+ class RequestError < ApiError; end
31
+
32
+ # TOTAL REQUESTS LIMIT EXCEEDED, TOTAL EXECUTION TIME LIMIT EXCEEDED. The
33
+ # limits move with wFirma's load, so this says back off, not stop.
34
+ class RateLimitError < ApiError; end
35
+
36
+ # OUT OF SERVICE, SNAPSHOT LOCK. wFirma is temporarily unavailable - an
37
+ # update, or a company restoring from backup - and will be back.
38
+ class ServiceUnavailableError < ApiError; end
39
+
40
+ # FATAL. An internal wFirma error; their docs say it should not happen.
41
+ class ServerError < ApiError; end
42
+ end
@@ -0,0 +1,96 @@
1
+ module Wfirma
2
+ # Invoices resource. Maps a simple attrs hash to wFirma's invoices/add
3
+ # payload format and wraps the response in a Result.
4
+ class Invoices
5
+ KEY_MAP = {
6
+ payment_method: "paymentmethod",
7
+ payment_date: "paymentdate"
8
+ }.freeze
9
+
10
+ # Defaults for the download/send print options; see #pdf and #send_email.
11
+ PDF_OPTIONS = {
12
+ "page" => "invoice", "address" => 0, "leaflet" => 0, "duplicate" => 0
13
+ }.freeze
14
+ SEND_OPTIONS = {
15
+ "page" => "invoice", "leaflet" => 0, "duplicate" => 0
16
+ }.freeze
17
+
18
+ def initialize(client)
19
+ @client = client
20
+ end
21
+
22
+ # attrs: `{contractor: {...}, items: [{...}], payment_method: "transfer", ...}`
23
+ # draft: true creates a "normal_draft" document (no book number, not
24
+ # sent to KSeF) - the safe way to test against the real API.
25
+ def create(attrs, draft: false)
26
+ payload = { "invoices" => { "invoice" => build_invoice(attrs, draft: draft) } }
27
+ Result.new(@client.call("invoices", "add", payload))
28
+ end
29
+
30
+ # PDF printout of an invoice, returned as raw bytes (binary String).
31
+ # Raises Wfirma::ApiError when wFirma answers with an error instead.
32
+ # Options (all optional, PDF_OPTIONS otherwise):
33
+ #
34
+ # page: "invoice" (original), "invoicecopy" (copy), "all" (both)
35
+ # address: 1 prints the buyer's address on the back of the original
36
+ # leaflet: 1 adds a transfer form (transfer payments in PLN only)
37
+ # duplicate: 1 marks the printout as a duplicate
38
+ def pdf(invoice_id, **options)
39
+ payload = parameters_payload(PDF_OPTIONS, options)
40
+ @client.download("invoices", "download", payload, {}, invoice_id)
41
+ end
42
+
43
+ # Asks wFirma to email the invoice PDF to the customer. Options:
44
+ #
45
+ # email: recipient; omitted falls back to the contractor record
46
+ # subject: / body: omitted falls back to wFirma's email template
47
+ # page / leaflet / duplicate: as in #pdf
48
+ def send_email(invoice_id, **options)
49
+ payload = parameters_payload(SEND_OPTIONS, options)
50
+ Result.new(@client.call("invoices", "send", payload, {}, invoice_id))
51
+ end
52
+
53
+ private
54
+
55
+ def build_invoice(attrs, draft:)
56
+ invoice = { "type" => draft ? "normal_draft" : "normal" }
57
+ attrs.each do |key, value|
58
+ case key.to_sym
59
+ when :items
60
+ invoice["invoicecontents"] = build_invoicecontents(value)
61
+ when :contractor
62
+ invoice["contractor"] = stringify_keys(value)
63
+ else
64
+ invoice[KEY_MAP.fetch(key.to_sym, key.to_s)] = value
65
+ end
66
+ end
67
+ invoice
68
+ end
69
+
70
+ # wFirma requires line items as a numeric-string-keyed map:
71
+ # {"0" => {"invoicecontent" => {...}}, "1" => ...}
72
+ def build_invoicecontents(items)
73
+ items.each_with_index.to_h do |item, index|
74
+ [index.to_s, { "invoicecontent" => stringify_keys(item) }]
75
+ end
76
+ end
77
+
78
+ # The download/send actions take their options as repeated <parameter>
79
+ # nodes. Over JSON those must be a numeric-string-keyed map, the same
80
+ # shape as invoicecontents - given a plain array wFirma silently ignores
81
+ # the options and falls back to its defaults. Nil options are dropped so
82
+ # wFirma applies its own default for them.
83
+ def parameters_payload(defaults, options)
84
+ parameters = defaults.merge(stringify_keys(options))
85
+ .reject { |_name, value| value.nil? }
86
+ .each_with_index.to_h do |(name, value), index|
87
+ [index.to_s, { "parameter" => { "name" => name.to_s, "value" => value.to_s } }]
88
+ end
89
+ { "invoices" => { "parameters" => parameters } }
90
+ end
91
+
92
+ def stringify_keys(hash)
93
+ hash.to_h { |key, value| [key.to_s, value] }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,122 @@
1
+ module Wfirma
2
+ # Wraps a parsed wFirma API response. wFirma answers HTTP 200 even for
3
+ # failures - the real outcome lives in status.code, and validation errors
4
+ # are nested inside the returned objects.
5
+ #
6
+ # Responses are shaped <collection>.<index>.<entity>, e.g. invoices.0.invoice
7
+ # or contractors.0.contractor.
8
+ #
9
+ # collection/entity are positional on purpose: `Result.new("status" => …)`
10
+ # with a brace-less hash would otherwise be parsed as keyword arguments.
11
+ class Result
12
+ attr_reader :raw
13
+
14
+ def initialize(raw, collection = "invoices", entity = "invoice")
15
+ @raw = raw || {}
16
+ @collection = collection
17
+ @entity = entity
18
+ end
19
+
20
+ def success?
21
+ status_code == "OK"
22
+ end
23
+
24
+ def status_code
25
+ dig_hash(raw, "status", "code")
26
+ end
27
+
28
+ # The object wFirma returned, from <collection>.<index>.<entity>.
29
+ def record
30
+ entries = raw[@collection]
31
+ return nil unless entries.is_a?(Hash)
32
+
33
+ entries.each do |key, value|
34
+ next unless key.to_s.match?(/\A\d+\z/) && value.is_a?(Hash)
35
+
36
+ found = value[@entity]
37
+ return found if found.is_a?(Hash)
38
+ end
39
+ nil
40
+ end
41
+
42
+ def record_id
43
+ id = record && record["id"]
44
+ id&.to_i
45
+ end
46
+
47
+ alias invoice record
48
+ alias invoice_id record_id
49
+
50
+ # Top-level failure text. Actions that answer without a record body
51
+ # (invoices/send) report the reason here rather than as field errors.
52
+ def message
53
+ dig_hash(raw, "status", "message")
54
+ end
55
+
56
+ def errors
57
+ @errors ||= field_errors.empty? ? Array(message_error) : field_errors
58
+ end
59
+
60
+ private
61
+
62
+ def field_errors
63
+ @field_errors ||= collect_errors(record, [])
64
+ end
65
+
66
+ # wFirma attaches validation errors to whichever object failed, which is
67
+ # often a nested one rather than the record itself: a rejected contractor
68
+ # on an invoice reports under invoice.contractor.errors, a bad line item
69
+ # under its own invoicecontent. Walk the whole record so those are not
70
+ # read as "no errors", and prefix each field with where it was found.
71
+ def collect_errors(node, path)
72
+ case node
73
+ when Array
74
+ node.each_with_index.flat_map { |item, index| collect_errors(item, path + [index.to_s]) }
75
+ when Hash
76
+ node.flat_map do |key, value|
77
+ next error_nodes(value).filter_map { |detail| format_error(detail, path) } if key == "errors"
78
+
79
+ collect_errors(value, path + [key.to_s])
80
+ end
81
+ else
82
+ []
83
+ end
84
+ end
85
+
86
+ def format_error(node, path)
87
+ node_message = node["message"]
88
+ return unless node_message
89
+
90
+ field = (path + [node["field"]]).compact.reject(&:empty?).join(".")
91
+ field.empty? ? node_message.to_s : "#{field}: #{node_message}"
92
+ end
93
+
94
+ def message_error
95
+ message if message.is_a?(String) && !message.empty?
96
+ end
97
+
98
+ # wFirma nests errors in several shapes: an array of {"error" => {...}}
99
+ # nodes, a single {"error" => {...}} hash, or a numeric-keyed hash of
100
+ # them. Normalize all of these to a flat array of error-detail hashes.
101
+ def error_nodes(container)
102
+ case container
103
+ when Array
104
+ container.flat_map { |item| error_nodes(item) }
105
+ when Hash
106
+ if container.key?("error")
107
+ error_nodes(container["error"])
108
+ elsif container.keys.all? { |key| key.to_s.match?(/\A\d+\z/) }
109
+ container.values.flat_map { |item| error_nodes(item) }
110
+ else
111
+ [container]
112
+ end
113
+ else
114
+ []
115
+ end
116
+ end
117
+
118
+ def dig_hash(hash, *keys)
119
+ hash.is_a?(Hash) ? hash.dig(*keys) : nil
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,60 @@
1
+ module Wfirma
2
+ # wFirma answers HTTP 200 for everything, so the top-level status code is
3
+ # the real outcome. This is the whole documented set (doc.wfirma.pl,
4
+ # "Komunikaty błędów"), split by what the caller can do about it.
5
+ #
6
+ # A code that describes the *record* reaches the caller as a Result. A code
7
+ # that aborts the *request* is raised: there is no record to report on, so a
8
+ # Result would carry an empty error list and the reason would be lost.
9
+ module Status
10
+ OK = "OK".freeze
11
+
12
+ # NOT FOUND is grouped with the aborting codes in wFirma's docs, but it
13
+ # answers a record the caller named - an edit of an id that is gone, a
14
+ # lookup that matched nothing - so it is an outcome, not a transport
15
+ # failure, and it stays a Result.
16
+ RESULT_CODES = [OK, "ERROR", "NOT FOUND"].freeze
17
+
18
+ ABORTING = {
19
+ "AUTH" => [AuthError, "authentication failed - check accessKey/secretKey/appKey"],
20
+ "AUTH FAILED LIMIT WAIT 5 MINUTES" =>
21
+ [AuthError, "too many failed authentication attempts - wait 5 minutes"],
22
+ "ACCESS DENIED" => [AccessDeniedError, "this account may not perform that action"],
23
+ "DENIED SCOPE REQUESTED" =>
24
+ [AccessDeniedError, "the OAuth authorization does not cover that scope"],
25
+ "ACTION NOT FOUND" => [RequestError, "no such action - check the module and action names"],
26
+ "COMPANY ID REQUIRED" =>
27
+ [RequestError, "the account holds several companies - pass company_id"],
28
+ "INPUT ERROR" => [RequestError, "wFirma could not read the request body"],
29
+ "TOTAL REQUESTS LIMIT EXCEEDED" => [RateLimitError, "request limit exceeded"],
30
+ "TOTAL EXECUTION TIME LIMIT EXCEEDED" => [RateLimitError, "execution time limit exceeded"],
31
+ "OUT OF SERVICE" => [ServiceUnavailableError, "the API is temporarily out of service"],
32
+ "SNAPSHOT LOCK" => [ServiceUnavailableError, "the company is being restored from a backup"],
33
+ "FATAL" => [ServerError, "internal wFirma error"]
34
+ }.freeze
35
+
36
+ UNKNOWN = [ApiError, "unrecognised status code"].freeze
37
+
38
+ module_function
39
+
40
+ # The exception for this status code, or nil when the code belongs in a
41
+ # Result. An unrecognised code is raised rather than passed through: this
42
+ # exists so a failure is never silent, and wFirma may add codes.
43
+ def error_for(code, message: nil, errors: [])
44
+ code = code.to_s
45
+ return nil if RESULT_CODES.include?(code)
46
+
47
+ error_class, explanation = ABORTING.fetch(code, UNKNOWN)
48
+ error_class.new(
49
+ message_for(code, explanation, message, errors),
50
+ status_code: code, errors: errors
51
+ )
52
+ end
53
+
54
+ def message_for(code, explanation, message, errors)
55
+ detail = message.to_s.empty? ? Array(errors).join("; ") : message.to_s
56
+ text = "wFirma: #{explanation} (status #{code.empty? ? "missing" : code})"
57
+ detail.empty? ? text : "#{text}: #{detail}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module Wfirma
2
+ VERSION = "0.1.0".freeze
3
+ end