Wfirma
Creating VAT invoices in wFirma: resolve the customer in the contractor catalogue, issue the invoice, fetch its PDF, have wFirma email it.
Pure Ruby (stdlib only) — no Rails, no ActiveSupport, no runtime dependencies.
API reference: doc.wfirma.pl.
Installation
gem "wfirma"
or gem install wfirma.
Building a client
Keys come from the wFirma panel: Ustawienia → Aplikacje → API.
require "wfirma"
# Production
client = Wfirma::Client.new(
access_key: ENV.fetch("WFIRMA_ACCESS_KEY"),
secret_key: ENV.fetch("WFIRMA_SECRET_KEY"),
app_key: ENV.fetch("WFIRMA_APP_KEY"),
company_id: ENV.fetch("WFIRMA_COMPANY_ID")
)
# Development / tests — no HTTP, no keys
client = Wfirma::Client.new(driver: Wfirma::Drivers::Fake.new)
To name the keys once instead of at every call site, set them globally — in an initializer, say — and build clients with no arguments:
Wfirma.configure do |config|
config.access_key = ENV.fetch("WFIRMA_ACCESS_KEY")
config.secret_key = ENV.fetch("WFIRMA_SECRET_KEY")
config.app_key = ENV.fetch("WFIRMA_APP_KEY")
config.company_id = ENV.fetch("WFIRMA_COMPANY_ID")
end
Wfirma::Client.new # takes all four
Wfirma::Client.new(company_id: 456) # overrides one, inherits the rest
base_url, open_timeout and read_timeout can be configured the same way;
left unset they keep the driver's defaults.
These are defaults for building a client, not a singleton the resources read
on the way out — there is deliberately no Wfirma.invoices.create. One wFirma
account can hold several companies (that is what the COMPANY ID REQUIRED
status code is about), so which company a document lands in stays visible at
the call site. A second company is a second client, not a global to reassign.
Wfirma::Client.new(driver:) reads no configuration at all, so tests on the
Fake driver need no global state. If a test does set some, Wfirma.reset_config!
puts it back.
Draft invoices
draft: true issues a normal_draft document: no book number, not sent to
KSeF. Everything below uses it. Drop the flag to issue a real VAT invoice.
Note the braces around the invoice attributes: create takes them as one
positional hash followed by draft:, so an unbraced hash would be read as
keyword arguments. upsert takes the customer as a bare hash.
Company with a NIP
customer = client.contractors.upsert(
name: "ACME Sp. z o.o.",
nip: "1234563218",
tax_id_type: "nip",
street: "Prosta 1",
zip: "00-001", # Polish codes must be XX-XXX or wFirma refuses
city: "Warszawa",
country: "PL",
email: "faktury@acme.pl"
)
unless customer.success?
# e.g. ["zip: Niepoprawny format kodu pocztowego."]
return handle_failure(customer.errors)
end
invoice = client.invoices.create(
{
contractor_id: customer.record_id,
payment_method: "transfer", # cash / transfer / compensation / cod / payment_card
payment_date: "2026-08-24", # payment due date
items: [
{ name: "Pakiet AML Premium", count: 1, price: "499.00", vat: 23, unit: "szt." }
]
},
draft: true
)
return handle_failure(invoice.errors) unless invoice.success?
invoice.invoice_id # => 588425015
invoice.invoice["fullnumber"] # => "WRF 6"
The second time this customer buys, upsert finds them by NIP and reuses the
same contractor record instead of creating another. If any field you pass has
changed, that field is written back to the record; fields you do not pass are
left alone.
Person without a NIP
Same call, with tax_id_type: "none" and no nip. Returning consumers are
recognised by email, so pass one — without it every purchase creates a new
contractor.
customer = client.contractors.upsert(
name: "Jan Kowalski",
tax_id_type: "none",
email: "jan@example.com", # how we recognise them next time
street: "Prosta 1",
zip: "00-001",
city: "Warszawa",
country: "PL"
)
return handle_failure(customer.errors) unless customer.success?
invoice = client.invoices.create(
{
contractor_id: customer.record_id,
payment_method: "transfer",
items: [
{ name: "Pakiet AML Standard", count: 1, price: "199.00", vat: 23, unit: "szt." }
]
},
draft: true
)
A consumer who gives a PESEL needs no special handling — wFirma keeps it in
the same nip field, so they are matched like a company:
client.contractors.upsert(
name: "Jan Kowalski", tax_id_type: "pesel", nip: "44051401359",
street: "Prosta 1", zip: "00-001", city: "Warszawa", country: "PL"
)
An email match only ever adopts a record that has no tax id, so someone buying privately from the same address as their company will not overwrite the company's record.
PDF and sending
pdf = client.invoices.pdf(invoice.invoice_id) # binary String, raises on failure
File.binwrite("faktura.pdf", pdf)
# wFirma emails the PDF itself. Omit email: to use the address on the
# contractor record, subject:/body: to use wFirma's template.
sent = client.invoices.send_email(invoice.invoice_id, email: "jan@example.com")
return handle_failure(sent.errors) unless sent.success?
Print options on both: page: ("invoice" original, "invoicecopy" copy,
"all" both), duplicate:, leaflet:, and address: on pdf.
Results and errors
Every write returns a Wfirma::Result. wFirma answers HTTP 200 even for
failures, so always check success? — the real outcome is in the status
code, not the transport.
result.success? # status.code == "OK"
result.record_id # the created/updated object's id (Integer), or nil
result.record # the object itself; #invoice / #invoice_id read the same
result.errors # ["contractor.nip: …", "invoicecontents.0.invoicecontent.price: …"]
result.status_code # "OK", "ERROR", "NOT FOUND", …
result.raw # the full parsed response
errors reports each failure qualified by where wFirma attached it, including
errors nested in the contractor or in a single line item.
Only three status codes reach you as a Result: OK, ERROR (validation
errors on the object) and NOT FOUND (a record you named that is not there).
Every other documented code aborts the request — there is no record for it to
report on — so it is raised rather than folded into a Result with an
empty errors list.
| Exception | wFirma status code | What to do |
|---|---|---|
Wfirma::ConnectionError |
— network failure, timeout, unparseable response | retry |
Wfirma::AuthError |
AUTH, AUTH FAILED LIMIT WAIT 5 MINUTES |
fix the keys; the second is a 5-minute lockout |
Wfirma::AccessDeniedError |
ACCESS DENIED, DENIED SCOPE REQUESTED |
the account or OAuth scope may not do this |
Wfirma::RequestError |
ACTION NOT FOUND, COMPANY ID REQUIRED, INPUT ERROR |
the request is wrong; retrying it will not help |
Wfirma::RateLimitError |
TOTAL REQUESTS LIMIT EXCEEDED, TOTAL EXECUTION TIME LIMIT EXCEEDED |
back off and retry later |
Wfirma::ServiceUnavailableError |
OUT OF SERVICE, SNAPSHOT LOCK |
wFirma is down or restoring; retry later |
Wfirma::ServerError |
FATAL |
wFirma's bug; report it |
Wfirma::ApiError |
any code not listed above | base class of all of these |
All of them are Wfirma::ApiError and carry status_code and errors, so
rescue Wfirma::ApiError catches the lot; Wfirma::Error additionally covers
ConnectionError. pdf also raises ApiError when it gets a JSON error
instead of a file. An unrecognised code raises ApiError rather than passing
for a soft failure — wFirma may add codes, and a silent one is worse than a
loud one.
wFirma's limits move with their server load, and their docs recommend batching work overnight and avoiding bursts. This library does not retry for you.
What the library deliberately does not do
- No postal-code fixing. Validate the address before this point; a malformed Polish code is reported as a field error on the customer, before any invoice exists.
- No VIES handling. wFirma checks EU VAT ids against VIES live and rejects inactive ones; the rejection is passed straight through.
- No defaults for
tax_id_typeorcountry. Pass them explicitly. - No retries or backoff.
RateLimitErrorandServiceUnavailableErrortell you when to back off; the scheduling is yours. - API Key authorization only. wFirma also documents OAuth 1.0a and OAuth 2.0, which reach further than API Keys do. Neither is implemented here.
Development mode
Drivers::Fake runs the whole library — payload mapping, envelopes, Result
parsing — with no HTTP. It keeps an in-memory contractor catalogue, so
find → add/edit behaves as it does against the real CRM.
fake = Wfirma::Drivers::Fake.new
client = Wfirma::Client.new(driver: fake)
fake.requests # every call made, in order
fake.reset! # clear recorded calls, stored contractors, failure mode
Failure scenarios, drivable from the UI:
| Contractor NIP | Result |
|---|---|
0000000000 |
validation error on the contractor |
0000000001 |
raises Wfirma::AuthError |
0000000002 |
raises Wfirma::ConnectionError |
A Polish zip that is not XX-XXX is rejected exactly as wFirma rejects it,
so that path can be exercised offline. All-zeros NIPs are checksum-invalid, so
no real customer can trigger these by accident.
fake.fail_next!(code: "ERROR", errors: ["contractor.name: nie może być puste"])
fake.fail_always!(code: "AUTH")
Any documented status code can be armed, and the Fake raises exactly what the real driver raises for it — so a rate-limit or outage path can be exercised offline:
fake.fail_next!(code: "TOTAL REQUESTS LIMIT EXCEEDED") # => Wfirma::RateLimitError
fake.fail_next!(code: "OUT OF SERVICE") # => Wfirma::ServiceUnavailableError
Development
bin/setup # bundle install
bundle exec rake # tests + rubocop
bundle exec yard server -r # preview the API docs at localhost:8808
Minitest, and the suite runs entirely offline: the Fake driver covers the
resource layer, webmock covers Drivers::Http.
License
MIT. See LICENSE.txt.