Wfirma
Creating VAT invoices in wFirma: resolve the customer in the contractor catalogue, issue the invoice, fetch its PDF, have wFirma email it — then list what is still unpaid and record the payments that settle 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 restbase_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.
Unpaid invoices
outstanding = client.invoices.outstanding # unpaid VAT invoices, longest overdue first
outstanding.records # [{"id" => "383172031", "fullnumber" => "FV 6/2026", …}, …]
outstanding.total # how many match across *all* pages
outstanding.records.first["remaining"] # => "1230.00" — still owedoutstanding asks for Invoice.type eq normal and Invoice.remaining gt 0.
Both are deliberate: normal is the VAT invoice, so drafts, proformas,
receipts and bills stay out; remaining rather than paymentstate survives
partial payment, reporting the amount actually still owed where the state flag
only says that something is owed.
Anything narrower goes through find, or through extra_conditions: on
outstanding, which appends to the two above:
client.invoices.find(
conditions: [
{ field: "Invoice.remaining", operator: "gt", value: 0 },
{ field: "ContractorDetail.nip", operator: "in", value: "8982167294,8982073475" }
],
order: { asc: "Invoice.paymentdate" },
limit: 100,
page: 1
)Conditions are {field:, operator:, value:} hashes (or [field, operator, value] triples), ANDed — wFirma's default. Field names are model-qualified,
and may name a related module in a 1-1 relation (ContractorDetail.nip).
Operators are wFirma's: eq, ne, gt, lt, ge, le, like,
not like, is null, is not null, in. Anything else raises
ArgumentError before a request goes out.
fields: trims the response — wFirma answers with around a hundred fields per
invoice otherwise — at the cost of the check below, which cannot re-examine a
condition on a field it asked wFirma to leave out.
client.invoices.find(conditions: [Wfirma::Invoices::OUTSTANDING],
fields: %w[Invoice.id Invoice.fullnumber Invoice.remaining])One invoice by id:
result = client.invoices.get(383_172_031)
result.record["paymentstate"] # => "unpaid"
result.status_code # => "NOT FOUND" if there is no such invoiceFilters wFirma drops without saying so
Given a conditions node it does not understand, wFirma answers HTTP 200 with
status OK and the entire collection — no error, no warning, no echo of
what it discarded. For code that books payments that is the most expensive
answer the API can give: every paid invoice would arrive looking unpaid.
So find (and payments.find) re-checks the response against the conditions
it sent, and raises Wfirma::FilterIgnoredError when a returned record
contradicts one:
begin
client.invoices.outstanding
rescue Wfirma::FilterIgnoredError => e
e.condition # {"field" => "Invoice.remaining", "operator" => "gt", "value" => "0"}
e.record # the record that should have been excluded
endIt is a Wfirma::Error but not an ApiError: the transport and the status
code were both fine, so rescue Wfirma::ApiError does not catch it.
The check is only as strong as what can be re-evaluated: like, the null
tests, and fields absent from the returned record (a related-model condition,
or a field fields: trimmed away) are taken on trust rather than guessed at.
Recording a payment
wFirma has no "mark as paid" flag to set. A payment is its own record pointed
at the document it settles, and wFirma derives alreadypaid, remaining and
paymentstate on the invoice from the payments attached to it.
client.payments.pay_invoice(383_172_031, value: "1230.00", date: Date.today)That indirection is worth having: a part payment is an ordinary record rather than a special case, and several payments can settle one invoice without anything overwriting anything.
client.payments.pay_invoice(invoice_id, value: BigDecimal("500.00"), date: Date.today)
second = client.payments.pay_invoice(invoice_id, value: BigDecimal("730.00"), date: Date.today)
return handle_failure(second.errors) unless second.success?
second.record_id # the payment's own idAmounts reach wFirma as decimal strings: a BigDecimal is formatted to two
places, a String passed through untouched. Dates go out as YYYY-MM-DD —
pass a Date/Time or the string itself.
payment_method: ("transfer", "cash", …) says where the money landed,
which is what decides whether wFirma books it through the cash register. Left
out, wFirma applies its own default rather than this library guessing at one.
add is the general form; object_name: defaults to "invoice" because
wFirma settles expenses and other payables through the same module.
client.payments.add(object_id: 383_172_031, value: "1230.00", date: Date.today,
object_name: "invoice", payment_method: "transfer")
# What has already been booked against an invoice
client.payments.find(
conditions: [{ field: "Payment.object_id", operator: "eq", value: 383_172_031 }]
).recordsResults and errors
Every call 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.records # every object returned — what find answers with
result.total # matching records across all pages, or nil outside find
result.errors # ["contractor.nip: …", "invoicecontents.0.invoicecontent.price: …"]
result.status_code # "OK", "ERROR", "NOT FOUND", …
result.raw # the full parsed responseA write answers with exactly one record, so record is the natural reader
there and records the one for find. total is what tells you whether a
second page is worth asking for: records.length is only ever the page.
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 and FilterIgnoredError, neither of which is a failure
wFirma reported. 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 and an
invoice catalogue, so find → add/edit and find → pay behave as they do against
the real thing.
fake = Wfirma::Drivers::Fake.new
client = Wfirma::Client.new(driver: fake)
fake.requests # every call made, in order
fake.reset! # clear recorded calls, stored records, failure modeInvoices issued through invoices.create land in that catalogue, but a
reconciliation flow needs invoices that already exist and may already be partly
paid, which no add call produces. seed_invoice puts one there directly,
shaped the way one comes back from invoices/find:
invoice = fake.seed_invoice("fullnumber" => "FV 2/2026", "total" => "1230.00",
"paymentdate" => "2026-03-01")
client.invoices.outstanding.records # => [the seeded invoice]
client.payments.pay_invoice(invoice["id"], value: "500.00", date: Date.today)
fake.invoice(invoice["id"])["remaining"] # => "730.00"
fake.invoice(invoice["id"])["paymentstate"] # => "unpaid"
fake.payments # every payment recorded, newest lastfind is filtered by the conditions the caller actually sent, so a query
whose conditions the live API would drop comes back unfiltered here too — and
trips the same FilterIgnoredError.
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::ServiceUnavailableErrorDevelopment
bin/setup # bundle install
bundle exec rake # tests + rubocop
bundle exec yard server -r # preview the API docs at localhost:8808Minitest, and the suite runs entirely offline: the Fake driver covers the
resource layer, webmock covers Drivers::Http.
License
MIT. See LICENSE.txt.