Compensating Transactions for ERP Integration

Type: ERP Integration System: Cross-ERP (Architecture Pattern) Confidence: 0.85 Sources: 8 Verified: 2026-03-07 Freshness: 2026-03-07

TL;DR

System Profile

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.

SystemRoleCompensation MechanismDirection
SalesforceCRM — opportunity/order sourceUndelete, status reversal, credit memosOutbound
SAP S/4HANAERP — financial/logistics masterReversal documents (FB08), storno, cancellationInbound
Oracle NetSuiteERP — mid-market financialVoid (transaction.void), reversing journalsInbound
Microsoft Dynamics 365ERP — finance and operationsCancel, reverse, corrective postingInbound
Orchestrator (Temporal/Step Functions)Saga coordinatorCompensation registry, retry, DLQN/A

Per-ERP Compensation Capabilities

What Can and Cannot Be Reversed

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]

ERPOperationCompensation MethodAPI SupportLimitations
SalesforceRecord createDelete (soft) to Recycle BinREST DELETE /sobjects/{Object}/{Id}Hard-deleted records cannot be recovered
SalesforceRecord delete (soft)UndeleteSOAP undelete() or REST PATCH15-day Recycle Bin retention limit
SalesforceOpportunity closeReopen (status change)REST PATCH StageNameDependent workflows may have already fired
SalesforceEmail sendNon-compensableN/ACannot unsend — send a follow-up correction
SAPFI document postingReversal document (FB08/BAPI)BAPI_ACC_DOCUMENT_REV_POSTOriginal period must be open; reason code required
SAPGoods receipt (MIGO)Reversal movement type (102)BAPI_GOODSMVT_CREATE / API_GOODSMVT_2Partial quantity reversal supported
SAPCustomer paymentReset clearing (FBRA) then reverse (FB08)BAPIMust reset clearing first — cannot skip
SAPBilling documentCancel billing (VF11) — creates offsetting docBAPI_BILLINGDOC_CANCELOriginal must not be cleared/settled
NetSuiteInvoice/paymentVoid (creates reversing journal)transaction.void() SuiteScriptRequires preference disabled for API use
NetSuiteSales orderClose/cancel line itemsSuiteTalk/RESTFulfilled lines cannot be voided
D365 FinanceGL journalReverse transactionOData: GeneralJournalEntries reversalCreates offsetting entry, maintains audit trail
D365 FinanceVendor paymentReverse/void checkODataUnapplies settlement automatically
D365 FinancePurchase order receiptCancel product receiptODataMust cancel before invoice matching

Compensation Design Patterns

Forward Recovery vs Backward Recovery

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.

Saga Orchestration for ERP Workflows

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]

Integration Pattern Decision Tree

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

Quick Reference

Compensating Actions Per ERP Per Operation Type

OperationSalesforceSAP S/4HANANetSuiteD365 Finance
Create recordDELETE (soft delete)Reversal documentVoid / DeleteReverse posting
Update recordPATCH (restore old values)Correction documentPATCH (restore)Corrective posting
Delete recordundelete() (SOAP)Re-post originalRestore from Recycle BinRe-create
Post journalN/A (no GL)FB08 reversalReversing journalReverse journal
Post invoiceCredit memoCancel billing (VF11)Credit memo / VoidCredit note
Process paymentRefund objectReset + reverse (FBRA+FB08)Void / RefundVoid check / reverse
Goods receiptN/AMovement type 102Item receipt reversalCancel product receipt
Close periodN/ANon-compensable (reopen)Non-compensable (reopen)Non-compensable (reopen)
Send emailNon-compensableNon-compensableNon-compensableNon-compensable
Print/mail checkNon-compensableNon-compensableNon-compensableNon-compensable

Step-by-Step Integration Guide

1. Design your compensation registry

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.

2. Define per-ERP compensating actions

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.

3. Wire the saga orchestrator

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.

4. Handle compensation failures

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.

Code Examples

Python: Saga Orchestrator with Compensation Registry

# 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}

JavaScript/Node.js: Temporal Workflow with Compensation

// 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;
  }
}

Error Handling & Failure Points

Common Error Scenarios

ScenarioCauseImpactResolution
Compensation blocked by closed periodSAP/NetSuite fiscal period closedReversal document cannot be postedPost reversal in current period with cross-period reference
Partial compensation successNetwork timeout during compensation chainSystem in inconsistent state across ERPsDLQ + scheduled reconciliation job
Idempotency violationCompensation executed twice due to retryDouble-reversal creates phantom transactionsUse idempotency keys per compensation action
Rate limit hit during compensationMultiple compensations trigger ERP rate limitingRemaining compensations delayed or blockedExponential backoff; space compensation calls
Record locked by another userConcurrent ERP user editing same recordCompensation action fails with lock errorRetry with jitter (random delay 1-5s)
Non-compensable step already executedEmail sent, check printed, goods shippedCannot undo physical-world actionsSend correction notification; flag for manual handling
Recycle Bin expired (Salesforce)15-day retention passed before compensationCannot undelete the recordRe-create the record from audit log
Authorization expired mid-compensationLong-running saga outlives auth token401 errors during compensationRefresh token before each compensation step

Failure Points in Production

Anti-Patterns

Wrong: Ignoring compensation — assuming failures are rare

// 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

Correct: Every step has a registered compensation

// 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;
  }
}

Wrong: Assuming ERP API operations are idempotent

// 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

Correct: Idempotent compensation with tracking

// 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;
  }
}

Wrong: Non-compensable steps early in the saga

// 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.
}

Correct: Non-compensable steps execute last

// 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)
}

Common Pitfalls

Diagnostic Commands

# 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"

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Multi-ERP workflow with 3+ steps that must be consistentSingle-ERP operation with native transaction supportERP's built-in transaction/rollback mechanism
Business process spans Salesforce + SAP + NetSuite + paymentSimple A-to-B sync between two systemsDirect API call with retry + dead letter queue
Regulatory requirement for complete audit trail of reversalsRead-only data extraction or reporting integrationCDC or batch export — no compensation needed
Operations are mostly reversible via ERP APIsMost operations are non-compensable (email, physical shipment)Forward recovery with retry until success
Failure rate is >0.1% and manual cleanup is too expensiveFailure rate is negligible and manual fix is acceptableScheduled reconciliation job (cheaper to build)

Cross-System Comparison

CapabilitySalesforceSAP S/4HANANetSuiteD365 Finance
Record deletion reversalUndelete from Recycle Bin (15 days)Re-post original documentRestore from Recycle Bin (limited)Re-create record
Financial reversalCredit memo (not true reversal)Reversal document (FB08) — nativeVoid or reversing journalReverse transaction — native
Reversal API supportREST DELETE + SOAP undeleteBAPI + OData (comprehensive)SuiteScript void() + RESTOData reversal endpoints
Period dependencyN/A (no fiscal periods in CRM)Must be in open periodMust be in open periodMust be in open period
Partial reversalPer-line-item possiblePartial quantity reversal (MIGO)Per-line credit memoPer-line corrective posting
Reversal audit trailRecycle Bin + field historyReversal document linked to originalVoid memo on transactionReversing entry linked to original
Bulk compensationBulk API 2.0 deleteMass reversal (F.80)CSV import for mass voidBatch journal reversal
Idempotent reversalsNo (must check status first)No (must check if already reversed)No (double-void throws error)No (must check reversal status)

Important Caveats

Related Units