Orders and invoices
Draining Allegro's order event journal into Medusa orders, the derived status ladder, fulfillment write-back, and attaching an issued invoice PDF to the Allegro order.
Why the event journal, and nothing else
GET /order/events is the only scheduled input. Polling checkout forms with
updatedAt.gte= cannot replace it, because Allegro does not reliably bump a
form's updatedAt when only its fulfillment status changed - so a window sweep
cannot see the most common status change there is.
The drain runs on a 20 second interval rather than a cron expression, because
Medusa's cron only resolves to the minute and a fresh order should be drained
sub-minute. ALLEGRO_ORDERS_SYNC_CRON switches it back to cron if you prefer;
the two are mutually exclusive in Medusa's scheduler and the cron wins when both
are set.
Cursor discipline
Events are consumed in order, and the cursor advances only over the leading run of events whose order landed. The first event belonging to a failed or deferred order stops the advance, so it and everything after it replay next tick.
Applying an order twice is harmless, because the upsert is idempotent. Losing a status change to a transient failure is not, which is why the cursor is conservative in that direction.
Three failure modes a single-input sync has to answer for:
One bad order must not wedge the tick. After five consecutive failures the form is quarantined and the cursor is allowed past it. Without that escape, one permanently broken order pins the cursor forever and eventually nothing imports at all.
An outage must not be mistaken for a hundred bad orders. A tick where every refresh failed and none succeeded is systemic: no streak grows, nothing is quarantined, the cursor holds.
A backlog must not starve new orders. The per-run cap is spent oldest-first, because that is the only order in which a backlog shrinks - but 20 of the 100 are reserved for the newest candidates, applied out of cursor order. Pure newest-first was rejected because it deadlocks: deferring the oldest blocks the cursor at the first event, so the same page replays forever.
Bootstrap
With no cursor, the newest event id is recorded and nothing is consumed. Replaying the sixty days Allegro retains would be thousands of calls, so a fresh install starts tracking from "now" and importing history is a deliberate operator action.
The status ladder
Allegro reports a checkout status and a seller-managed fulfillment status. Their
product is allegro_order.derived_status:
| Allegro | Derived |
|---|---|
checkout CANCELLED (wins over anything) | cancelled |
fulfillment NEW + checkout BOUGHT | pending |
fulfillment NEW + READY_FOR_PROCESSING | new |
PROCESSING, SUSPENDED | processing |
READY_FOR_SHIPMENT, READY_FOR_PICKUP | ready_for_shipment |
SENT | sent |
PICKED_UP | delivered |
RETURNED | returned |
| an unmodelled fulfillment status | nothing is written |
Medusa's order.status enum has no sent or ready_for_shipment, so only the
two ends Medusa genuinely models are pushed onto the order, through their own
workflows: cancelled cancels it, delivered completes it. Writing the column
directly would fight the dashboard and the order-edit flows.
derived_status is the comparison basis, not the raw status columns. The raw
columns are rewritten on every pass, so re-deriving from them made a single
suppressed status write permanent - the guard saw "no transition" forever after
and the order froze at whatever status it happened to carry. derived_status is
written in the same operation as any action, so a lost write simply retries, and a
staff edit survives because staff change the order and leave the derived status
where Allegro put it.
Crash-safe ordering. The bookkeeping row goes in first without synced_at,
then the Medusa order, then the status action, then the watermark last. A crash
anywhere earlier leaves the row unfinished so the next pass repairs it.
What the plugin will not do to an order
Unmatched lines do not lose the sale. A line whose sygnatura matches no Medusa
variant is carried as a title-only custom item and recorded in line_conflicts.
The sale happened on Allegro whatever Medusa's catalogue says, and an order nobody
can see is not safer than one that is visibly half-mapped.
Totals and line prices come from Allegro verbatim, never recomputed. Money is
a decimal string end to end, because round-tripping through a float is how a sync
starts pushing 233.20999999999998.
A disputed total is recorded, not corrected. Every order's Medusa total is
compared against the totalToPay Allegro recorded for the form, to the grosz and
in the same currency. A disagreement lands on allegro_order.conflict as
total-mismatch with both figures in conflict_detail, and the Orders page shows
it beside the total. It never blocks or rolls back the order, and it clears itself
on the next pass once the totals agree. The usual benign cause is a title-only
custom line, which can legitimately move the total - the detail says how many
custom lines the order has for exactly that reason, so check that count before
investigating arithmetic.
Note also that imported orders carry no computed tax lines. Taking line prices from Allegro verbatim is right for reconciliation, but it means an Allegro order's tax breakdown in Medusa is empty.
Reservations, so the order can be fulfilled
Medusa's createOrderWorkflow - the workflow every Allegro order is created with -
validates that stock exists and then deliberately creates no inventory
reservation. Only cart checkout reserves, and no Allegro order goes through a
cart. Core's fulfillment refuses any line whose variant manages inventory and has
no reservation, so without this the admin's own Fulfill button, and every
plugin that fulfils, failed with No stock reservation found for item ordli_... -
leaving paid, delivered orders reading as unfulfilled and stalling the fulfillment
write-back above behind them.
So the drain creates the reservations itself, through core's own
createReservationsWorkflow, immediately after the order exists and before the
payment is recorded - the payment emits payment.captured, which is the head of
the chain that ends in a fulfillment, so a reservation landing after it could still
lose the race.
Three properties make it safe to run on every pass, which is what it does:
- Idempotent. Reservations that already exist are subtracted from what is needed, per line and per inventory item. A reserved order costs two reads and writes nothing.
- It never over-reserves. The quantity is
required_quantity x (ordered - already fulfilled). Fulfilled units have already consumed their reservation. - It never fails the order. A line whose inventory item is stocked at no location is logged as a warning and skipped. That is a catalogue problem for a human; losing the sale over it would be worse.
Because it runs on every pass, the reconciliation sweep heals the orders created
before this existed: the next sweep that reaches such an order creates its
reservation and it becomes fulfillable, with no backfill script to run. The job
line reports reservationsCreated when a sweep did that. It is deliberately not
counted as a reconcileRepaired: every order predating this needs a reservation,
so folding it in would report a healthy event journal as broken for as long as the
backfill runs.
A cancelled order is skipped - holding stock for a sale that is not happening costs the store real money on every other order.
order.placed for a marketplace sale
Medusa does not emit order.placed for an order created through
createOrderWorkflow, which is the workflow this drain calls. In Medusa 2.18 that
event is emitted by completeCartWorkflow (a storefront checkout) and by
convertDraftOrderWorkflow, each beside their create rather than inside it - so
until this plugin emitted it, an Allegro sale announced itself to nothing. Anything
subscribed to order.placed (a Slack announcement, an ERP push, a mailing) was
structurally deaf to marketplace orders while looking perfectly healthy: the
subscriber was registered, the order existed, and no error was logged anywhere.
So the drain emits it, with core's payload verbatim - { id }, at
EventPriority.CRITICAL - because a subscriber must never have to know which
channel an order arrived through.
Exactly once per order. It is emitted only by the pass that actually created the order. A redelivered Allegro event, a forced refresh, the reconciliation sweep and the adoption path that picks up an order a crashed pass left behind all take the "this form already has an order" branch, and none of them announce. An order that was adopted is never announced, on purpose: adoption also runs for an order a crashed pass created minutes ago, and a duplicate "new order" cannot be taken back.
It is never a reason to hold the event cursor. If the event bus is unavailable, the failure is a warning on the order and the drain moves on - stalling every later order behind an undelivered notification would be a much worse outcome, and the retry would find the order already created and correctly not announce it anyway.
allegro.order.billing_ready, when the order can finally be invoiced
An Allegro order is created from a checkout-form snapshot taken before the buyer
has finished the form, so it very often has no billing address. The buyer's
details arrive minutes later, on a later drain pass. Meanwhile the payment
finalises and payment.captured fires, an invoicing plugin subscribed to that
event builds an invoice against an order with no address, fails its own
completeness gate and parks the order for a human. Measured on one live order:
12:32:22 order created (the buyer had not paid, and had not finished the form)
12:36:22 payment captured
12:36:24 the invoicing plugin queued the order off `payment.captured`
12:36:25 "buyer address is incomplete (missing: street, city, postal_code)"
12:36:41 the drain writes the real billing address - 16 s too lateNothing was broken in either plugin. The invoicing side subscribed to the event
that carries the fact it reacts to, rather than the event that carries the data
it needs - and there was no such event, because the billing address and the tax id
are written through the Order module service rather than updateOrderWorkflow (see
medusajs/medusa#16636), so they land with no order.updated and no event at all.
So the drain announces it: allegro.order.billing_ready, payload { id }, at
EventPriority.CRITICAL, the same shape order.placed carries. Import the name
from @zanreal/medusa-allegro/workflows rather than retyping the string.
It fires on the edge, not on the state. Exactly one of two things emits it:
- the creating pass, when the order was created already carrying a usable billing address - otherwise an order whose buyer had finished the form before we ever saw it would never get the event, since there is then nothing left for the address or tax-id fill to do; or
- a later pass that actually changed something - it filled in an address or wrote the tax id - and left the billing address complete.
Both signals are per-pass, so the ~20s drain ticks on either side of the change announce nothing. Completeness means the three fields an invoice builder demands - street, city, postal code - all non-blank, read off the values the order holds.
It says the data is there. It does not say "invoice this now." The event is deliberately not gated on payment or on order status; a subscriber decides for itself whether the order is paid, cancelled or already invoiced. Reading it as an instruction would fire invoices at cancelled orders.
It is never a reason to hold the event cursor, for the same reason
order.placed is not: the data is already written, and a retry would find it
complete and correctly not emit. A lost announcement costs a warning and falls back
to whatever retry the consumer already has.
A half-written billing address is a gap, not an address
Worth knowing because it defeated the repair silently for months. Allegro will hand
back an invoice block that carries only a name and a country code, and the checkout
form reader returns an address if any field is set - so the order gets created
with a billing_address row that has no street, no city and no postal code.
The repair used to ask "does this order have a billing address row?", which that row answered yes to, forever. The three fields an invoice needs were therefore never filled in on any pass, on exactly the orders that most needed it. The question is now "is this order's billing address usable", asked of the field values, and a partial address is repaired like a missing one - filling only the blanks, so a value the row already carried (or that a human corrected) is never overwritten.
Fulfillment write-back
A subscriber on order.fulfillment_created and shipment.created sets Allegro's
seller-managed status - READY_FOR_SHIPMENT and SENT respectively - for
Allegro-sourced orders. It is a no-op for any other order.
The event is the fast path, and it is the only thing that reports a fulfillment
promptly. But it is no longer the only signal, and that correction matters: a
fulfillment is a point-in-time act, yet "this order has a fulfillment that has
shipped" is ordinary durable state sitting on fulfillment.shipped_at. The
reconciliation sweep compares exactly that against the status Allegro reports.
It never throws. The Medusa fulfillment already exists by the time it runs, so
failing the subscriber would not undo it and would bury the reason. The error is
recorded on allegro_order.last_error instead.
The sweep is the retry path
The subscriber fires once per shipment. Until 2026-08-23 that was the only attempt
there ever was, and a live order proved what that costs: the write-back failed, the
buyer had their licence key, and the Allegro order read READY_FOR_SHIPMENT for
three days with nothing anywhere saying why.
So the reconciliation sweep - which already re-reads every open Allegro order, on the slow tier - gained one more comparison. No new schedule was added, and none is wanted: this rides the sweep that already runs.
For each open order it re-reads, if Medusa has a live fulfillment with shipped_at
set and the form Allegro just returned is not SENT, it pushes SENT through
pushAllegroFulfillment - the subscriber's own workflow, not a second way to write
a fulfillment status.
Four refusals keep it from becoming a second writer rather than a backstop:
- Allegro already says
SENT. The idempotency gate. An order whose write-back landed costs zero marketplace writes on every sweep after it. - The shipment is younger than the grace window (
ALLEGRO_ORDERS_RECONCILE_SENT_GRACE_MS, 10 min by default). The subscriber gets first refusal. Without this the sweep would race it - see the read-model lag below, which is the reason the window exists at all. - The order is
delivered,returnedorcancelled, or carries a fulfillment status this plugin does not model.SENTis either rejected there or walks a finished order backwards. - Nothing shipped. A cancelled fulfillment is not a shipment and is ignored.
A push the sweep makes is logged at warn and reported on the job line as
fulfillmentsPushed, because it means the subscriber's write never landed. A push
that fails is counted as fulfillmentPushFailures, carries its reason on
allegro_order.last_error, and is retried by the next sweep - which is the whole
difference from what came before, where a failed write-back left no trace at all.
The same toggle governs both paths, so disarming fulfillment_writeback_enabled
stops the sweep pushing as well as the subscriber.
One known, accepted wrinkle: last_error is a single column shared by three
writers, so an invoice attach that succeeds on the same tick clears a SENT push
failure recorded moments earlier in the same run. It is bounded rather than lossy
- the next open-tier sweep retries the push and re-writes the reason - and the run's own error line reports the failure either way.
It has its own toggle, fulfillment_writeback_enabled, and deliberately does
not read ordersSyncDisabled. That switch stops the drain from consuming the
journal, and pausing an import is a different decision from refusing to tell a
buyer that a shipment happened. So if a mapping is suspect and you are worried
about marking the wrong Allegro order as shipped, disarm this one writer, fix the
mapping, and re-arm - order import is unaffected, and there is no reconnect.
One known wrinkle: a store that creates a fulfillment and a shipment in one action sends two updates. The second wins, which is correct but wasteful.
Allegro's read model lags its own writes, by about 45 seconds
Worth knowing before you debug anything in this area, because it looks exactly like a failure and is not one.
Measured rather than assumed: while repairing a stranded order by hand on
2026-08-25, a PUT /order/checkout-forms/{id}/fulfillment returned 2xx and the
very next GET /order/checkout-forms/{id} still reported READY_FOR_SHIPMENT.
Only a re-read roughly 45 seconds later reported SENT.
A status you read back immediately after pushing it is stale, not rejected. Do not retry on that reading and do not conclude the write failed - wait and re-read.
It is also the whole reason the grace window above exists. Without it the sweep
would re-read moments after a successful subscriber push, see
READY_FOR_SHIPMENT, and "repair" an order that was never broken - reporting a
healthy write-back as failing.
The invoice chain
Allegro expects the invoice for an order to be downloadable from the order view. This plugin does not issue invoices, so the chain is:
order paid -> an invoicing module issues the invoice
-> emits `infakt.invoice.issued`
-> medusa-allegro registers the document on the checkout form
-> uploads the PDF
-> stamps allegro_order.invoice_attached_atNeither plugin imports the other. The whole contract is an event name, its
payload, and a container key (invoiceModuleKey) that this plugin resolves
lazily. A store can invoice without selling on Allegro and sell on Allegro without
invoicing, so a hard dependency either way would make each plugin unusable without
the other. With no invoicing module registered the chain is simply inert: nothing
is logged, nothing is retried, and every other loop behaves identically.
The event payload is read defensively, because it crosses a version boundary.
order_id and invoice_uuid are required, everything else is optional, unknown
fields are ignored, and a malformed payload is logged and dropped rather than
thrown - a throwing subscriber would be retried with the same malformed payload
until its budget ran out, and the reason would never reach anybody.
Why the dedupe read is not optional
POST /order/checkout-forms/{id}/invoices has no idempotency key. A second
call with the same invoice number registers a second document rather than
returning the first, and Allegro accepts at most ten documents per order. So two
things happen before any create:
- If
allegro_order.allegro_invoice_idis set, that document is reused outright. - Otherwise
GET .../invoicesis read and matched on invoice number. A match is reused.
The id is persisted the instant a create returns, before the upload is attempted. That write is the whole reason the column exists: a crash between a successful create and the upload would otherwise register a second document for the same invoice on the retry.
The size check also happens before anything is registered. Allegro rejects a file over 3 MB, and a registered document with no file still counts against the ten, so registering first could eventually leave an order unable to accept the invoice that would fit. An oversized or empty PDF is recorded on the row and never uploaded.
Two further details are worth knowing. The file is uploaded as the raw request
body with Content-Type: application/pdf, not JSON and not multipart - handing
a Uint8Array to JSON.stringify yields {}, which Allegro accepts, leaving
the order carrying an invoice document with a two-byte file and nothing reporting
a failure. And fetching the PDF flips the invoice to printed on the invoicing
side, which is that system recording that the document left it rather than a
mistake, so this plugin resolves the Allegro client before fetching: paying that
side effect for an upload that cannot happen buys nothing.
The retry sweep
The event is not the only path. At the end of every orders drain, inside the same
single-flight claim because it writes to the same rows, a bounded sweep asks the
invoicing module for its 50 most recently touched invoices and attaches any whose
Allegro order has no invoice_attached_at, up to 10 per tick. It covers both
halves of a failed attach: the one that registered nothing, and the one that
registered but never uploaded.
Candidates come from the invoicing module rather than from a marker of this
plugin's own, deliberately. Attach failures are recorded in
allegro_order.last_error, which is shared with the drain and cleared on its next
clean pass of the same form, so a sweep keyed off that string would silently lose
exactly the orders that are otherwise healthy. Comparing "what has been issued"
against invoice_attached_at cannot be invalidated that way.
Two limitations, stated rather than left to be discovered:
- The sweep only runs when the drain runs, so
ordersSyncDisabledpauses the retry as well. The event path is unaffected, so a newly issued invoice still lands. - An attach failure can be overwritten in
last_errorby a later healthy drain pass of the same form. The sweep is unaffected, but the admin may stop showing the reason; the run's own error line on the orders state row names the count.
Reading a rejected attach
A failure line names which of the four calls failed - the PDF fetch from the
invoicing module, the dedupe read, the metadata create, or the file upload - plus
everything in Allegro's errors[] (code, path, message, userMessage), the
x-request-id to quote at Allegro support, and the two values this plugin chose:
file.name and invoiceNumber. Those two are the whole of what a rejected create
can be about, and none of it is buyer data - the invoice endpoints take a filename
and an invoice number and nothing else. The calls also ask for Accept-Language: en-US, so Allegro's userMessage arrives in the same language as the rest of the
log.
This matters because AllegroApiError.message alone is only the first
userMessage Allegro returned, and for an HTTP 400 that string is routinely the
generic "Bad Request" while the code and path that name the offending field go
unread. A 400 with no reason is not something an operator can act on.
One deliberate experiment on a 400. Allegro documents no 400 for
POST .../invoices at all - the documented rejections are 403 (scope), 404, 409
(the order already has an invoice), 422 (the order will not take one) and 429 (too
fast) - and its schema requires exactly one field, file. So invoiceNumber is
the only field whose value can make an otherwise legal body illegal. When a
create carrying a number is rejected with a 400, and only then, the attach retries
once with the number omitted. If Allegro accepts that, the buyer gets the invoice
and the cause is proven, both said plainly in the log; if it rejects that too, the
body is exonerated and both rejections are on the record. No other status is ever
retried this way, because each of them means something a blind retry would make
worse.
Its own kill switch
Invoice attach has its own toggle, invoice_attach_enabled, and it is the one
writer that ships on - because by the time this plugin hears about an invoice
it already exists as a legal document, so delivering it is the safe default. It is
on but inert until an invoicing module is emitting events.
It is deliberately not a reading of ordersSyncDisabled. That switch stops the
drain from consuming the journal, and an operator reaches for it to halt a runaway
import; delivering an invoice the marketplace order needs is a different decision
with different consequences. One switch covering both would mean pausing an import
silently stops issued invoices reaching buyers.
When the writer is disarmed, the reason is recorded on the order row rather than only logged. "The invoice is not on the order" looks identical to a broken integration from outside, and a disarmed writer is the one explanation nobody guesses.
Operator tools
A quarantined order. The Orders page quarantine list carries each entry's error and how long it has been failing. Fix the cause, then press Repair. A success clears both failure maps and hands the form back to the drain. A failed repair does not grow the streak, so retrying while you work on it cannot make things worse. If the order is older than Allegro's event retention, Repair still works - it fetches the form directly rather than through the journal.
Importing orders the journal never named. The Import window on the Orders
page covers the cases the drain structurally cannot: the drain was disabled longer
than the retention window, a database was restored, the cursor was lost, or you
want history on a new install. It never moves the event cursor, because an import
fills a gap behind it, and it holds the orders claim while it runs so the drain
cannot import anything new in the meantime. One run covers at most 3,000 orders;
for a larger backfill, run several windows with a moving since.
Allegro order quirks worth knowing
A checkout form carries up to three different people. buyer is the account
holder's registration data, which no Allegro seller ever sees in their own UI;
delivery.address is the buyer-entered shipping recipient, and it is what the
seller sees against the order; invoice.address is the invoice recipient, which
can legitimately name someone else again. Reading only the first is how an
integration ends up displaying a name the Allegro seller panel contradicts. This
plugin puts delivery.address on the Medusa shipping address and
invoice.address on the billing address, falling back to shipping rather than to
the account holder.
The Medusa customer is the account holder, and only ever the account holder.
That is what a customer entity is: the identity behind the account the order came
from, which is also whose relay address customer.email already holds. So
buyer.firstName / buyer.lastName - plus buyer.companyName for a company
account - are written onto the customer, and the delivery recipient is never
borrowed to fill an empty one. The two are routinely different people (a gift, an
office delivery), and a wrong name is worse than a missing one. Names are filled
only where the customer's own field is empty: anything a staff member set by hand
is left exactly as it is, so the fill is safe to repeat. Orders created before this
existed have customers with no name at all; the reconciliation sweep fills them in
as it re-reads each open order, with nothing to run by hand.
The buyer block spells the postal code postCode, not zipCode, alone among
the addresses in a checkout form. Typing it as the common address shape silently
drops it.
An invoice company's tax id lives in a typed array now. company.ids with a
PL_NIP entry is the current source; the flat company.taxId is deprecated but
still populated, so it stays as the fallback. The other id types Allegro can
return are foreign registration numbers of similar length to a NIP, so this plugin
deliberately does not read them - a wrong pairing is worse than no pairing.
RETURNED is Allegro-managed. It appears once every unit is returned and
refunded, and the fulfillment endpoint rejects it, so it is readable but never
settable.