An order saga partial failure happens when one part of a purchase succeeds and another doesn’t. One seller accepts the headphones, another rejects the charger, and your application has already accepted payment for both. A single “order failed” label can’t explain what should happen next.
We need to track each part before deciding the overall outcome. Let’s start there, then introduce the follow-up actions needed to repair a partial result.
Give each line its own state
Store order lines with seller, product variant, quantity, allocated amount and fulfilment state. Keep the original paid quote so you can explain which charges belong to each line.
The order’s overall state should be derived from those line states and payment facts. It shouldn’t overwrite them. “Partially accepted” carries more useful information than resetting the entire order to pending.
Delivery charges and discounts can be shared across lines. Decide how to allocate or recalculate them before issuing partial refunds. Don’t refund a line’s list price if the customer paid a discounted amount under a different policy.
Record the next action after each outcome
When a seller rejects a line, the system may need to release stock, cancel a shipment request or refund part of the payment. These are compensating actions: new operations that address an earlier partial success.
A compensation isn’t a time machine. A refund can fail or remain pending, and a parcel that has shipped may require a return process rather than cancellation.
Keep each action’s ID, state and external reference. Retrying the orchestration should find that existing action instead of creating another refund for the same rejected line.
Build an item-level coordinator
The example keeps an accepted line active and creates one refund intent for a rejected line. Replaying the same result doesn’t create another intent.
import assert from 'node:assert/strict';
const order={paid:3000,lines:[{id:'a',amount:2000,state:'waiting'},{id:'b',amount:1000,state:'waiting'}]};
const refunds=new Map();
function sellerResult(id, result) {
const line=order.lines.find(l=>l.id===id);
if(!line)throw new Error('Unknown line');
if(line.state!=='waiting') {
if(line.state!==result)throw new Error('Conflicting seller outcome');
return;
}
line.state=result;
if(result==='rejected')refunds.set(`refund:${id}`,{amount:line.amount,state:'requested'});
}
sellerResult('a','accepted');sellerResult('b','rejected');sellerResult('b','rejected');
assert.equal(order.lines[0].state,'accepted');
assert.equal(refunds.size,1);
assert.equal(refunds.get('refund:b').amount,1000);
assert.equal(refunds.get('refund:b').state,'requested');
assert.throws(()=>sellerResult('b','accepted'));
console.log('PASS: partial outcome preserves accepted line and one compensation intent');
The order remains partially fulfilled rather than becoming a fictional all-or-nothing result. The refund intent still needs a provider outcome before the customer can be told that money has been returned.
The fixture uses in-memory state to test the transition. Production needs durable transactions or an outbox so recording the rejected line and scheduling its compensation can’t separate during a crash.
Track refunds as a lifecycle
Creating a refund request isn’t always the same as completing it. Keep requested, pending, succeeded, failed and unknown outcomes according to the provider’s actual semantics. Refund lifecycle reference.
If the response disappears, reconcile the original refund operation. Don’t create another refund merely because your coordinator restarted and still sees a rejected line.
Keep total confirmed and pending refund amounts within the appropriate paid balance. Several failed lines must not independently refund the full order because each worker loaded the same remaining amount before either committed.
Coordinate without holding one giant transaction
You can’t hold a database transaction open while several sellers, a shipping service and a payment provider respond. Use short local transactions and durable progress between external calls.
For each step, record the intended operation before sending it. Apply results conditionally against the expected state or version. A delayed seller acceptance shouldn’t revive a line after compensation has already committed without an explicit reconciliation decision.
Use bounded retry policies. A permanent seller rejection isn’t a transient outage to retry indefinitely. A temporary connection failure may need investigation before you know whether the seller accepted the request.
Let the customer see the actual progress
Explain which items are confirmed, which can’t be supplied and what is happening to the affected payment. Don’t announce a complete refund while only the intent exists.
If the business offers substitution, keep that as a new proposal requiring the relevant confirmation. Replacing the rejected charger with a more expensive product isn’t just an implementation detail.
Support should see the same item-level timeline. Include seller responses, compensation attempts and unresolved operations so they can help without restarting the workflow blindly.
Test one failure at every step
Reject one seller before payment, after payment and after another seller has shipped. Lose a refund response, replay a seller result and restart after saving the compensation intent.
Check invariants: accepted items aren’t cancelled accidentally, rejected items don’t ship, and refund intent isn’t duplicated. Then check totals against the paid quote and the chosen discount and shipping policy.
The local test verifies one coordinator rule. It doesn’t certify a full marketplace payment system. Your real provider, seller and accounting integrations need their own contract and failure tests.
Start with line-level states and stable compensation IDs. Once every partial outcome is visible and repeatable, you can make the overall order summary useful without hiding the work still needed to resolve it.
Run the example and adapt it
Save the code as 18-partial-order-sagas.mjs and run it with Node.js 24 or later:
node 18-partial-order-sagas.mjs
The code above includes assertions you can run locally. It tests the local decision logic with controlled inputs; it doesn’t connect to a live provider or establish production performance. Keep that boundary when adapting it to your application.

