The invoicing pipeline

From the trigger event to a finished invoice - the fully-paid gate, the five skip conditions, the durable state machine, the crash window that is never retried, and how backoff works.

The trigger only enqueues. Everything that decides whether an invoice is actually issued happens later, in a worker that re-reads the order fresh on every tick.

That split is the whole point. payment.captured fires once per capture, and an order can be captured in parts. Invoicing on the first capture would issue an invoice for the full order total against a partial payment.

Getting from an event to an order

Two trigger events are supported, and they deliver different payloads:

Eventid in the payloadHow the order is reached
order.placedthe orderdirectly
payment.captured (default)a paymenta graph hop through payment_collection.order

The default is payment.captured because Medusa has no order.paid event and an invoice must state a payment that happened. order.placed exists for stores that invoice on placement, such as B2B on invoice terms, and it is safe: the fully-paid gate still holds the row until the money arrives. The event only decides when the row is created.

A payment that resolves to no order returns null rather than throwing. A payment collection for a cart that never became an order, or one attached to a claim or exchange, is a real and expected shape - throwing would make Medusa retry the event forever over something that is not an error.

The gates, evaluated on every tick

Before any step runs, the worker re-reads the order and checks five things. Each one produces a skipped row with a reason, not a silent no-op.

  1. Already invoiced elsewhere. If order.metadata.invoice_number is set, the order was invoiced outside this pipeline and is skipped. If the row also already has an invoice_uuid from this pipeline, that is a conflict a human resolves, not something the row picks a side on.
  2. Before the start date. With startDate configured, an order placed before it is skipped. The comparison is on Warsaw calendar days, matching how the date is written on the invoice - comparing raw timestamps would put an order placed at 01:00 Warsaw on the start date on the wrong side of it.
  3. Wrong currency. An order in any currency other than the configured one is skipped.
  4. Canceled. Canceled before invoicing is a skip. Canceled after the invoice was issued goes to needs_review: a corrective invoice is a legal document with its own rules, and the plugin does not issue one on its own.
  5. Not fully paid. The row is deferred and re-checked in 30 minutes. If the order stops being fully paid after its invoice was issued, that is needs_review rather than an endless wait for re-payment.

The fully-paid gate

Captures are summed net of refunds across every payment collection, preferring each collection's own captured_amount and falling back to summing its payments when that aggregate is absent. Canceled payments are skipped, because a canceled payment's captured_amount can still be non-zero from before the cancellation.

Everything is compared in integer minor units. Comparing decimals invites the classic 133.44 !== 133.44000000000001 stall, where an order is paid in full and the pipeline waits for it forever.

Every default in that summation moves the answer away from paid: an absent refund really is no refund, and an unreadable capture counts for nothing. The worst case is one more deferred tick, never an invoice against a payment that did not happen. The order total is the one value that is never defaulted - if it cannot be read, the gate refuses outright.

The state machine

The next step is derived purely from which columns are still null:

  invoice_uuid null?  -> task_reference set?  -> resolve-create-task
                      -> submit_started_at set? -> CRASH WINDOW (refuse)
                      -> otherwise              -> submit-create
  invoice_number null?                          -> fetch-invoice-number
  ksef_required and no ksef_number?             -> ksef_sent_at set ? poll-ksef : send-to-ksef
  emitEvent and no event_emitted_at?            -> emit-event
  never marked, not adopted, not confirmed?     -> confirm-paid
  otherwise                                     -> complete

There is no step counter and no progress held in memory, which is exactly what makes a crash at any instant recoverable. The pure rules live in src/lib/invoicing/state-machine.ts with no database and no HTTP client, so the crash-safety properties are unit-testable.

Marking the invoice paid

The last step, and deliberately the last: POST /async/invoices/{uuid}/paid.json is asynchronous - HTTP 201 means the task was accepted, not that the invoice is paid - and the status it eventually writes is a single last-write-wins enum (draft, sent, printed, paid). Any later action on the document overwrites it, including a plain PDF download. So the marking is read back rather than assumed:

  1. markPaid is sent (no amount field exists on that endpoint, and allow_correction is never sent - booking an accounting correction is the account owner's decision).
  2. paid_marked_at is written once, on the first attempt, even if the call failed. It is what stops the step ever running twice, and is never rewritten.
  3. The invoice is read back, and what is read is paid_date. It is written by the paid endpoint and survives every later action on the document, which is exactly what status does not do. paid_date sets paid_confirmed_at and settled_at.
  4. Unconfirmed, the row completes anyway, with a warning naming the invoice uuid and the order, and the admin widget shows "not confirmed". There is no retry loop: whatever overwrote the marking the first time overwrites it again, so re-marking could only spend an issued, KSeF-filed invoice's completion on a signal that was never going to settle. Settlement is picked up afterwards by the settlement reconciliation, on its own schedule.

status is deliberately not the signal. Production invoice 2/09/2026 was marked at 12:40:03 and read back three seconds later as status: "sent" - our own Allegro attachment had downloaded the PDF, and a PDF download flips the status. Its paid_date was intact. Read through status, a perfectly settled invoice reports as unconfirmed; read through paid_date, it reports as what it is. paid_price is no better in the other direction: invoice 9/08/2026 carries status: "paid" together with paid_price: 0.

Once paid_confirmed_at is set the payment is never re-checked. A human downloading the PDF flips the inFakt status to "printed", and that must not read as a payment coming undone. An adopted invoice is never marked at all: it is not this pipeline's document.

The crash window

submit_started_at is written before the create call. If the worker later finds that column set with no task_reference, the create may have reached inFakt and it stops:

a previous inFakt create attempt may have gone through without a stored task reference - check inFakt for a stray invoice, then adopt it with link-manually or clear the row

The row goes to needs_review and is never retried automatically. A human checks inFakt, then either adopts the stray invoice or clears the marker. Rows in this state are annotated in_crash_window by the ledger API so the admin UI can disable retry, rather than letting an operator discover the problem from an error message.

Automatically retrying that one create is the failure mode this design refuses. Everything else retries, because everything else is either idempotent or observable.

Outcomes, retries and backoff

A step unwinds by throwing one of three control-flow signals, or a real error:

SignalMeaningCounts an attempt?
defernot done, not a failure - still processing, or not yet paidNo
reviewterminal, needs a humann/a, terminal
skipdeliberately not invoiced, with a reasonNo
a thrown errorretry with backoffYes

Defer not counting an attempt is deliberate: a slow external system must not burn the row's retry budget. A row deferred a hundred times has spent nothing.

Waiting for data is not a review

On a marketplace order the buyer's billing details arrive with the payment, so the first attempt can run before they exist. The row that exposed this was queued at 12:36:24, parked at 12:36:25 for a missing street, city and postal code - and the real address was written 16 seconds later.

So the address-incomplete reason, and only that reason, defers instead of parking. It is bounded by wall clock rather than by attempts (a defer burns none): 60 minutes from the row's created_at, after which it becomes the same needs_review it always was, with the same message plus what the wait proved. Every other build refusal - a company name that would reach a legal document malformed, a tax id that does not normalize, a VAT id VIES could not confirm - parks immediately, inside the window exactly as outside it.

A deferred row carries defer_reason, which the order widget renders next to the next-check time, so "Awaiting" says what it is awaiting. It is cleared the moment the row advances. No admin notification and no alert is raised for a deferral; that stays for a genuine needs_review.

allegro.order.billing_ready (from @zanreal/medusa-allegro) is subscribed as a wake-up, not as a trigger: it advances a row that is already waiting for the address it announces, and never enqueues one.

Retries back off as 5 min * 2 ** attempts, capped at 6 hours, so the first retry already waits 10 minutes - the failures worth retrying at all, such as rate limits and inFakt outages, do not clear in five. After 8 attempts the row goes to needs_review, because an infinite retry loop against a permanently broken row is indistinguishable from a working pipeline in every dashboard.

Some HTTP statuses are terminal immediately, with no retry: 400, 403, 404, 405, 409, 422. 409 is there because inFakt uses it for "this already happened", which a retry cannot improve. 429 and 5xx are deliberately absent - those are exactly what backoff exists for.

last_error is truncated to 300 characters and carries no buyer PII; an inFakt validation dump can be kilobytes long.

Cadences

WhatInterval
Worker schedule*/5 * * * *, override with INFAKT_WORKER_CRON. The safety net - a paid order is invoiced immediately on payment.captured.
Re-check while inFakt or KSeF are still processing2 minutes
Riding KSeF to a terminal state inside one run5 s, doubling to a 30 s cap, for up to 150 s shared by the whole run
Waiting for buyer data that has not arrived, from created_at60 minutes
Re-check while the order is not yet fully paid30 minutes
Confirming the paid marking tookOne read-back, in the same run. Never retried - see above
Settlement reconciliation17 * * * *, override with INFAKT_SETTLEMENT_CRON. A separate job on separate columns
Re-reading an invoice whose settlement still disagrees6 hours
First retry after a failure10 minutes, doubling to a 6 hour cap

Nothing routine waits for the cron. The payment subscriber issues the invoice, the billing-ready event wakes a row waiting for an address, the KSeF poll rides its own document to a terminal state inside one run, and the admin actions (enqueue, retry, adopt, clear) run the pipeline as they are clicked. Everything is event-driven; the cron is the safety net, never the mechanism. If a normal invoice only completes when the tick next fires, that is a defect in the event path, not a reason to shorten the interval.

The worker's schedule is an environment variable rather than a plugin option because Medusa reads a scheduled job's config.schedule at plugin-load time, before the container and the plugin's resolved options exist.

Once an invoice is issued the plugin emits infakt.invoice.issued, unless emitIssuedEvent is set to false, so another plugin can react - attaching the PDF to a marketplace order, for instance.

On this page