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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 930534cc573b3a0c653e4e9a71c670428f56cb5679855cc6ddc57a07f79bf58b
4
+ data.tar.gz: 45467f884fffa3503d36e57b5f64ace096ce9ad758207472073cd73ac7b0562f
5
+ SHA512:
6
+ metadata.gz: 935e0000e190aabc935cb93a1c040a5e92e45dc2ca2ffed592ce662f890f763b3818679aec100a9b6707d041a2d50f8c639fafb19abd030774f8072123a9d993
7
+ data.tar.gz: 27f6d9046c03b26bcbcce29b3f6cb0228b991da58c3528c4f207f44f3404e4d56bc39bb6f4e656c0147dd0517f59d22dd4b921957ffd37b748a87501e321bd17
data/.yardopts ADDED
@@ -0,0 +1,8 @@
1
+ --title "wFirma API client for Ruby"
2
+ --markup markdown
3
+ --readme README.md
4
+ --no-private
5
+ lib/**/*.rb
6
+ -
7
+ CHANGELOG.md
8
+ LICENSE.txt
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-08-12
9
+
10
+ First release as a standalone gem. The library previously lived inside the
11
+ iaml-api-user application as `lib/wfirma`; the entries below are relative to
12
+ that version, for the one consumer upgrading from it.
13
+
14
+ ### Added
15
+
16
+ - `Wfirma::Client` with `contractors` and `invoices` resources: resolve a
17
+ customer in the contractor catalogue, issue a VAT invoice, fetch its PDF,
18
+ and have wFirma email it.
19
+ - `Wfirma::Drivers::Http` (real transport) and `Wfirma::Drivers::Fake`
20
+ (in-memory, no HTTP) drivers.
21
+ - `Wfirma::Result` for wFirma's HTTP-200-on-failure responses.
22
+ - `Wfirma::Status`, covering wFirma's full documented set of top-level status
23
+ codes, and an exception per class of failure: `AccessDeniedError`,
24
+ `RequestError`, `RateLimitError`, `ServiceUnavailableError`, `ServerError`.
25
+ - `Wfirma.configure`, supplying defaults for `Client.new`. They are defaults
26
+ for building a client, not a singleton the resources read, so several
27
+ companies can be invoiced in one process and `Client.new(driver:)` still
28
+ reads no global state.
29
+ - CI across every supported Ruby (3.3, 3.4, 4.0).
30
+
31
+ ### Changed
32
+
33
+ - Status codes that abort the request are now raised instead of arriving as a
34
+ `Result` with an empty `errors` list. Previously only `AUTH` raised, so a
35
+ rate-limit block, an outage or a missing `company_id` all surfaced as
36
+ "it failed" with no reason attached. `OK`, `ERROR` and `NOT FOUND` still
37
+ answer with a `Result`; an unrecognised code now raises `ApiError`.
38
+ - `AuthError` is now a subclass of `ApiError` rather than of `Error` directly,
39
+ so `rescue Wfirma::ApiError` covers every wFirma-reported failure.
40
+ - `Drivers::Fake` raises the same class the real driver does for any armed
41
+ status code, so failure paths can be exercised offline.
42
+ - `Client.new` with no keys configured or passed now raises `ArgumentError`
43
+ instead of building a client whose every request failed on authentication.
44
+ - Minimum Ruby is 3.3: 3.2 reached end of life on 2026-03-31.
45
+
46
+ [Unreleased]: https://github.com/trusive/wfirma/compare/v0.1.0...HEAD
47
+ [0.1.0]: https://github.com/trusive/wfirma/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Radosław Woźniak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # Wfirma
2
+
3
+ Creating VAT invoices in wFirma: resolve the customer in the contractor
4
+ catalogue, issue the invoice, fetch its PDF, have wFirma email it.
5
+
6
+ Pure Ruby (stdlib only) — no Rails, no ActiveSupport, no runtime dependencies.
7
+
8
+ API reference: [doc.wfirma.pl](https://doc.wfirma.pl/).
9
+
10
+ ## Installation
11
+
12
+ ```ruby
13
+ gem "wfirma"
14
+ ```
15
+
16
+ or `gem install wfirma`.
17
+
18
+ ## Building a client
19
+
20
+ Keys come from the wFirma panel: *Ustawienia → Aplikacje → API*.
21
+
22
+ ```ruby
23
+ require "wfirma"
24
+
25
+ # Production
26
+ client = Wfirma::Client.new(
27
+ access_key: ENV.fetch("WFIRMA_ACCESS_KEY"),
28
+ secret_key: ENV.fetch("WFIRMA_SECRET_KEY"),
29
+ app_key: ENV.fetch("WFIRMA_APP_KEY"),
30
+ company_id: ENV.fetch("WFIRMA_COMPANY_ID")
31
+ )
32
+
33
+ # Development / tests — no HTTP, no keys
34
+ client = Wfirma::Client.new(driver: Wfirma::Drivers::Fake.new)
35
+ ```
36
+
37
+ To name the keys once instead of at every call site, set them globally — in
38
+ an initializer, say — and build clients with no arguments:
39
+
40
+ ```ruby
41
+ Wfirma.configure do |config|
42
+ config.access_key = ENV.fetch("WFIRMA_ACCESS_KEY")
43
+ config.secret_key = ENV.fetch("WFIRMA_SECRET_KEY")
44
+ config.app_key = ENV.fetch("WFIRMA_APP_KEY")
45
+ config.company_id = ENV.fetch("WFIRMA_COMPANY_ID")
46
+ end
47
+
48
+ Wfirma::Client.new # takes all four
49
+ Wfirma::Client.new(company_id: 456) # overrides one, inherits the rest
50
+ ```
51
+
52
+ `base_url`, `open_timeout` and `read_timeout` can be configured the same way;
53
+ left unset they keep the driver's defaults.
54
+
55
+ These are defaults for *building* a client, not a singleton the resources read
56
+ on the way out — there is deliberately no `Wfirma.invoices.create`. One wFirma
57
+ account can hold several companies (that is what the `COMPANY ID REQUIRED`
58
+ status code is about), so which company a document lands in stays visible at
59
+ the call site. A second company is a second client, not a global to reassign.
60
+
61
+ `Wfirma::Client.new(driver:)` reads no configuration at all, so tests on the
62
+ Fake driver need no global state. If a test does set some, `Wfirma.reset_config!`
63
+ puts it back.
64
+
65
+ ## Draft invoices
66
+
67
+ `draft: true` issues a `normal_draft` document: no book number, not sent to
68
+ KSeF. Everything below uses it. Drop the flag to issue a real VAT invoice.
69
+
70
+ Note the braces around the invoice attributes: `create` takes them as one
71
+ positional hash followed by `draft:`, so an unbraced hash would be read as
72
+ keyword arguments. `upsert` takes the customer as a bare hash.
73
+
74
+ ## Company with a NIP
75
+
76
+ ```ruby
77
+ customer = client.contractors.upsert(
78
+ name: "ACME Sp. z o.o.",
79
+ nip: "1234563218",
80
+ tax_id_type: "nip",
81
+ street: "Prosta 1",
82
+ zip: "00-001", # Polish codes must be XX-XXX or wFirma refuses
83
+ city: "Warszawa",
84
+ country: "PL",
85
+ email: "faktury@acme.pl"
86
+ )
87
+
88
+ unless customer.success?
89
+ # e.g. ["zip: Niepoprawny format kodu pocztowego."]
90
+ return handle_failure(customer.errors)
91
+ end
92
+
93
+ invoice = client.invoices.create(
94
+ {
95
+ contractor_id: customer.record_id,
96
+ payment_method: "transfer", # cash / transfer / compensation / cod / payment_card
97
+ payment_date: "2026-08-24", # payment due date
98
+ items: [
99
+ { name: "Pakiet AML Premium", count: 1, price: "499.00", vat: 23, unit: "szt." }
100
+ ]
101
+ },
102
+ draft: true
103
+ )
104
+
105
+ return handle_failure(invoice.errors) unless invoice.success?
106
+
107
+ invoice.invoice_id # => 588425015
108
+ invoice.invoice["fullnumber"] # => "WRF 6"
109
+ ```
110
+
111
+ The second time this customer buys, `upsert` finds them by NIP and reuses the
112
+ same contractor record instead of creating another. If any field you pass has
113
+ changed, that field is written back to the record; fields you do not pass are
114
+ left alone.
115
+
116
+ ## Person without a NIP
117
+
118
+ Same call, with `tax_id_type: "none"` and no `nip`. Returning consumers are
119
+ recognised by **email**, so pass one — without it every purchase creates a new
120
+ contractor.
121
+
122
+ ```ruby
123
+ customer = client.contractors.upsert(
124
+ name: "Jan Kowalski",
125
+ tax_id_type: "none",
126
+ email: "jan@example.com", # how we recognise them next time
127
+ street: "Prosta 1",
128
+ zip: "00-001",
129
+ city: "Warszawa",
130
+ country: "PL"
131
+ )
132
+
133
+ return handle_failure(customer.errors) unless customer.success?
134
+
135
+ invoice = client.invoices.create(
136
+ {
137
+ contractor_id: customer.record_id,
138
+ payment_method: "transfer",
139
+ items: [
140
+ { name: "Pakiet AML Standard", count: 1, price: "199.00", vat: 23, unit: "szt." }
141
+ ]
142
+ },
143
+ draft: true
144
+ )
145
+ ```
146
+
147
+ A consumer who gives a **PESEL** needs no special handling — wFirma keeps it in
148
+ the same `nip` field, so they are matched like a company:
149
+
150
+ ```ruby
151
+ client.contractors.upsert(
152
+ name: "Jan Kowalski", tax_id_type: "pesel", nip: "44051401359",
153
+ street: "Prosta 1", zip: "00-001", city: "Warszawa", country: "PL"
154
+ )
155
+ ```
156
+
157
+ An email match only ever adopts a record that has no tax id, so someone buying
158
+ privately from the same address as their company will not overwrite the
159
+ company's record.
160
+
161
+ ## PDF and sending
162
+
163
+ ```ruby
164
+ pdf = client.invoices.pdf(invoice.invoice_id) # binary String, raises on failure
165
+ File.binwrite("faktura.pdf", pdf)
166
+
167
+ # wFirma emails the PDF itself. Omit email: to use the address on the
168
+ # contractor record, subject:/body: to use wFirma's template.
169
+ sent = client.invoices.send_email(invoice.invoice_id, email: "jan@example.com")
170
+ return handle_failure(sent.errors) unless sent.success?
171
+ ```
172
+
173
+ Print options on both: `page:` (`"invoice"` original, `"invoicecopy"` copy,
174
+ `"all"` both), `duplicate:`, `leaflet:`, and `address:` on `pdf`.
175
+
176
+ ## Results and errors
177
+
178
+ Every write returns a `Wfirma::Result`. wFirma answers HTTP 200 even for
179
+ failures, so **always check `success?`** — the real outcome is in the status
180
+ code, not the transport.
181
+
182
+ ```ruby
183
+ result.success? # status.code == "OK"
184
+ result.record_id # the created/updated object's id (Integer), or nil
185
+ result.record # the object itself; #invoice / #invoice_id read the same
186
+ result.errors # ["contractor.nip: …", "invoicecontents.0.invoicecontent.price: …"]
187
+ result.status_code # "OK", "ERROR", "NOT FOUND", …
188
+ result.raw # the full parsed response
189
+ ```
190
+
191
+ `errors` reports each failure qualified by where wFirma attached it, including
192
+ errors nested in the contractor or in a single line item.
193
+
194
+ Only three status codes reach you as a `Result`: `OK`, `ERROR` (validation
195
+ errors on the object) and `NOT FOUND` (a record you named that is not there).
196
+ Every other documented code aborts the request — there is no record for it to
197
+ report on — so it is **raised** rather than folded into a `Result` with an
198
+ empty `errors` list.
199
+
200
+ | Exception | wFirma status code | What to do |
201
+ |---|---|---|
202
+ | `Wfirma::ConnectionError` | — network failure, timeout, unparseable response | retry |
203
+ | `Wfirma::AuthError` | `AUTH`, `AUTH FAILED LIMIT WAIT 5 MINUTES` | fix the keys; the second is a 5-minute lockout |
204
+ | `Wfirma::AccessDeniedError` | `ACCESS DENIED`, `DENIED SCOPE REQUESTED` | the account or OAuth scope may not do this |
205
+ | `Wfirma::RequestError` | `ACTION NOT FOUND`, `COMPANY ID REQUIRED`, `INPUT ERROR` | the request is wrong; retrying it will not help |
206
+ | `Wfirma::RateLimitError` | `TOTAL REQUESTS LIMIT EXCEEDED`, `TOTAL EXECUTION TIME LIMIT EXCEEDED` | back off and retry later |
207
+ | `Wfirma::ServiceUnavailableError` | `OUT OF SERVICE`, `SNAPSHOT LOCK` | wFirma is down or restoring; retry later |
208
+ | `Wfirma::ServerError` | `FATAL` | wFirma's bug; report it |
209
+ | `Wfirma::ApiError` | any code not listed above | base class of all of these |
210
+
211
+ All of them are `Wfirma::ApiError` and carry `status_code` and `errors`, so
212
+ `rescue Wfirma::ApiError` catches the lot; `Wfirma::Error` additionally covers
213
+ `ConnectionError`. `pdf` also raises `ApiError` when it gets a JSON error
214
+ instead of a file. An unrecognised code raises `ApiError` rather than passing
215
+ for a soft failure — wFirma may add codes, and a silent one is worse than a
216
+ loud one.
217
+
218
+ wFirma's limits move with their server load, and their docs recommend batching
219
+ work overnight and avoiding bursts. This library does not retry for you.
220
+
221
+ ## What the library deliberately does not do
222
+
223
+ - **No postal-code fixing.** Validate the address before this point; a
224
+ malformed Polish code is reported as a field error on the customer, before
225
+ any invoice exists.
226
+ - **No VIES handling.** wFirma checks EU VAT ids against VIES live and rejects
227
+ inactive ones; the rejection is passed straight through.
228
+ - **No defaults for `tax_id_type` or `country`.** Pass them explicitly.
229
+ - **No retries or backoff.** `RateLimitError` and `ServiceUnavailableError`
230
+ tell you when to back off; the scheduling is yours.
231
+ - **API Key authorization only.** wFirma also documents OAuth 1.0a and OAuth
232
+ 2.0, which reach further than API Keys do. Neither is implemented here.
233
+
234
+ ## Development mode
235
+
236
+ `Drivers::Fake` runs the whole library — payload mapping, envelopes, `Result`
237
+ parsing — with no HTTP. It keeps an in-memory contractor catalogue, so
238
+ find → add/edit behaves as it does against the real CRM.
239
+
240
+ ```ruby
241
+ fake = Wfirma::Drivers::Fake.new
242
+ client = Wfirma::Client.new(driver: fake)
243
+
244
+ fake.requests # every call made, in order
245
+ fake.reset! # clear recorded calls, stored contractors, failure mode
246
+ ```
247
+
248
+ Failure scenarios, drivable from the UI:
249
+
250
+ | Contractor NIP | Result |
251
+ |---|---|
252
+ | `0000000000` | validation error on the contractor |
253
+ | `0000000001` | raises `Wfirma::AuthError` |
254
+ | `0000000002` | raises `Wfirma::ConnectionError` |
255
+
256
+ A Polish `zip` that is not `XX-XXX` is rejected exactly as wFirma rejects it,
257
+ so that path can be exercised offline. All-zeros NIPs are checksum-invalid, so
258
+ no real customer can trigger these by accident.
259
+
260
+ ```ruby
261
+ fake.fail_next!(code: "ERROR", errors: ["contractor.name: nie może być puste"])
262
+ fake.fail_always!(code: "AUTH")
263
+ ```
264
+
265
+ Any documented status code can be armed, and the Fake raises exactly what the
266
+ real driver raises for it — so a rate-limit or outage path can be exercised
267
+ offline:
268
+
269
+ ```ruby
270
+ fake.fail_next!(code: "TOTAL REQUESTS LIMIT EXCEEDED") # => Wfirma::RateLimitError
271
+ fake.fail_next!(code: "OUT OF SERVICE") # => Wfirma::ServiceUnavailableError
272
+ ```
273
+
274
+ ## Development
275
+
276
+ ```sh
277
+ bin/setup # bundle install
278
+ bundle exec rake # tests + rubocop
279
+ bundle exec yard server -r # preview the API docs at localhost:8808
280
+ ```
281
+
282
+ Minitest, and the suite runs entirely offline: the Fake driver covers the
283
+ resource layer, webmock covers `Drivers::Http`.
284
+
285
+ ## License
286
+
287
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,54 @@
1
+ module Wfirma
2
+ # Public entry point.
3
+ #
4
+ # # Production:
5
+ # client = Wfirma::Client.new(access_key: "...", secret_key: "...",
6
+ # app_key: "...", company_id: 123)
7
+ #
8
+ # # Or, with Wfirma.configure having named them once:
9
+ # client = Wfirma::Client.new
10
+ # client = Wfirma::Client.new(company_id: 456) # a second company
11
+ #
12
+ # # Development (no HTTP, no keys):
13
+ # client = Wfirma::Client.new(driver: Wfirma::Drivers::Fake.new)
14
+ #
15
+ # contractor = client.contractors.upsert(name: "ACME", nip: "…", …)
16
+ # result = client.invoices.create({ contractor_id: contractor.record_id, … },
17
+ # draft: true)
18
+ # pdf = client.invoices.pdf(result.invoice_id)
19
+ # client.invoices.send_email(result.invoice_id, email: "buyer@example.com")
20
+ class Client
21
+ attr_reader :driver
22
+
23
+ # Anything passed here wins over Wfirma.configure; anything left out falls
24
+ # back to it, and then to the driver's own defaults. An injected driver
25
+ # is used as-is and reads no configuration at all.
26
+ def initialize(access_key: nil, secret_key: nil, app_key: nil, company_id: nil,
27
+ base_url: nil, open_timeout: nil, read_timeout: nil, driver: nil)
28
+ given = {
29
+ access_key: access_key, secret_key: secret_key, app_key: app_key,
30
+ company_id: company_id, base_url: base_url,
31
+ open_timeout: open_timeout, read_timeout: read_timeout
32
+ }.compact
33
+
34
+ @driver = driver || Drivers::Http.new(**Wfirma.config.to_h.merge(given))
35
+ end
36
+
37
+ def call(module_name, action, payload, params = {}, id = nil)
38
+ driver.call(module_name, action, payload, params, id)
39
+ end
40
+
41
+ # Binary actions (invoices/download); returns raw file bytes.
42
+ def download(module_name, action, payload, params = {}, id = nil)
43
+ driver.download(module_name, action, payload, params, id)
44
+ end
45
+
46
+ def invoices
47
+ @invoices ||= Invoices.new(self)
48
+ end
49
+
50
+ def contractors
51
+ @contractors ||= Contractors.new(self)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ module Wfirma
2
+ # Application-wide defaults for Wfirma::Client.new, so the keys are named
3
+ # once - in an application's initializer, say - not at every call site.
4
+ #
5
+ # Wfirma.configure do |config|
6
+ # config.access_key = ENV.fetch("WFIRMA_ACCESS_KEY")
7
+ # config.secret_key = ENV.fetch("WFIRMA_SECRET_KEY")
8
+ # config.app_key = ENV.fetch("WFIRMA_APP_KEY")
9
+ # config.company_id = ENV.fetch("WFIRMA_COMPANY_ID")
10
+ # end
11
+ #
12
+ # Wfirma::Client.new # takes all four
13
+ # Wfirma::Client.new(company_id: 456) # overrides one, inherits the rest
14
+ #
15
+ # These are defaults for building a client, not a singleton the resources
16
+ # read on the way out. Every request still goes through a Client someone
17
+ # built, so a second company is another Client rather than a global to
18
+ # mutate mid-flight, and Client.new(driver:) reads none of this at all.
19
+ #
20
+ # A setting left unset means "whatever Drivers::Http defaults to", so the
21
+ # defaults for base_url and the timeouts live in one place only.
22
+ class Configuration
23
+ SETTINGS = %i[
24
+ access_key secret_key app_key company_id base_url open_timeout read_timeout
25
+ ].freeze
26
+
27
+ attr_accessor(*SETTINGS)
28
+
29
+ # Only the settings actually given, so unset ones fall through to the
30
+ # driver rather than overriding it with nil.
31
+ def to_h
32
+ SETTINGS.to_h { |name| [name, public_send(name)] }.compact
33
+ end
34
+ end
35
+
36
+ class << self
37
+ def config
38
+ @config ||= Configuration.new
39
+ end
40
+
41
+ def configure
42
+ yield config
43
+ config
44
+ end
45
+
46
+ # Mainly for tests: global defaults are the one piece of state here that
47
+ # leaks between examples.
48
+ def reset_config!
49
+ @config = nil
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,132 @@
1
+ module Wfirma
2
+ # Contractors (CRM) resource.
3
+ #
4
+ # An invoice can carry the buyer inline, but that data never reaches the
5
+ # contractor catalogue - each invoice only gets its own contractor_detail
6
+ # snapshot (verified against the live API). Resolving the customer through
7
+ # this resource and passing the resulting id as `contractor_id:` is what
8
+ # puts them in the wFirma contractor list, with one record reused across
9
+ # their invoices.
10
+ #
11
+ # contractor = client.contractors.upsert(name: "ACME Sp. z o.o.", nip: "…", …)
12
+ # raise contractor.errors.join(", ") unless contractor.success?
13
+ #
14
+ # client.invoices.create(contractor_id: contractor.record_id, items: [...])
15
+ class Contractors
16
+ def initialize(client)
17
+ @client = client
18
+ end
19
+
20
+ # contractors/find narrowed by a single equality condition, which is all a
21
+ # tax-id lookup needs. Returns the matching contractor hashes (possibly
22
+ # empty); a failed query returns [] as well, so check the tax id you pass.
23
+ def find_by(field, value)
24
+ payload = {
25
+ "contractors" => {
26
+ "parameters" => {
27
+ "conditions" => {
28
+ "condition" => { "field" => field.to_s, "operator" => "eq", "value" => value.to_s }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ records(@client.call("contractors", "find", payload))
34
+ end
35
+
36
+ def find_by_nip(nip)
37
+ find_by("nip", nip)
38
+ end
39
+
40
+ def create(attrs)
41
+ call("add", stringify_keys(attrs))
42
+ end
43
+
44
+ def update(contractor_id, attrs)
45
+ call("edit", stringify_keys(attrs), contractor_id)
46
+ end
47
+
48
+ # Reuses the catalogue record carrying this tax id, overwriting any of the
49
+ # given fields whose value differs, and creates one when there is none.
50
+ # Fields not passed are left alone, so edits made in the wFirma panel
51
+ # survive. Returns a Result either way - check success? before using
52
+ # record_id, since wFirma validates on write (postal code format, VIES).
53
+ #
54
+ # By default it recognises a returning customer by tax id, falling back to
55
+ # email for consumers who have none - see #recognise. Pass match_on to
56
+ # match on exactly one field instead. wFirma does not treat any of these
57
+ # as unique, so the first match wins; with nothing to match on, every call
58
+ # creates a new contractor.
59
+ #
60
+ # match_on is positional so that the customer can be passed as a bare hash
61
+ # - `upsert(name: "…", nip: "…")` would otherwise be read as keywords.
62
+ def upsert(attrs, match_on = nil)
63
+ attrs = stringify_keys(attrs)
64
+ existing = match_on ? match(attrs, match_on.to_s) : recognise(attrs)
65
+ return create(attrs) unless existing
66
+
67
+ changed = attrs.reject { |field, value| existing[field].to_s == value.to_s }
68
+ return unchanged_result(existing) if changed.empty?
69
+
70
+ update(existing["id"], changed)
71
+ end
72
+
73
+ private
74
+
75
+ # The tax id is authoritative. wFirma keeps a PESEL in that same `nip`
76
+ # field - `tax_id_type` says which kind of identifier it is - so this
77
+ # covers consumers who give one.
78
+ #
79
+ # A consumer with tax_id_type "none" has nothing there, so fall back to
80
+ # their email. That only ever adopts another record without a tax id: one
81
+ # person may well buy both privately and for their company off the same
82
+ # address, and a company record must not be overwritten with personal
83
+ # details. A consumer who changes email reads as a new customer, which is
84
+ # the safe direction to be wrong in.
85
+ def recognise(attrs)
86
+ match(attrs, "nip") || consumer_match(attrs)
87
+ end
88
+
89
+ def match(attrs, field)
90
+ value = attrs[field]
91
+ return nil if value.to_s.empty?
92
+
93
+ find_by(field, value).first
94
+ end
95
+
96
+ def consumer_match(attrs)
97
+ email = attrs["email"]
98
+ return nil if email.to_s.empty?
99
+
100
+ find_by("email", email).find { |contractor| contractor["nip"].to_s.empty? }
101
+ end
102
+
103
+ def call(action, contractor, id = nil)
104
+ payload = { "contractors" => { "contractor" => contractor } }
105
+ result(@client.call("contractors", action, payload, {}, id))
106
+ end
107
+
108
+ # Nothing to write, so hand back the record we already have in the shape
109
+ # wFirma would have answered with.
110
+ def unchanged_result(contractor)
111
+ result("contractors" => { "0" => { "contractor" => contractor } },
112
+ "status" => { "code" => "OK" })
113
+ end
114
+
115
+ def result(raw)
116
+ Result.new(raw, "contractors", "contractor")
117
+ end
118
+
119
+ def records(response)
120
+ entries = response["contractors"]
121
+ return [] unless entries.is_a?(Hash)
122
+
123
+ entries.filter_map do |key, value|
124
+ value["contractor"] if key.to_s.match?(/\A\d+\z/) && value.is_a?(Hash)
125
+ end
126
+ end
127
+
128
+ def stringify_keys(hash)
129
+ hash.to_h { |key, value| [key.to_s, value] }
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,22 @@
1
+ module Wfirma
2
+ # Payloads are plain JSON-safe hashes/arrays/scalars, so a simple recursive
3
+ # dup is enough to give responses and stored records an object graph fully
4
+ # independent from the caller's - mirroring the isolation a real HTTP
5
+ # round-trip (serialize/deserialize) would give.
6
+ module DeepDup
7
+ module_function
8
+
9
+ def deep_dup(value)
10
+ case value
11
+ when Hash
12
+ value.each_with_object({}) { |(key, val), copy| copy[deep_dup(key)] = deep_dup(val) }
13
+ when Array
14
+ value.map { |item| deep_dup(item) }
15
+ when String
16
+ value.dup
17
+ else
18
+ value
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ module Wfirma
2
+ module Drivers
3
+ class Fake
4
+ # In-memory stand-in for wFirma's contractor catalogue, so the
5
+ # find -> add/edit flow behaves in dev the way it does against the real
6
+ # CRM: the same customer resolves to one record across invoices.
7
+ class Catalogue
8
+ include DeepDup
9
+
10
+ def initialize
11
+ @records = {}
12
+ @next_id = 0
13
+ end
14
+
15
+ # Answers the single equality condition Contractors#find_by sends,
16
+ # in wFirma's numeric-string-keyed shape.
17
+ def find(condition)
18
+ field = condition["field"].to_s
19
+ value = condition["value"].to_s
20
+ matches = @records.values.select { |record| record[field].to_s == value }
21
+ matches.each_with_index.to_h do |record, index|
22
+ [index.to_s, { "contractor" => deep_dup(record) }]
23
+ end
24
+ end
25
+
26
+ def add(attrs)
27
+ id = (@next_id += 1)
28
+ @records[id] = deep_dup(attrs).merge("id" => id.to_s)
29
+ deep_dup(@records[id])
30
+ end
31
+
32
+ # Merges in only the fields given, the way contractors/edit does.
33
+ # Returns nil when there is no such record.
34
+ def edit(id, attrs)
35
+ record = @records[id.to_i]
36
+ return nil unless record
37
+
38
+ record.merge!(deep_dup(attrs))
39
+ deep_dup(record)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end