This is a cross-system architecture pattern card covering compensating transaction design across major ERP platforms. It applies to any integration where two or more ERPs participate in a multi-step business process and true distributed transaction support (2PC/XA) is unavailable — which is virtually every cloud ERP integration scenario.
| System | Role | Compensation Mechanism | Direction |
|---|---|---|---|
| Salesforce | CRM — opportunity/order source | Undelete, status reversal, credit memos | Outbound |
| SAP S/4HANA | ERP — financial/logistics master | Reversal documents (FB08), storno, cancellation | Inbound |
| Oracle NetSuite | ERP — mid-market financial | Void (transaction.void), reversing journals | Inbound |
| Microsoft Dynamics 365 | ERP — finance and operations | Cancel, reverse, corrective posting | Inbound |
| Orchestrator (Temporal/Step Functions) | Saga coordinator | Compensation registry, retry, DLQ | N/A |
Understanding each ERP's reversal capabilities is the foundation of compensation design. Every ERP handles "undo" differently, and some operations simply cannot be reversed via API. [src4, src5, src6, src8]
| ERP | Operation | Compensation Method | API Support | Limitations |
|---|---|---|---|---|
| Salesforce | Record create | Delete (soft) to Recycle Bin | REST DELETE /sobjects/{Object}/{Id} | Hard-deleted records cannot be recovered |
| Salesforce | Record delete (soft) | Undelete | SOAP undelete() or REST PATCH | 15-day Recycle Bin retention limit |
| Salesforce | Opportunity close | Reopen (status change) | REST PATCH StageName | Dependent workflows may have already fired |
| Salesforce | Email send | Non-compensable | N/A | Cannot unsend — send a follow-up correction |
| SAP | FI document posting | Reversal document (FB08/BAPI) | BAPI_ACC_DOCUMENT_REV_POST | Original period must be open; reason code required |
| SAP | Goods receipt (MIGO) | Reversal movement type (102) | BAPI_GOODSMVT_CREATE / API_GOODSMVT_2 | Partial quantity reversal supported |
| SAP | Customer payment | Reset clearing (FBRA) then reverse (FB08) | BAPI | Must reset clearing first — cannot skip |
| SAP | Billing document | Cancel billing (VF11) — creates offsetting doc | BAPI_BILLINGDOC_CANCEL | Original must not be cleared/settled |
| NetSuite | Invoice/payment | Void (creates reversing journal) | transaction.void() SuiteScript | Requires preference disabled for API use |
| NetSuite | Sales order | Close/cancel line items | SuiteTalk/REST | Fulfilled lines cannot be voided |
| D365 Finance | GL journal | Reverse transaction | OData: GeneralJournalEntries reversal | Creates offsetting entry, maintains audit trail |
| D365 Finance | Vendor payment | Reverse/void check | OData | Unapplies settlement automatically |
| D365 Finance | Purchase order receipt | Cancel product receipt | OData | Must cancel before invoice matching |
There are two fundamental approaches to handling saga step failures. [src1, src3]
Backward recovery (compensating transactions): Undo completed steps in reverse order. Use when operations are reversible and the business process can be abandoned.
Forward recovery (retry + continue): Retry the failed step until it succeeds. Use when the process must complete (e.g., payroll initiated, goods shipped).
BACKWARD RECOVERY (Compensating Transactions)
Step 1: Create Salesforce Opportunity OK Committed
Step 2: Create SAP Sales Order OK Committed
Step 3: Create NetSuite Invoice FAILED
-> Compensate Step 2: Cancel SAP Sales Order
-> Compensate Step 1: Revert SF Opportunity to "Open"
FORWARD RECOVERY (Retry + Continue)
Step 1: Create Salesforce Opportunity OK Committed
Step 2: Create SAP Sales Order OK Committed
Step 3: Create NetSuite Invoice FAILED
-> Retry Step 3 with exponential backoff
-> If retries exhausted: human escalation, do NOT undo Steps 1-2
Decision rule: If the process involves irrevocable side effects (payment captured, goods shipped, regulatory filing submitted), use forward recovery for steps after the irrevocable point. Use backward recovery for steps before it.
Orchestration is strongly preferred over choreography for ERP compensation because: (1) ERP APIs have inconsistent event models, (2) compensation ordering requires centralized state, and (3) debugging cross-ERP failures requires a single audit trail. [src2, src3, src7]
SAGA ORCHESTRATOR FLOW
Orchestrator
|
+-- Step 1: CreateSalesforceOrder()
| +-- onSuccess: record SF Order ID, register compensateStep1()
| +-- onFailure: ABORT (nothing to compensate)
|
+-- Step 2: CreateSAPSalesOrder(sfOrderId)
| +-- onSuccess: record SAP SO number, register compensateStep2()
| +-- onFailure: COMPENSATE [Step 1]
|
+-- Step 3: CreateNetSuiteInvoice(sapSONumber)
| +-- onSuccess: record NS Invoice ID, register compensateStep3()
| +-- onFailure: COMPENSATE [Step 2, Step 1]
|
+-- Step 4: CapturePayment(nsInvoiceId) NON-COMPENSABLE
+-- onSuccess: SAGA COMPLETE
+-- onFailure: COMPENSATE [Step 3, Step 2, Step 1]
START — User needs to handle failures in multi-ERP workflows
+-- Is the failed operation within a single ERP?
| +-- YES -> Use the ERP's native rollback (SAP LUW, SF trigger rollback)
| +-- NO -> Distributed workflow, continue
+-- How many systems have already committed?
| +-- 0 -> No compensation needed — just fail and retry
| +-- 1 -> Simple bilateral compensation — reverse the one committed op
| +-- 2+ -> Full saga with compensation registry
+-- Are all committed operations reversible via API?
| +-- YES -> Backward recovery (compensating transactions)
| | +-- < 5 steps? -> Inline compensation logic
| | +-- >= 5 steps? -> Use Temporal or Step Functions orchestrator
| +-- PARTIALLY -> Hybrid approach
| | +-- Order steps: compensable first, non-compensable last
| | +-- For compensable steps -> backward recovery
| | +-- For non-compensable steps -> forward recovery (retry)
| +-- NO -> Forward recovery only (retry failed step until success)
+-- What if compensation itself fails?
| +-- Financial/regulated -> Dead letter queue + manual reconciliation
| +-- Best-effort -> Log failure + alert + scheduled retry
| +-- Can retry indefinitely -> Temporal durable execution
+-- Orchestration tool?
+-- Need durability guarantees -> Temporal (code-first, replay-safe)
+-- AWS-native -> Step Functions (state machine)
+-- iPaaS-based -> MuleSoft/Boomi/Workato with error handlers
+-- Simple (< 3 steps) -> Custom code with compensation array
| Operation | Salesforce | SAP S/4HANA | NetSuite | D365 Finance |
|---|---|---|---|---|
| Create record | DELETE (soft delete) | Reversal document | Void / Delete | Reverse posting |
| Update record | PATCH (restore old values) | Correction document | PATCH (restore) | Corrective posting |
| Delete record | undelete() (SOAP) | Re-post original | Restore from Recycle Bin | Re-create |
| Post journal | N/A (no GL) | FB08 reversal | Reversing journal | Reverse journal |
| Post invoice | Credit memo | Cancel billing (VF11) | Credit memo / Void | Credit note |
| Process payment | Refund object | Reset + reverse (FBRA+FB08) | Void / Refund | Void check / reverse |
| Goods receipt | N/A | Movement type 102 | Item receipt reversal | Cancel product receipt |
| Close period | N/A | Non-compensable (reopen) | Non-compensable (reopen) | Non-compensable (reopen) |
| Send email | Non-compensable | Non-compensable | Non-compensable | Non-compensable |
| Print/mail check | Non-compensable | Non-compensable | Non-compensable | Non-compensable |
Before writing any saga code, map every step to its compensating action. Register the compensation BEFORE executing the step — this prevents orphaned state when an activity times out mid-execution. [src2]
// Compensation registry pattern — register BEFORE execute
interface SagaStep<T> {
name: string;
execute: () => Promise<T>;
compensate: (result: T) => Promise<void>;
}
class CompensationRegistry {
private completedSteps: Array<{
name: string;
compensate: () => Promise<void>;
}> = [];
async executeStep<T>(step: SagaStep<T>): Promise<T> {
const placeholder = { name: step.name, compensate: async () => {} };
this.completedSteps.unshift(placeholder);
const result = await step.execute();
placeholder.compensate = () => step.compensate(result);
return result;
}
async compensateAll(): Promise<CompensationReport> {
const report: CompensationReport = { succeeded: [], failed: [] };
for (const step of this.completedSteps) {
try {
await step.compensate();
report.succeeded.push(step.name);
} catch (err) {
report.failed.push({ name: step.name, error: err });
}
}
return report;
}
}
Verify: The registry should contain entries in reverse execution order.
Each ERP has different API semantics for undoing operations. Define typed compensating action factories for each ERP. [src4, src5, src6, src8]
// Salesforce compensation actions
const salesforceCompensations = {
deleteRecord: (objectType: string, recordId: string) => async () => {
await sfClient.delete(`/sobjects/${objectType}/${recordId}`);
},
undeleteRecord: (recordId: string) => async () => {
await sfClient.soap('undelete', { ids: [recordId] });
},
};
// SAP S/4HANA compensation actions
const sapCompensations = {
reverseDocument: (companyCode, docNumber, fiscalYear, reasonCode) => async () => {
await sapClient.call('BAPI_ACC_DOCUMENT_REV_POST', {
OBJECTKEY: docNumber, BUKRS: companyCode,
GJAHR: fiscalYear, REASON_REV: reasonCode,
});
},
reverseGoodsMovement: (materialDoc, year) => async () => {
await sapClient.post('/API_GOODSMVT_2/GoodsMovement', {
GoodsMovementType: '102', MaterialDocument: materialDoc,
MaterialDocumentYear: year,
});
},
};
// NetSuite compensation actions
const netsuiteCompensations = {
voidTransaction: (transactionId, tranType) => async () => {
await nsClient.post('/transaction/void', {
id: transactionId, type: tranType,
});
},
};
// Dynamics 365 Finance compensation actions
const d365Compensations = {
reverseJournal: (journalBatchNumber) => async () => {
await d365Client.post('/GeneralJournalEntries/reverse', {
JournalBatchNumber: journalBatchNumber,
});
},
};
Verify: Each compensating action should be independently testable.
Connect your steps and compensations in an orchestrator. [src2, src7]
async function orderToPaySaga(order: OrderRequest): Promise<SagaResult> {
const registry = new CompensationRegistry();
try {
const sfOppId = await registry.executeStep({
name: 'createSalesforceOpportunity',
execute: () => sfClient.createOpportunity(order),
compensate: (oppId) => salesforceCompensations.revertStatus(
'Opportunity', oppId, 'Prospecting')(),
});
const sapSONumber = await registry.executeStep({
name: 'createSAPSalesOrder',
execute: () => sapClient.createSalesOrder(order, sfOppId),
compensate: (soNumber) => sapCompensations.reverseDocument(
order.companyCode, soNumber, currentFiscalYear(), '01')(),
});
// Step 3, Step 4 (non-compensable last)...
return { status: 'completed', sfOppId, sapSONumber };
} catch (error) {
const report = await registry.compensateAll();
if (report.failed.length > 0) {
await deadLetterQueue.publish({ sagaId: order.sagaId, compensationFailures: report.failed });
}
throw new SagaCompensationError(error, report);
}
}
Verify: Test by injecting a failure at each step and confirming all prior steps are compensated.
When a compensating transaction itself fails, route to a dead letter queue — do not recurse. [src1, src2]
class CompensationFailureHandler {
private maxRetries = 3;
async handleFailedCompensation(
sagaId: string, failedStep: string,
compensateAction: () => Promise<void>, attempt = 1
): Promise<void> {
try {
await compensateAction();
} catch (error) {
if (attempt < this.maxRetries) {
await sleep(Math.pow(2, attempt) * 1000);
return this.handleFailedCompensation(sagaId, failedStep, compensateAction, attempt + 1);
}
// Exhausted retries — dead letter queue
await this.dlq.publish({ sagaId, failedStep, attempts: attempt,
error: error.message, requiresManualIntervention: true });
}
}
}
Verify: Confirm DLQ receives messages for steps that fail compensation after max retries.
# Input: List of saga steps with execute/compensate callables
# Output: SagaResult with status and compensation report
import asyncio
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
@dataclass
class SagaStep:
name: str
execute: Callable[[], Awaitable[Any]]
compensate: Callable[[Any], Awaitable[None]]
class SagaOrchestrator:
def __init__(self):
self._completed = []
async def run(self, steps):
results = {}
try:
for step in steps:
result = await step.execute()
self._completed.insert(0, (step.name, lambda r=result, s=step: s.compensate(r)))
results[step.name] = result
return {"status": "completed", "results": results}
except Exception as e:
report = await self._compensate_all()
return {"status": "compensated", "error": str(e), "report": report}
async def _compensate_all(self):
succeeded, failed = [], []
for name, comp_fn in self._completed:
try:
await comp_fn()
succeeded.append(name)
except Exception as e:
failed.append({"step": name, "error": str(e)})
return {"succeeded": succeeded, "failed": failed}
// Input: Order details from upstream system
// Output: Completed saga or compensated state with audit trail
import { proxyActivities } from '@temporalio/workflow';
const { createSFOpportunity, cancelSFOpportunity,
createSAPSalesOrder, reverseSAPDocument,
createNSInvoice, voidNSTransaction } = proxyActivities({
startToCloseTimeout: '30s',
retry: { maximumAttempts: 3 },
});
export async function orderToPayWorkflow(order) {
const compensations = [];
try {
compensations.unshift(() => cancelSFOpportunity(sfOppId));
const sfOppId = await createSFOpportunity(order);
compensations.unshift(() => reverseSAPDocument(sapSONum));
const sapSONum = await createSAPSalesOrder(order, sfOppId);
compensations.unshift(() => voidNSTransaction(nsInvId));
const nsInvId = await createNSInvoice(order, sapSONum);
return { status: 'completed', sfOppId, sapSONum, nsInvId };
} catch (err) {
for (const compensate of compensations) {
try { await compensate(); }
catch (compErr) { console.error('Compensation failed:', compErr.message); }
}
throw err;
}
}
| Scenario | Cause | Impact | Resolution |
|---|---|---|---|
| Compensation blocked by closed period | SAP/NetSuite fiscal period closed | Reversal document cannot be posted | Post reversal in current period with cross-period reference |
| Partial compensation success | Network timeout during compensation chain | System in inconsistent state across ERPs | DLQ + scheduled reconciliation job |
| Idempotency violation | Compensation executed twice due to retry | Double-reversal creates phantom transactions | Use idempotency keys per compensation action |
| Rate limit hit during compensation | Multiple compensations trigger ERP rate limiting | Remaining compensations delayed or blocked | Exponential backoff; space compensation calls |
| Record locked by another user | Concurrent ERP user editing same record | Compensation action fails with lock error | Retry with jitter (random delay 1-5s) |
| Non-compensable step already executed | Email sent, check printed, goods shipped | Cannot undo physical-world actions | Send correction notification; flag for manual handling |
| Recycle Bin expired (Salesforce) | 15-day retention passed before compensation | Cannot undelete the record | Re-create the record from audit log |
| Authorization expired mid-compensation | Long-running saga outlives auth token | 401 errors during compensation | Refresh token before each compensation step |
Post to current period with reason code indicating cross-period reversal. [src4]Create a reversing journal entry manually via SuiteScript. [src5]Batch compensations into groups of 150 or use Bulk API. [src8]Always unapply settlements before reversing payments. [src6]Set workflow timeouts generously or use child workflows for compensation. [src2]// BAD — no compensation logic, relies on "it usually works"
async function naiveOrderFlow(order) {
const sfId = await createSalesforceOpp(order);
const sapSO = await createSAPOrder(order, sfId); // What if this fails?
const nsInv = await createNetSuiteInvoice(order, sapSO); // sfId is orphaned
return { sfId, sapSO, nsInv };
}
// Result: Salesforce has an Opportunity with no matching SAP order
// GOOD — every step has a compensating action
async function compensatedOrderFlow(order) {
const registry = new CompensationRegistry();
try {
const sfId = await registry.executeStep({
name: 'sf_opp', execute: () => createSalesforceOpp(order),
compensate: (id) => deleteSalesforceOpp(id),
});
// ... remaining steps with compensation
return { sfId };
} catch (e) {
await registry.compensateAll();
throw e;
}
}
// BAD — retrying compensation without idempotency key
async function retryCompensation(action) {
for (let i = 0; i < 3; i++) {
try { await action(); return; }
catch (e) { /* retry */ }
}
}
// Result: SAP creates 3 reversal documents for 1 original
// GOOD — idempotency key prevents duplicate compensations
async function idempotentCompensation(sagaId, stepName, action) {
const key = `${sagaId}:${stepName}:compensate`;
const existing = await compensationLog.find(key);
if (existing?.status === 'completed') return;
await compensationLog.upsert(key, { status: 'in_progress' });
try {
await action();
await compensationLog.upsert(key, { status: 'completed' });
} catch (error) {
await compensationLog.upsert(key, { status: 'failed', error: error.message });
throw error;
}
}
// BAD — payment captured at Step 2, but Step 3 can fail
async function badStepOrdering(order) {
const sfId = await createSalesforceOpp(order);
const paymentId = await capturePayment(order); // NON-COMPENSABLE
const sapSO = await createSAPOrder(order, sfId); // If this fails...
// ...payment already captured but no SAP order. Refund takes 5-10 days.
}
// GOOD — non-compensable payment capture is the final step
async function correctStepOrdering(order) {
const sfId = await createSalesforceOpp(order); // compensable
const sapSO = await createSAPOrder(order, sfId); // compensable
const nsInv = await createNetSuiteInvoice(order, sapSO); // compensable
const paymentId = await capturePayment(order); // NON-COMPENSABLE (last)
}
Log every compensation action with original transaction ID, timestamp, reason, and compensating transaction ID. [src1]Use reversing journals for financial transactions in regulated environments. [src5]Check period status before SAP reversals; if closed, post to current period with cross-period reason code. [src4]Use conditional updates (ETags, version fields, SystemModstamp) to detect concurrent modifications. [src1]Include compensation testing in CI — inject failures at each saga step. [src2]Cap at one compensation attempt per step. If compensation fails after retries, route to DLQ. [src1, src7]# Check SAP posting period status (must be open for reversals)
curl -X GET "$SAP_BASE_URL/API_FISCALYEARPERIOD_SRV/FiscalYearPeriods?\
$filter=FiscalYear eq '2026' and FiscalPeriod eq '03'" \
-H "Authorization: Bearer $SAP_TOKEN"
# Salesforce: check if record is in Recycle Bin (available for undelete)
curl "$SF_INSTANCE/services/data/v62.0/queryAll/?q=\
SELECT+Id,IsDeleted+FROM+Opportunity+WHERE+Id='$OPP_ID'" \
-H "Authorization: Bearer $SF_TOKEN"
# D365 Finance: check if journal can be reversed
curl -X GET "$D365_BASE_URL/data/GeneralJournalEntries?\
\$filter=JournalBatchNumber eq '$JOURNAL_BATCH'" \
-H "Authorization: Bearer $D365_TOKEN"
# Check dead letter queue for failed compensations (AWS SQS example)
aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages
# Temporal: check workflow compensation status
tctl workflow show -w "$SAGA_WORKFLOW_ID" -r "$RUN_ID"
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Multi-ERP workflow with 3+ steps that must be consistent | Single-ERP operation with native transaction support | ERP's built-in transaction/rollback mechanism |
| Business process spans Salesforce + SAP + NetSuite + payment | Simple A-to-B sync between two systems | Direct API call with retry + dead letter queue |
| Regulatory requirement for complete audit trail of reversals | Read-only data extraction or reporting integration | CDC or batch export — no compensation needed |
| Operations are mostly reversible via ERP APIs | Most operations are non-compensable (email, physical shipment) | Forward recovery with retry until success |
| Failure rate is >0.1% and manual cleanup is too expensive | Failure rate is negligible and manual fix is acceptable | Scheduled reconciliation job (cheaper to build) |
| Capability | Salesforce | SAP S/4HANA | NetSuite | D365 Finance |
|---|---|---|---|---|
| Record deletion reversal | Undelete from Recycle Bin (15 days) | Re-post original document | Restore from Recycle Bin (limited) | Re-create record |
| Financial reversal | Credit memo (not true reversal) | Reversal document (FB08) — native | Void or reversing journal | Reverse transaction — native |
| Reversal API support | REST DELETE + SOAP undelete | BAPI + OData (comprehensive) | SuiteScript void() + REST | OData reversal endpoints |
| Period dependency | N/A (no fiscal periods in CRM) | Must be in open period | Must be in open period | Must be in open period |
| Partial reversal | Per-line-item possible | Partial quantity reversal (MIGO) | Per-line credit memo | Per-line corrective posting |
| Reversal audit trail | Recycle Bin + field history | Reversal document linked to original | Void memo on transaction | Reversing entry linked to original |
| Bulk compensation | Bulk API 2.0 delete | Mass reversal (F.80) | CSV import for mass void | Batch journal reversal |
| Idempotent reversals | No (must check status first) | No (must check if already reversed) | No (double-void throws error) | No (must check reversal status) |