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_type or country. Pass them explicitly.
  • No retries or backoff. RateLimitError and ServiceUnavailableError tell 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.