Плагин Medusa v2 для польского документооборота. Выставляет счета в inFakt по оплаченным заказам и передаёт их в KSeF, с устойчивым к сбоям конечным автоматом и интерфейсом оператора для счетов, которым нужна проверка.
Polish invoicing for Medusa v2. Issues an inFakt invoice for every paid order and files the B2B ones to KSeF, Poland's national e-invoicing system.
Full documentation, in English and Polish, is published at https://zanreal.com/docs/oss/medusa-infakt and authored in .
Built against Medusa core .
The hard part of this integration is not the API calls. It is that issuing an invoice cannot be undone. inFakt's create endpoint has no idempotency key, so a retried request produces a second real, numbered, legally-issued document - and the only way to withdraw one is a formal corrective invoice. Almost every design decision below follows from that.
Each step persists its result before the next one starts, and the next step is derived from which columns are still null. A crash at any instant resumes exactly where it stopped on the following tick.
This plugin is not legal advice. It automates a filing obligation; confirming that obligation applies to your business, and that your invoices are correct, remains yours.
is on npm:
That resolves to a prebuilt tarball - the published package already contains the output its point at, so nothing needs to compile on install.
keeps moving after a release ships (see Releasing), so if you need a fix or feature that has landed on but not yet been released, install it as a git dependency instead, pinned to a commit:
Pin to the commit you tested against. would move under you on the next push to the repository.
Installed this way, the package compiles itself on install - runs , which turns the checked-out source into the output its point at. pnpm 10 and newer refuse to run that script for a dependency they do not already trust, so a fresh install needs it allowed once, in your project's :
The key is the exact tarball URL pnpm resolves the pinned commit to, which is why it carries the same SHA as the dependency line above - update both together when you move the pin.
Register it in :
Then generate and run the migration in the consuming app, as with any other module:
npx medusa db:migrateinFakt's sandbox () has been unreliable, so testing against a real inFakt trial account is the more dependable path. If you do:
Passed via . The single object cascades to every module the plugin registers (there is one: ).
| Option | Type | Default | Notes |
|---|---|---|---|
| - | The enable switch. inFakt API key, sent as . Absent or blank leaves the plugin inert; see below. Read it from an env var. | ||
| See the sandbox note above. | |||
| - | Optional, strict . Orders placed before it are skipped. Absent means no floor. See below. | ||
| The domestic currency. Orders in any other currency are skipped unless opts them in. | |||
| inFakt VAT rate symbol for domestic lines. Cross-border lines are decided by the VAT regime, not by this. | |||
| Master switch for all cross-border VAT. Off means the plugin behaves exactly as it did before this feature existed. See below. | |||
| Extra currencies to invoice, e.g. . Only consulted when . | |||
| What an unreachable VIES means. parks the order; charges destination VAT. Never zero-rates either way. | |||
| EU REST endpoint | Override the VIES endpoint. | ||
| VIES request timeout. | |||
| Email cross-border invoices to the buyer. A foreign buyer cannot collect one from KSeF. | |||
| Switches the OSS code path on. Requires . Read the OSS warning below before enabling. | |||
| Whether the company actually holds a union OSS registration (VIU-R). Without it, EU consumers get the domestic rate below the threshold. | |||
| EUR 10 000 / PLN 42 000 | Per-currency intra-EU B2C limits, minor units. A currency with no entry parks the order rather than being excluded from the count. | ||
| Warn at this fraction of the threshold, so there is time to register before orders start parking. | |||
| inFakt's service taxonomy. Software and license keys are . | |||
| Which event queues an order. Medusa has no event. | |||
| Who gets filed. is for development only. | |||
| in production | Verify the account's KSeF integration and refuse to run when it is not active. | ||
| - | Per-invoice predicate. Overrides entirely, including . | ||
| see below | Where to find the buyer's NIP. | ||
| Emit once an invoice is issued. | |||
| Per-request timeout for inFakt calls. | |||
| - | Encrypts an admin-set override at rest. Required before one can be saved from Settings -> inFakt; see below. Read it from an env var. | ||
| - | The secret inFakt generated for its webhook. The enable switch for - unset, that route answers 401 to everything. Read it from an env var. See The KSeF webhook. |
Off by default. Without , this plugin invoices the domestic currency only, puts on every line, and skips everything else - exactly as it did before cross-border support existed. Nothing below applies until you opt in.
Every order gets exactly one regime, decided in :
| Destination | Buyer | Regime | Rate | On the invoice | VAT-UE? |
|---|---|---|---|---|---|
| Poland | anyone | (23) | nothing extra | no | |
| Another member state | business, VAT id confirmed by VIES | "Odwrotne obciazenie / Reverse charge" + basis | yes | ||
| Another member state | consumer, below the threshold, not OSS-registered | (23) | nothing extra | no | |
| Another member state | consumer, above the threshold, not OSS-registered | blocked | - | - | - |
| Another member state | consumer, OSS-registered | destination country's rate | OSS document | no | |
| Outside the EU (incl. GB) | business | out-of-scope annotation | no | ||
| Outside the EU | consumer | blocked | - | - | - |
The EU-consumer row is the one to read twice. Today this store is not registered for OSS, so an EU consumer is charged Polish 23% - not the destination rate, and emphatically not zero. That is correct because of the intra-EU B2C threshold, and it stops being correct the moment the threshold is crossed.
Anything the tree cannot answer becomes with a reason, never a guess. A late invoice is a support ticket; a wrong one is a liability.
is not . inFakt's is a Polish zero rate; ("nie podlega") means the supply is outside the scope of Polish VAT. Cross-border services are . There is no reverse-charge rate symbol any more - inFakt's expired on 2019-11-01 with the domestic reverse charge - so the legal annotation is carried as invoice text.
"Not Poland" does not mean "no VAT". An EU consumer is never zero-rated. Below the threshold they owe Polish VAT; above it (or once we register for OSS) they owe their own country's. Zero is wrong in both directions.
A reverse charge and an export of services are not the same thing. They carry the same , but a reverse charge belongs in the VAT-UE summary and an export must never appear there. They are separate regimes for that reason alone.
A VAT id that is merely present is not one that is valid. A buyer is treated as a business only when all three hold: an id is supplied, VIES confirms it, and its country matches the billing country. Anything else is a consumer or a park.
VIES has three outcomes, not two, and the third one matters:
Parking is the default because both automatic answers are wrong in different directions: zero-rating rests on evidence we do not have, and charging destination VAT silently overrides a business customer's own statement about who they are. Set to never delay a paid order - that over-collects, which a corrective invoice can fix, rather than under-collecting, which it cannot. Neither setting can produce a reverse charge on an unconfirmed number.
Validate at checkout and cache the result on the order (), so a routine VIES outage cannot strand an order the customer has already paid for. The reader accepts /, , or .
The place of supply of a service follows the customer; the place of supply of goods follows the goods. So each product needs a marker - set to or , on the line, the variant or the product.
Unmarked products are not assumed to be services. They keep invoicing normally domestically (a Polish sale is 23% either way) and park on the first foreign order, naming the product. That means no catalogue backfill is needed before shipping this, and no silent wrong answer either.
A supplier established in one member state may keep taxing intra-EU B2C sales at its own rate while the combined net value of those sales stays at or below EUR 10 000, measured across the current and previous calendar year (art. 28k ust. 2 ustawy o VAT; Directive 2006/112 art. 59c). Above it, the place of supply moves to each consumer's country and OSS registration is required.
Three properties of that rule shape the implementation:
Where the counter lives. It is derived from the invoices themselves: every row stores and , and the counter sums those rows. There is no separate ledger to drift out of sync, and an accountant can audit the figure by listing the same rows they would anyway. Only EU B2C counts - reverse-charge B2B and non-EU sales are outside the threshold entirely.
The currency approximation, stated plainly. The limit is EUR 10 000 with a statutory PLN equivalent of 42 000 PLN. Sales may be in either. Doing this exactly needs NBP rates per transaction date, which this plugin does not have and will not invent. Instead it tracks a running total per currency, expresses each as a fraction of that currency's own limit, and sums the fractions. A currency with no configured limit does not get skipped - it parks the order, because silently not counting a currency is the one failure mode the whole mechanism exists to prevent.
and are separate flags, and both default to false. switches the code path on; asserts that destination-rate invoicing is actually lawful for this company. Setting without changes nothing about which regime an EU consumer gets.
Two further reasons OSS is gated:
1. Checkout has to charge the destination rate first. This plugin decides the rate from inFakt's own , but the money was already taken by Medusa at whatever rate its tax module was configured with. If those disagree, no correct invoice exists - it would either misstate the tax or misstate the total. The builder therefore cross-checks and parks on a mismatch. If your non-Polish tax regions have no rate configured, every OSS order will park with a reason saying so. That is intended.
2. OSS invoices have their own numbering series. They are a separate document family at inFakt. Downstream, the invoice number is the reference license keys are bought and recovered under, and it is not unique across families - a series that restarts at 1 can collide with an existing VAT invoice number and cause one order's keys to be delivered against another's. A collision guard () refuses to announce a number another order already holds, but the numbering inFakt actually assigns to OSS invoices has not been confirmed against a real document.
A Polish B2B buyer collects their invoice from KSeF. A foreign buyer cannot - they have no access to it - so filing a cross-border invoice is not the same as delivering it. The pipeline emails cross-border invoices via inFakt, best-effort, controlled by . Foreign tax ids are still filed to KSeF: keys on any tax id, not only a Polish NIP.
Today a non-domestic order is skipped, which means no invoice, no , and therefore no downstream license-key purchase or delivery. Adding a currency to turns all of that on for those orders.
Both describe inFakt, not a preference of whoever wrote this plugin. inFakt is a Polish invoicing and bookkeeping service: an account belongs to a Polish registered business, the books it keeps are Polish books, and its ledger currency is PLN, so defaulting to anything else would describe no real inFakt account. is likewise inFakt's own symbol for the Polish basic VAT rate, from the same vocabulary as , , , and - a value from the integrated service, not a commercial choice.
They are kept deliberately, and the reasoning is repeated next to the constants in and locked by a test, so that a later sweep for shipped defaults does not delete them by mistake. A store that invoices in another currency or at another rate sets these two options explicitly, and everything else keeps working.
, , , and can all be overridden live from Settings -> inFakt without a redeploy - see Live overrides below. Every other option in this table stays -only.
Every option is validated in the module loader, so a misconfiguration is a boot failure with a precise message rather than an opaque 401 or 422 in the middle of a customer's checkout.
The plugin should simply work when it is configured and do nothing when it is not. is that switch at the config level: absent or blank, the plugin boots inert - no order is ever enqueued or invoiced - with one clear line in the boot log and in the admin UI. Set it, and the plugin is fully active.
This is the one option that does not throw when it is missing. Every other option, including , fails loudly at boot when it is malformed.
That is not the whole story, though, because alone is not a safe signal to start invoicing. A store cutting over from a legacy invoicing system has configured from day one - the admin UI needs it to render at all - but invoicing has to stay off until an operator deliberately turns it on. Two more layers sit on top of , checked fresh on every subscriber invocation and every worker tick (not just at boot, because both of these CAN change without a restart):
The combined answer - - is what the subscriber and the worker actually check. reports it, along with which of the three is responsible (: , , , or ).
is not the only field this plugin lets an operator change without a redeploy. Settings -> inFakt can also override , , , and - every one of these plugin options, except , , , , , and , which stay -only.
Each override is a nullable column on the same singleton row as the pause switch. Null means "not overridden - use the value", so shipping this onto an existing install changes nothing until an operator opens the Settings page and saves a field on purpose. Once saved, the override wins outright and is read fresh on every subscriber invocation and every worker tick - see in .
accepts any subset of , , , , and - only the fields present in the body are written. reports both (the raw override, null where unset) and (the merged, currently-in-effect value).
is handled differently from the other four. It is a credential, not configuration, so an override is encrypted (AES-256-GCM, via Node's built-in , no new dependency) with the plugin's option before it is ever written to the database, and it is never read back by any admin route - reports only (true from either source) and (true when an override specifically is saved). Setting is required before an override can be saved at all; answers 400 with a message naming the option otherwise. clears a saved override and falls back to the boot-time . If is ever rotated or removed, a previously saved override can no longer be decrypted; the plugin falls back to the boot-time silently at every runtime decision point (never a crash), and staying while invoicing behaves as if it were is the signal that this happened.
Leave it unset and every order the pipeline otherwise sees is invoiced, subject to every other gate (fully paid, right currency, not canceled, not already invoiced outside this pipeline - see below). There is no back-catalogue risk in leaving it unset on a brand-new store: it is a floor for stores that already have order history this plugin should not touch, not a precondition for the plugin to run.
Set it to add that floor. It must be exactly and a real calendar date - a value that is present but malformed still fails loudly at boot rather than being read as "unset", because a typo here must not silently turn into "invoice everything". The parse has to round-trip: succeeds by rolling over to March 2nd, which would make a fat-fingered floor silently mean a different day than it reads as.
| Variable | Default | Effect |
|---|---|---|
| Cron schedule for the worker job. A reconciliation interval, not a latency budget: a paid order is invoiced immediately by the subscriber, and this tick retries whatever that could not finish. | ||
| Cron schedule for the settlement reconciliation - the read-only pass that compares inFakt's with what Medusa captured. A separate job on separate columns, which never takes the invoicing claim and never marks anything paid. | ||
| unset | // force-disables invoicing, overriding everything else. |
Why the cron is not an option. Medusa evaluates a scheduled job's at plugin-load time, before the DI container - and therefore this plugin's options - exists. There is no supported way for a static export to read a resolved module's options, so this one setting has to be an environment variable.
Why the force-off is an environment variable too, and not a plugin option. Unlike the pause switch, this one is deliberately NOT reachable from the admin - an operator flips it at deploy time (or during an incident) without touching the database, and it cannot be undone by anyone clicking around in the admin. See Enablement above.
fires once per capture, and an order can be captured in parts (several payment collections, a partial capture, a split payment). Invoicing on the first capture would issue an invoice for the full order total against a partial payment.
So the subscriber's only job is to create the ledger row. Every consequential decision belongs to the worker, which is idempotent, restartable, and re-reads live state on every tick. A deferred order needs no second event; the next tick picks it up.
One further event is subscribed and is deliberately not a trigger: , emitted by the moment a marketplace order's billing address is written. It never enqueues - admission stays with the configured trigger - it only advances a row that is already waiting for that address, instead of leaving it to the next cron tick.
Duplicate delivery is harmless ( is unique, so a second enqueue is a no-op). A missed event is recoverable through , since Medusa's event delivery is at-most-once.
The worker takes an atomic claim - one against the run state row - and holds it for the whole run. Zero returned rows means the claim was refused; nothing is inferred.
That is not bookkeeping. Two overlapping runs reading the same due row would both pass the crash-window check, both write , and both POST a create: two real numbered invoices for one order.
A claim older than ten minutes is treated as a crashed process and taken over, so a dead run can never wedge invoicing permanently. Releases are conditional on the claim token, so a taken-over run cannot clear its successor's lock.
is written to the database before the create call. On resume, a row with that marker set but no means the create may already have reached inFakt.
Such a row goes to and the create is never retried automatically. That is the one failure mode this design refuses to guess about: inFakt has no idempotency key, so a retried create can issue a second real numbered invoice, and the customer receives two invoices for one order.
Resolving it is a human decision with exactly two outcomes, both on the order's detail page:
The Retry button is not rendered at all for these rows, and the server refuses a retry on them independently.
Every other failure retries with backoff (base 10 minutes, doubling, capped at 6 hours, 8 attempts), because every other failure is either idempotent or observable. HTTP go straight to - retrying an identical request against those cannot succeed. and are deliberately not in that set.
Waiting is not failing: a deferral (inFakt still processing, KSeF still processing, the order not yet fully paid) does not consume an attempt. An order that sits unpaid for a week still has its full retry budget when the money lands.
The sum of the invoice lines must equal the order total, grosz for grosz, or the build fails and the row goes to .
An invoice is a legal statement of what the buyer paid. One that states a different number is worse than no invoice at all, because correcting it needs a formal corrective invoice. So a discount, gift card, credit line or fee adjustment this plugin does not model gets a human's attention rather than being silently absorbed into a line.
Practically:
Shipping becomes one line per method that costs anything, labelled . Free methods produce no line.
A store migrating off an older invoicing system typically ports its order history into Medusa with the invoice it already issued recorded on the order itself, not in this plugin's ledger: (and usually , naming where it came from).
The worker treats a non-empty in that metadata as a fact, not a suggestion. Before any other check runs, an order carrying one is skipped with , and nothing is ever submitted to inFakt for it. This is a build-time gate, so it holds no matter which path put the row in the queue - an trigger firing at import time, or an operator manually queuing it through .
Two structural facts make this a narrower problem than it first sounds:
An export that produced this metadata can also be WRONG. An order whose invoice number was lost in the export looks, to the guard above, like an order that was never invoiced - while the invoice sits in inFakt, correctly issued and filed. That is what the reconciliation below exists to recover, and it recovers it from inFakt, not from whatever produced the export.
, and the Adopt existing invoices panel on the plugin's settings page.
For a store whose history was invoiced somewhere else: the documents are real, numbered and filed, and only this ledger does not know about them. The reconciliation reads invoices from the inFakt API and matches them to Medusa orders on order data alone. No other system is consulted, and none needs to exist - not the legacy system that issued them, not the export that lost them.
Every rule below is a hard gate. There is no score, and no signal can make up for a failing one.
| Gate | Rule | Why |
|---|---|---|
| Issue date | within (default 7, max 31) of the order's Warsaw calendar day. An undated invoice is dropped. | Keeps a repeat customer's later order from matching the earlier invoice for the same basket. Warsaw, because that is the day the invoice itself is dated. |
| Buyer identity | B2B: exact normalized NIP. B2C: exact email OR exact normalized full name. | The one signal that says these are the same person. Diacritics and NIP prefixes are normalized away first. |
| Gross total | Integer equality in grosze, no tolerance. Currency must agree when both state one. An order whose total cannot be read matches nothing and says so. | An amount that is close is an amount that is wrong. A one-grosz drift means it is a different document, and an unreadable total is never treated as 0. |
| Uniqueness | Exactly one invoice may survive all three, unless the chronological pairing below settles it. | Two survivors is the duplicate-invoice case, which is precisely what a human has to look at. |
| Not already taken | The invoice must not already be recorded on another ledger row, by uuid or by number. | One document settles one order. The number check matters because an imported row may carry only the number. |
| The order's own claim | When names an invoice, the match must BE that one. | An order that names an invoice and matches a different one by amount and buyer is a warning, not a discovery. |
What an invoice calls its lines is never compared. Not as a gate, not as a confidence grade, not as a tiebreak. The two systems name a line their own way for perfectly legitimate documents - a catalogue title here, a shortened trade name or a single aggregate line there - so a name check can only ever report a correct match as weaker than it is, and an operator then learns to ignore the grade. The signals are the person, the date and the amount.
One buyer, several orders on one day, all for the same amount, invoiced with several documents that are equally identical: nothing but the order of events separates them, and refusing every one of them helps nobody. So the orders are sorted by the moment they were placed, the invoices by their number within their shared issue date, and the two lists are paired one to one.
That is the ONLY place chronology decides anything here, and it is fenced in hard. It engages only when the two sides are genuinely twins - same buyer, same Warsaw day, same gross total, the very same set of candidate invoices, and those invoices agreeing on issue date, amount and currency - and only when the counts on both sides are equal. Everything else refuses the whole group and says so per order:
Any other multi-candidate case is reported, never guessed. has a nearest-date tiebreak for the crash-window flow, where a human is already looking at one order and knows an invoice exists; it is deliberately not used here.
Every proposal is graded or , and the grade is about what a human should look at rather than whether the match is allowed - every gate above passed either way.
when the buyer was identified by a key (a NIP or an email address) and the invoice was issued on the order's day or the day next to it, or when the order names that invoice number itself, which is the order's own claim rather than an inference.
when the buyer was matched on a full name alone (two people can share one), when the issue date sat more than a day from the order (still inside the window the operator asked for, but no longer the obvious document), or when the chronological pairing settled it, which is correct only if duplicate orders were invoiced in the order they were placed.
How many invoices happened to be in the date window is recorded as evidence but does not grade: candidates that lost on identity or amount lost on a hard gate, and letting their number darken a survivor would mark every match in a busy week as weaker than the same match in a quiet one.
An adopted row carries , the invoice's uuid and number, set to the day the document was issued, and : the signal that identified the buyer, the gross total, how far the issue date sat from the order, and whether chronology had to tell same-day duplicates apart (, with the order's place among them). Signal KINDS and numbers only - never an email or a name, because this table holds no buyer data.
is recorded too, decided from the tax code on the adopted document exactly as would have decided it. On a terminal adopted row it is an audit fact, not an instruction: nothing acts on a row, and the order widget says "not tracked by this plugin" rather than claiming a filing is queued.
It is a read. The reconciliation calls nothing that creates, sends or files.
Issuing an invoice and recording that it was paid are two jobs on two clocks. The first has a legal deadline; the second is bookkeeping. They are kept apart - separate job, separate columns, separate events - because an earlier version wedged them together as a retry loop and held issued, KSeF-filed invoices out of for fifteen minutes at a time.
is the signal, and nothing else is. inFakt's is a single last-write-wins enum that any later action overwrites, including a plain PDF download. Invoice was marked paid at 12:40:03 and read back three seconds later as - our own Allegro attachment had fetched the PDF - with its intact. The amounts are no better in the other direction: invoice carries together with . Both amounts are recorded as evidence and neither is ever decisive.
Four nullable columns carry the result, with no backfill: (inFakt's ), (when anyone last looked), (how the two systems disagree, or null) and (evidence only).
| Drift code | Meaning |
|---|---|
| Captured in full in Medusa, no in inFakt. The only code a fix could ever safely touch | |
| Money went back, inFakt still has it settled. Report only | |
| inFakt has it settled, Medusa captured nothing. Report only | |
| inFakt has it settled, Medusa captured part of the total. Report only | |
| The invoice or the order could not be read well enough to compare |
Nothing is fixed automatically in this version - not even . The report names the rows a future fix would touch () so the blast radius can be judged before anything is armed. Adopted invoices are reported and never fixed, whatever their code: their payment bookkeeping belongs to whoever issued them.
It runs on , and for the one order each names, and hourly at as the backstop over a sliding ninety-day window (a full pass is available on demand). A row that settles and agrees is never read again; a row that disagrees is re-read at most every six hours. It takes no invoicing claim, never fetches a PDF, never marks anything paid, and never writes payment state back into Medusa.
Medusa core has no field for a business buyer's tax id, so every storefront puts it somewhere different. The default extractor tries, in order:
It also accepts , , and as metadata keys. Override it entirely with the option rather than reshaping your orders:
Two deliberate restrictions:
A NIP that normalizes to ten digits but fails its checksum is still used. inFakt, and ultimately KSeF, is the authority on whether a number is acceptable; refusing here would park a legally required document over a check this plugin is not the arbiter of.
is where a tax id belongs. Reading one out of exists because some upstream systems concatenated the two into the name, and this plugin then has to un-concatenate them - which is inherently approximate. It got it wrong: two invoices went out reading , because the strip removed the digits and the word but not the brackets they sat in. They were already numbered and already filed to KSeF, so correcting them needs a formal corrective invoice.
writes now and leaves the company name alone. Source 3 stays for the orders that predate that, and for storefronts that still do it - but if you control the writer, write the metadata key.
Whatever the cleaning produced, a company name that still looks mangled is never sent. The invoice goes to instead. Five shapes are refused, all of them residue rather than anything a legal name has: an empty bracket pair, an unbalanced bracket, a leading or trailing dangling separator, a name with no letters or digits at all, and a name that still contains the tax id. A trailing full stop is explicitly fine - ends in one.
The trade is deliberate and one-directional: a delayed invoice is an operator task on the queue, a wrong one is a legal document that can only be undone with another legal document.
The company name was never the only assembled string on an invoice, and the gate above was only ever applied to it. The shape checks now live in , and three other places on the document use them.
The VAT regime reads the same company field, and used to read it raw. treats a non-empty company name as one of the two signals that a non-EU buyer is a business rather than a consumer, and a business supply is invoiced - outside Polish VAT - with a statutory annotation on the face of the document. That decision was made on verbatim while the payload printed the cleaned value, so one order could be a company to the regime and have no company name at all to the builder. It now goes through , which cleans the field and then puts it through the full gate. A non-EU buyer whose company field holds only residue is - a row - rather than an invoice asserting a place of supply outside Poland.
Note which branch of the gate does the work there: deliberately falls back to the raw value when cleaning empties it, so a field holding nothing but survives as itself - the brackets balance, digits are present, nothing dangles. Only "the name still contains the tax id" catches it.
A shipping line is a concatenation too. glued the carrier name behind on nothing but truthiness, so a method named or printed on an invoice line: the same stranded separator as , one field over. A method with no usable name is now simply , which is what the line means.
A line item name is glued and then un-glued. joins the product title to the variant title with , and where the variant title already begins with the product title it strips the shared prefix back off. That strip removed only whitespace and hyphens, so a catalogue that separates its own variants with something else - - came out as . It now drops every leading non-alphanumeric, and falls back to the product name alone rather than printing a bare separator.
Truncation cut inside a character. Service names are capped at 255 for inFakt, and counts UTF-16 code units - a marketplace title whose 255th boundary landed inside a surrogate pair emitted a lone surrogate, which is a replacement glyph on the customer's PDF. walks whole code points and trims whatever separator the cut leaves behind.
None of these park an invoice; they are shape fixes at the point of assembly rather than gates. Only the regime's company-name signal can send a row to , and only in the direction of asking a human rather than issuing a document.
| Behaviour | |
|---|---|
| (default) | A buyer with a NIP is filed. A consumer is not. What the law wants. |
| Every invoice is filed, including consumer ones. | |
| Nothing is filed. Development and testing only. |
overrides the mode entirely, including . An operator who wrote a predicate has made a more specific statement than the mode does; the recorded reason says which of the two answered, so the override is visible in the audit trail.
The decision is frozen onto the row at build time, with its reason, in and . Re-deriving it from live config on a later tick would let a mid-flight change reclassify an invoice that has already been issued.
With on (the default in production), the worker verifies the inFakt account's KSeF integration via and fails the whole run loudly when it is not active - a clear error in the log and a red run state in the admin UI.
Letting the rows accumulate instead would be worse. An inactive integration makes every B2B submit fail with a 422, which is non-retryable, so every company invoice would quietly park itself for a human while a legal deadline passed. A red run state is something an operator notices; a growing queue is not.
The check runs at most hourly, and immediately when the integration is known to be inactive, so fixing it in inFakt takes effect on the next tick. Re-check KSeF on Settings -> inFakt forces it right away.
A failed check is recorded as an error, never as . "We could not reach inFakt" and "your integration has lapsed" call for completely different responses.
inFakt's KSeF documentation asks for a webhook rather than repeated reads ("Zachęcamy do skonfigurowania webhooka, który poinformuje o zmianie statusu przetwarzania na końcowy. Ograniczy to ilość zbędnych zapytań."). Filing a B2B invoice has been mandatory since April 2026, which makes it the one step in this pipeline with a statutory deadline behind it, so the status should arrive when inFakt has it.
is that endpoint. It is optional: leave unset and the plugin behaves exactly as it did before this existed - the KSeF poll rides each document to a terminal state inside the run, and the cron sweeps up whatever it could not finish. Wiring the webhook does not replace either of them.
The webhook is a nudge, never a fact. Nothing is read out of the payload except which invoice to look at. The route then re-reads the status from through the same step the cron runs, so the persisted columns advance through their normal code and remain the only source of truth. A forged or replayed delivery, if one ever got past the signature, can at worst cause a status read - it cannot write a KSeF number, park an invoice, or mark an unfiled document as filed.
Two events concern this plugin, out of the seven in inFakt's table: and . Everything else is answered 200 and ignored.
A webhook configured with "bez poufnych informacji" reduces to . That mode works here too, and is arguably the better one to pick: the identifier is all this endpoint uses.
Every delivery carries , the hex HMAC-SHA256 of the raw request body under the secret inFakt generates per webhook and shows in its details in the panel. The route verifies it in constant time against the preserved raw bytes - not against re-serialised JSON, which would be different bytes - and answers 401 to a missing, malformed or wrong one, which is what inFakt's documentation says a failed verification must answer.
There is no timestamp and no replay window in inFakt's scheme, so the signature proves authenticity and nothing more. That is survivable only because of the "nudge, never a fact" property above; do not weaken it.
With no configured the route answers 401 to every request, including one carrying a valid signature. An unauthenticated endpoint that advances a legally significant document on anyone's say-so is worse than a webhook nobody has wired up yet.
There is no API for this - webhooks are created by hand, per account, in the panel.
Two operational facts worth knowing. inFakt retries a delivery until it is answered 200 or 201, emails after six failures and switches the webhook off automatically after ten - which is why this route answers 200 to everything it cannot act on, rather than spending that budget on a condition no retry can change. And inFakt publishes the addresses its API and webhooks call from, so an ingress can allowlist them:
The feature is marked beta by inFakt, and their docs contradict themselves on the auto-disable threshold (ten failures in one place, eleven in another). Treat the poll as load-bearing, not as legacy.
is a custom prefix Medusa applies no authentication to, and it is the prefix a Medusa deployment typically publishes when it publishes anything at all - so this route needs no ingress change where is already public, and is untouched. This is an unauthenticated route of its own, with its own credential and no authority: it is not on anything, and no admin matcher was widened. See the comment in , which registers exactly one thing for it - , without which the HMAC cannot be checked at all.
If your deployment does not publish , publish that one path, ideally locked to and to the addresses above. Do not publish to get it.
A row raises a Medusa admin notification that deep-links to the order. Open that order - the Invoicing widget on its detail page carries the reason, PII-free, and usually names the fix, alongside the same operator actions listed below.
| What it says | What happened | What to do |
|---|---|---|
| a previous inFakt create attempt may have gone through... | The process died between the create being sent and its reference being stored. | Look for an invoice for that order in inFakt. Found one: Link invoice with its uuid. None: No invoice in inFakt, confirm. |
| line total N does not match order total M | The order has a discount, credit line or fee this plugin does not model. | Decide what the invoice should say. Invoice it manually in inFakt and Link invoice, or Skip with a reason. |
| buyer address is incomplete (missing: ...) | The billing address lacks a field inFakt requires. | Fix the order's billing address, then Retry. |
| buyer tax id does not normalize to a 10-digit NIP (N digits...) | The captured tax id is not a Polish NIP - often a foreign VAT id. | Correct or remove the tax id on the order, then Retry. Removing it makes the order a consumer invoice, outside KSeF. |
| ...could not be confirmed against VIES... | VIES, or that member state's node, was unreachable. Not a rejection. | Retry once VIES is back. If this recurs, validate at checkout and cache on the order, or set . |
| the VAT id was issued by X but the billing country is Y | The two pieces of evidence disagree |
npm install @zanreal/medusa-infakt1// package.json2{3 "dependencies": {4 "@zanreal/medusa-infakt": "github:zanreal-labs/medusa-infakt#1c7a50c551f59658156d6f0b024996946cd71417"5 }6}1# pnpm-workspace.yaml2allowBuilds:3 "@zanreal/medusa-infakt@https://codeload.github.com/zanreal-labs/medusa-infakt/tar.gz/1c7a50c551f59658156d6f0b024996946cd71417": true1import { defineConfig, loadEnv } from "@medusajs/framework/utils";2
3loadEnv(process.env.NODE_ENV || "development", process.cwd());4
5module.exports = defineConfig({6 // ...7 plugins: [8 {9 resolve: "@zanreal/medusa-infakt",10 options: {11 // The plugin's enable switch. Unset (or point this at an env var that is12 // not set) and the plugin boots inert, with one line in the boot log.13 apiKey: process.env.INFAKT_API_KEY,14 environment: "production",15 // Optional. Leave it unset to invoice every order the pipeline sees.16 // Set it when installing onto a store with a back catalogue this plugin17 // should not touch - orders placed before it are skipped.18 // startDate: "2026-08-01",19 currency: "PLN",20 taxSymbol: "23",21 ksef: { mode: "nip-only", requireActive: true },22 // Optional. Required only before an operator can save an apiKey23 // override from Settings -> inFakt - see "Live overrides" below.24 settingsEncryptionKey: process.env.INFAKT_SETTINGS_ENCRYPTION_KEY,25 },26 },27 ],28});1payment.captured -> subscriber -> InfaktInvoice row (status: pending)2 |3 worker tick (every 5 min, single-flighted)4 |5 gates: not backfilled, startDate, currency, canceled, fully paid6 |7 submit_started_at -> POST /async/invoices.json8 |9 task_reference -> poll until 201 + invoice_uuid10 |11 invoice_number -> GET /invoices/{uuid}.json12 |13 ksef_sent_at -> POST /ksef2/documents/{uuid}/send.json (when required)14 |15 ksef_number -> poll until "success"16 | (a configured webhook only17 | wakes this poll sooner)18 |19 event_emitted_at -> emit infakt.invoice.issued20 |21 done1options: {2 nipExtractor: (order) => order.metadata?.company_tax_id as string | undefined,3}1{2 "event": {3 "name": "send_to_ksef_error",4 "uuid": "432cc5fc-f7ca-4afa-9420-7d6410fc0940",5 "created_at": "2023-10-02T11:30:30.656+02:00",6 "retry_counter": 07 },8 "resource": {9 "invoice_uuid": "ee2484ce-052c-495a-9ce1-5bd3ae8314aa",10 "status": "error",11 "ksef_number": null,12 "status_description": "Wystąpił problem podczas otwarcia sesji."13 }14}118.195.224.145 35.157.20.95 18.153.130.220 18.158.11.58 18.158.35.194218.159.228.63 18.195.110.70 3.121.46.57 3.124.100.165 3.125.243.21833.126.125.137 3.67.214.209 3.79.196.143 3.79.223.93 52.28.116.250