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:

AllegroDerived
checkout CANCELLED (wins over anything)cancelled
fulfillment NEW + checkout BOUGHTpending
fulfillment NEW + READY_FOR_PROCESSINGnew
PROCESSING, SUSPENDEDprocessing
READY_FOR_SHIPMENT, READY_FOR_PICKUPready_for_shipment
SENTsent
PICKED_UPdelivered
RETURNEDreturned
an unmodelled fulfillment statusnothing 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 late

Nothing 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, returned or cancelled, or carries a fulfillment status this plugin does not model. SENT is 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_at

Neither 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:

  1. If allegro_order.allegro_invoice_id is set, that document is reused outright.
  2. Otherwise GET .../invoices is 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 ordersSyncDisabled pauses the retry as well. The event path is unaffected, so a newly issued invoice still lands.
  • An attach failure can be overwritten in last_error by 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.

On this page