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
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
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
|