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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +287 -0
- data/lib/wfirma/client.rb +54 -0
- data/lib/wfirma/configuration.rb +52 -0
- data/lib/wfirma/contractors.rb +132 -0
- data/lib/wfirma/deep_dup.rb +22 -0
- data/lib/wfirma/drivers/fake/catalogue.rb +44 -0
- data/lib/wfirma/drivers/fake.rb +254 -0
- data/lib/wfirma/drivers/http.rb +111 -0
- data/lib/wfirma/errors.rb +42 -0
- data/lib/wfirma/invoices.rb +96 -0
- data/lib/wfirma/result.rb +122 -0
- data/lib/wfirma/status.rb +60 -0
- data/lib/wfirma/version.rb +3 -0
- data/lib/wfirma.rb +15 -0
- metadata +64 -0
|
@@ -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
|