Project

wfirma

0.0
The project is in a healthy, maintained state
Issue VAT invoices in wFirma, resolve customers in the contractor catalogue, fetch invoice PDFs and have wFirma email them. Pure Ruby, stdlib only, with a Fake driver for development and tests.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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

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 owed

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

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

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

Amounts 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 }]
).records

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

A 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_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 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 mode

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

find 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::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.