QuickBooks Connector

Technical Documentation - QuickBooks Webhook Integration


1. Overview

The QuickBooks Webhook Integration is a real-time change notification system that receives inbound HTTP POST requests from QuickBooks Online (QBO) whenever an entity — Customer, Vendor, Account, Product/Item, Invoice, or Payment — is created, updated, or voided in QuickBooks. It automatically synchronises those changes into corresponding Salesforce custom objects.

The system is built in three layers:

  1. REST Endpoint (Webhook_QBChangeHandler) — receives the raw QBO webhook payload, parses it, and dispatches one batch job per entity type

  2. Batch Processors (six BatchQBSF*Webhook classes) — manage asynchronous, callout-enabled processing with a built-in concurrency guard

  3. Integration Helper (QuickbooksIntegrationHelper) — contains per-entity business logic: Ignore-flag acknowledgement, live QB API queries, lookup resolution, and final DML via UpsertCustomRecords


2. Component Architecture

QuickBooks Online
        │  HTTP POST to /services/apexrest/getUpdatesQB/*
        │  Header: RealmId
        ▼
┌───────────────────────────────────┐
│  Webhook_QBChangeHandler          │  ← @RestResource, global
│  handleCallback()                 │
│  - Parses eventNotifications JSON │
│  - Groups QB IDs by entity type   │
│  - Dispatches batch per entity    │
└────────────┬──────────────────────┘
             │  Database.executeBatch(batchSize from Custom Setting)
             │
   ┌─────────┴─────────────────────────────────────────────────────┐
   │                 Entity-Specific Batch Classes                 |
   │                                                               │
   │  BatchQBSFCustomerWebhook      BatchQBSFVendorWebhook         │
   │  BatchQBSFAccountWebhook       BatchQBSFProductWebhook        │
   │  BatchQBSFInvoiceWebhook       BatchQBSFPaymentWebhook        │
   │  BatchQBSFSalesReceiptWebhook  BatchQBSFPaymentMethodWebhook  │
   │  start()  → Concurrency Guard (AsyncApexJob check)            │
   │  execute() → delegate to QuickBooksIntegrationHelper          │
   │  finish()  → debug log                                        │
   └────────────┬──────────────────────────────────────────────────┘
                │
                ▼
┌───────────────────────────────────┐
│  QuickbooksIntegrationHelper      │  ← public, instance-based
│                                   │
│  handleQBCustomer()               │
│  handleQBVendor()                 │
│  handleQBAccount()                │
│  handleQBProduct()                │
│  handleQBInvoice()                │
│  handleQBPayment()                │
│  handleQBSalesReceipt()           │
│  handleQBPaymentMethod()          │
└────────────┬──────────────────────┘
             │
     ┌───────┴──────────┐
     │                  │
     ▼                  ▼
WS_Quickbooks     UpsertCustomRecords
(QB API callout)  (without sharing DML)


3. Inbound Webhook Payload Structure

QuickBooks Online sends the following JSON payload on entity change:

{
  "eventNotifications": [
    {
      "realmId": "1234567890",
      "dataChangeEvent": {
        "entities": [
          {
            "name": "Customer",
            "id": "123",
            "operation": "Create",
            "lastUpdated": "2025-01-15T12:00:00.000Z"
          },
          {
            "name": "Invoice",
            "id": "456",
            "operation": "Update",
            "lastUpdated": "2025-01-15T12:01:00.000Z"
          }
        ]
      }
    }
  ]
}

Important notes:

  • Only the first element of eventNotifications (lstEvents[0]) is processed. QBO typically sends one notification per realm per webhook delivery.

  • The RealmId is read from the HTTP request header (req.headers.get('RealmId')), not from the JSON body.

  • Only operations create, update, and void are processed. delete is intentionally ignored.


4. Class Reference

4.1 Webhook_QBChangeHandler

File: force-app/main/default/classes/Webhook_QBChangeHandler.cls Access Modifier: global with sharing REST Endpoint: @RestResource(urlMapping='/getUpdatesQB/*') HTTP Method: @HttpPost

Method: handleCallback

@HttpPost global static void handleCallback()

Responsibilities:

  1. Reads the raw request body (RestContext.request.requestbody) and the RealmId header.

  2. Parses eventNotifications[0].dataChangeEvent.entities from the JSON payload.

  3. Filters to only create, update, void operations.

  4. Groups QB entity IDs into a Map<String, List<String>> keyed by lowercase entity name.

  5. Reads batch size from cm_finance__Quickbooks_Common_Settings__c instance BatchSize (default: 50).

  6. Dispatches a Database.executeBatch() call for each entity type present in the payload.

  7. Always responds HTTP 200 via the finally block — regardless of parse errors or exceptions.

Supported Entity Name Keys (lowercase):

QB Entity Name (lowercase)

Batch Class Dispatched

customer

BatchQBSFCustomerWebhook

account

BatchQBSFAccountWebhook

item

BatchQBSFProductWebhook

invoice

BatchQBSFInvoiceWebhook

payment

BatchQBSFPaymentWebhook

vendor

BatchQBSFVendorWebhook

salesreceipt

BatchQBSFSalesReceiptWebhook

paymentmethod

BatchQBSFPaymentMethodWebhook

Design note: QB sends item as the entity name for Products/Services. The handler maps itemBatchQBSFProductWebhook. Any entity name not in this set is silently discarded.

Always HTTP 200: The finally block unconditionally sets res.statusCode = 200. This is required by QBO's webhook contract — QBO will retry delivery if it receives a non-200 response. Returning 200 immediately acknowledges receipt and prevents retries, with all actual processing happening asynchronously via batch.

Parse error behaviour: Any exception during JSON parsing or batch dispatch is caught and debug-logged. The finally block still fires, so QBO always gets its 200 ACK.


4.2 Batch Webhook Classes

All six batch classes follow an identical structural pattern. They differ only in the entity type they handle.

Class

Handles

Delegates to

BatchQBSFCustomerWebhook

Customer

handleQBCustomer()

BatchQBSFAccountWebhook

Account (Chart of Accounts)

handleQBAccount()

BatchQBSFProductWebhook

Product/Item

handleQBProduct()

BatchQBSFInvoiceWebhook

Invoice

handleQBInvoice()

BatchQBSFPaymentWebhook

Payment

handleQBPayment()

BatchQBSFVendorWebhook

Vendor

handleQBVendor()

BatchQBSFSalesReceiptWebhook

Sales Receipt

handleQBSalesReceipt()

BatchQBSFPaymentMethodWebhook

Payment Method

handleQBPaymentMethod()

Common class structure:

Implements: Database.Batchable<String>, Database.AllowsCallouts
Access: public with sharing

Constructor

Each class has two constructors:

// Default (empty — used for programmatic instantiation)
public BatchQBSFXxxWebhook()

// Primary (used by Webhook_QBChangeHandler)
public BatchQBSFXxxWebhook(List<String> lstQBIds, String realmId)

Parameter

Type

Description

lstQBIds

List<String>

QB entity IDs received in the webhook payload for this entity type

realmId

String

QB Realm/Company ID from the request RealmId header

start(Database.BatchableContext BC) — Concurrency Guard

This is the most important method in every batch class. Before returning the record iterable, it checks whether another instance of the same batch class is already running:

String sQuery = 'SELECT Id FROM AsyncApexJob 
                 WHERE status = :sStatus 
                 AND ApexClass.Name = :sApexClassName 
                 AND Id != :thisJobId';

Condition

Result

Another instance of this batch is already Processing

Returns empty List<String>() → batch exits immediately with no records processed

No other instance running

Returns this.lstQBIds → normal processing

Why this matters: QuickBooks can send multiple webhook deliveries in rapid succession for the same entity type (e.g., bulk imports). This guard ensures that at most one batch of each entity type runs at a time, preventing duplicate API callouts and DML conflicts.

Note: BatchQBSFAccountWebhook has a minor omission — it does not include sStatus and sApexClassName as bind variables in the query's parameter map (passes only thisJobId). The query still executes correctly because the other bind variables (sStatus, sApexClassName) resolve from the surrounding method scope, but it is inconsistent with the other five batch classes.

execute(Database.BatchableContext BC, List<String> lstRecordQBIds)

Instantiates QuickBooksIntegrationHelper, sets its lstRecordQBIds and realmId properties, then calls the appropriate handle*() method:

QuickBooksIntegrationHelper oHelper = new QuickBooksIntegrationHelper();
oHelper.lstRecordQBIds = lstRecordQBIds;
oHelper.realmId = this.realmId;
oHelper.handleQBCustomer(); // (or the relevant entity method)

finish(Database.BatchableContext BC)

Writes a debug log only. No post-processing logic.


4.3 QuickbooksIntegrationHelper

File: force-app/main/default/classes/QuickbooksIntegrationHelper.cls Access Modifier: public with sharing

The core processing class. Instantiated fresh per batch execute() call. State is set via public instance properties before calling any handle method.

Instance Properties

Property

Type

Set By

lstRecordQBIds

List<String>

Batch execute() — QB entity IDs to process

realmId

String

Batch execute() — QB Realm/Company ID

Static Properties

Property

Source

enforceFLS

CMQBConnectorUtility.enforceFLSfalse for automated/guest users

getPaymentReceipt

CMQBConnectorUtility.getPaymentReceipt() — controls payment PDF generation


Universal Processing Pattern

All six handler methods share the same core pattern. Understanding this pattern once explains all of them:

Step 1: Resolve QB Company
  → Query cm_finance__Quickbooks_Company__c WHERE Company_ID__c = realmId AND Active__c = true
  → If not found: create Failed records for all IDs and exit

Step 2: Ignore-Flag Acknowledgement (automation echo suppression)
  → Query SF for existing records WHERE QB_Id IN lstRecordQBIds 
    AND Ignore_Webhook__c = true AND QB_Company = sCompanyRecId
  → For each matched record:
      - Remove its QB ID from lstRecordQBIds (so it won't be re-fetched from QBO)
      - Clear Ignore_Webhook__c = false
      - Add to the "success" list directly

Step 3: Live QB API Callout (only for remaining IDs)
  → Build QB SQL query: select * from <Entity> WHERE Id IN ('id1','id2',...)
  → Call WS_Quickbooks.query<Entity>()
  → On API success: parse response, resolve lookups, set Composite_Unique_Key__c, set status
  → On API failure: create Failed records for each remaining ID

Step 4: DML via UpsertCustomRecords
  → UpsertCustomRecords.upsert<Entity>Records(allRecords)

The Ignore-Flag pattern is the key anti-loop mechanism. When Salesforce creates or updates a QB record (via the Automation Service or batch sync), it sets Ignore_Webhook__c = true. When QBO echoes that change back as a webhook, Step 2 identifies and acknowledges those records without making a redundant API callout — it simply clears the flag. Only genuinely external QBO changes (QB IDs not matched in Step 2) trigger a live API fetch.


Method: handleQBCustomer

QB API Query: select * from Customer WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Customer__c Composite_Unique_Key format: {QB_Id}::Customer::{RealmId} Status on success: Awaiting Salesforce Sync

Additional Logic — Parent Customer Resolution:

After parsing the QB API response, the handler resolves sub-customer parent relationships:

  1. Collects all cm_finance__Parent_QB_Customer_QB_Id__c values from the parsed customer records.

  2. Queries cm_finance__Quickbooks_Customer__c in Salesforce by those QB IDs to find the corresponding SF record IDs.

  3. Sets cm_finance__Parent_Quickbooks_Customer__c lookup field on each sub-customer record.

This ensures sub-customers are linked to their parent customer's SF record rather than just holding the raw QB ID.

Failure Records (per ID):

Scenario

Status

Status_Description__c

QB Company not found

Failed

Company not found

oAPIResponse is null

Failed

Issue in API Response

isSuccess = false

Failed

API error message from QBO

Unhandled exception

Failed

Exception message + line number

All failure records are also upserted via UpsertCustomRecords.upsertQBCustomerRecords().


Method: handleQBVendor

QB API Query: select * from Vendor WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Vendor__c Composite_Unique_Key format: {QB_Id}::Vendor::{RealmId} Status on success: Awaiting Salesforce Sync

Follows the universal pattern with no additional lookup resolution. No parent-child relationship handling (vendors are always top-level).


Method: handleQBAccount

QB API Query: select * from Account WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Account__c Composite_Unique_Key format: {QB_Id}::Account::{RealmId} Status on success: Successfully Synced (differs from other entities — Accounts sync directly with no further SF-side processing required)

Follows the universal pattern. No additional lookup resolution.


Method: handleQBPaymentMethod

QB API Query: select * from PaymentMethod WHERE Id IN ('...') SF Target Object: cm_finance__Payment_Method__c Composite_Unique_Key format: {QB_Id}::PaymentMethod::{RealmId} Status on success: Successfully Synced (differs from other entities — Accounts sync directly with no further SF-side processing required)

Follows the universal pattern. No additional lookup resolution.


Method: handleQBProduct

QB API Query: select * from Item WHERE Id IN ('...') SF Target Object: cm_finance__Quickbooks_Product__c Composite_Unique_Key format: {QB_Id}::Product::{RealmId} Status on success: Awaiting Salesforce Sync

Additional Logic — Account Lookup Resolution:

After parsing the QB API response, the handler resolves the three financial account references for each product:

  1. Collects QB IDs from cm_finance__QB_Income_Account_QB_Id__c, cm_finance__QB_Expense_Account_QB_Id__c, and cm_finance__QB_Asset_Account_QB_Id__c.

  2. Queries cm_finance__Quickbooks_Account__c to resolve QB IDs → SF record IDs.

  3. Populates the three lookup fields: cm_finance__Income_Account__c, cm_finance__Expense_Account__c, cm_finance__Asset_Account__c.

Additional Logic — Deduplication Before Upsert:

Before calling UpsertCustomRecords, the handler deduplicates lstAllProducts by Composite_Unique_Key__c:

Map<String, cm_finance__Quickbooks_Product__c> dedupMap = new Map<String, ...>();
for(cm_finance__Quickbooks_Product__c oProd : lstAllProducts){
    if(!dedupMap.containsKey(oProd.Composite_Unique_Key__c) || oProd.Id != null){
        dedupMap.put(oProd.Composite_Unique_Key__c, oProd);
    }
}
UpsertCustomRecords.upsertQBProductRecords(dedupMap.values());

A record with an Id set (existing SF record from Step 2) always wins over a newly constructed record for the same key. This prevents upsert duplicate errors when both an Ignore_Webhook__c = true match and a fresh API parse produce a record for the same QB ID.


Method: handleQBInvoice

QB API Query: select * from Invoice WHERE Id IN ('...') SF Target Objects: cm_finance__Quickbooks_Invoice__c, cm_finance__Quickbooks_Invoice_Item__c Composite_Unique_Key format (Invoice): {QB_Id}::Invoice::{RealmId} Status on success: Invoice PDF Pending (if GenerateInvoicePDF setting = true) or Awaiting Salesforce Sync

This is the most complex handler. After the universal Steps 1–3, it performs the following additional processing:

Step 3a — Four Lookup Resolutions:

Lookup

Collected From

Queries

Maps

QB Customer → SF Customer

cm_finance__Quickbooks_Customer_QB_Id__c on invoice

cm_finance__Quickbooks_Customer__c

QB ID → SF record ID

QB Product → SF Product

cm_finance__Quickbooks_Product_QB_Id__c on line items

cm_finance__Quickbooks_Product__c

QB ID → SF record ID

QB Class → SF Class

Quickbooks_Class_QB_Id__c on line items

cm_finance__Quickbooks_Class__c

QB ID → SF record ID

QB Custom Field Def → SF Custom Field

DefinitionId extracted from custom field temp values

cm_finance__Quickbooks_Custom_Field__c

Definition ID → SF record ID

Step 3b — Custom Field Decoding:

Custom field values are temporarily stored in cm_finance__Custom_Field_1/2/3__c using the format DefinitionId::FieldValue. The handler splits each value, resolves the SF Custom Field record ID, and writes the actual value:

Before: cm_finance__Custom_Field_1__c = "abcd1234::Project Alpha"
After:  cm_finance__Custom_Field_1__c = "Project Alpha"
        cm_finance__Quickbooks_Custom_Field_1__c = <SF record Id of Custom Field>

Step 4a — Invoice Upsert:

UpsertCustomRecords.upsertQBInvoiceRecords(lstAllSuccessInvoice);

Step 4b — Line Item Reconciliation (post-invoice-upsert):

After invoices are upserted (and SF IDs are assigned), the handler performs full line-item reconciliation:

1. Fetch all existing cm_finance__Quickbooks_Invoice_Item__c 
   WHERE Invoice__c IN (upserted invoice SF IDs)
   
2. Build a Map<Composite_Unique_Key__c, Invoice_Item__c> of existing items
   - Items without a Composite_Unique_Key__c → flag for deletion/archival

3. For each new line item from QBO:
   a. Set Invoice__c = the parent invoice's new SF ID
   b. Resolve QB Product ID → SF Product ID (mapQBLineItem)
   c. Resolve QB Class ID → SF Class ID (mapQBClass)
   d. If Composite_Unique_Key__c matches an existing item: 
      carry the existing item's Id (update path)
   e. If no match: insert as new line item

4. Orphaned existing line items (in SF but not in new QBO data):
   - If CMQBConnectorUtility.deleteExtraLineItemsSetting() = true:
       → data.remove() (hard delete)
   - If false:
       → Set Archived__c = true (soft delete / audit trail)

5. UpsertCustomRecords.upsertQBInvoiceItemRecords(lstAllLineItems)

This reconciliation ensures that line item additions, modifications, and removals from QBO are all accurately reflected in Salesforce on every invoice webhook event.

Method: handleQBSalesReceipt

QB API Query: select * from SalesReceipt WHERE Id IN ('...') SF Target Objects: cm_finance__Quickbooks_Sales_Receipt__c, cm_finance__Quickbooks_Sales_Receipt_Item__c Composite_Unique_Key format (Sales Receipt): {QB_Id}::SalesReceipt::{RealmId} Status on success: Sales Receipt PDF Pending (if the sales-receipt PDF setting is enabled) or Awaiting Salesforce Sync

This handler follows the universal Steps 1–3 and then applies the same lookup, custom-field, parent-record upsert, and line-item reconciliation pattern used by handleQBInvoice.

Step 3a — Six Lookup Resolutions:

Lookup

Collected From

Queries

Maps

QB Customer → SF Customer

cm_finance__Quickbooks_Customer_QB_Id__c on the sales receipt

cm_finance__Quickbooks_Customer__c

QB ID → SF record ID

QB Product → SF Product

cm_finance__Quickbooks_Product_QB_Id__c on line items

cm_finance__Quickbooks_Product__c

QB ID → SF record ID

QB Class → SF Class

Quickbooks_Class_QB_Id__c on line items

cm_finance__Quickbooks_Class__c

QB ID → SF record ID

QB Custom Field Def → SF Custom Field

DefinitionId extracted from custom-field temporary values

cm_finance__Quickbooks_Custom_Field__c

Definition ID → SF record ID

QB Account → SF Account

cm_finance__Quickbooks_Deposit_Account__c on the sales receipt

cm_finance__Quickbooks_Account__c

QB ID → SF record ID

QB Payment Method → SF Payment Method

cm_finance__Payment_Method__c on the sales receipt

cm_finance__Payment_Method__c

QB ID → SF record ID

Step 3b — Custom Field Decoding:

Custom-field values are temporarily stored in cm_finance__Custom_Field_1/2/3__c using the format DefinitionId::FieldValue. The handler splits each value, resolves the Salesforce Custom Field record ID, and writes the actual value:

Before: cm_finance__Custom_Field_1__c = "abcd1234::Project Alpha"
After:  cm_finance__Custom_Field_1__c = "Project Alpha"
        cm_finance__Quickbooks_Custom_Field_1__c = <SF record Id of Custom Field>

Step 4a — Sales Receipt Upsert:

UpsertCustomRecords.upsertQBSalesReceiptRecords(lstAllSuccessSalesReceipt);

Step 4b — Line Item Reconciliation (post-sales-receipt-upsert):

After sales receipts are upserted and Salesforce IDs are assigned, the handler reconciles the related line items:

SQL
1. Fetch all existing cm_finance__Quickbooks_Sales_Receipt_Item__c
   WHERE Sales_Receipt__c IN (upserted sales-receipt SF IDs)

2. Build a Map<Composite_Unique_Key__c, Sales_Receipt_Item__c> of existing items
   - Items without a Composite_Unique_Key__c → flag for deletion/archival

3. For each new line item from QBO:
   a. Set Sales_Receipt__c = the parent sales receipt's new SF ID
   b. Resolve QB Product ID → SF Product ID (mapQBLineItem)
   c. Resolve QB Class ID → SF Class ID (mapQBClass)
   d. If Composite_Unique_Key__c matches an existing item:
      carry the existing item's Id (update path)
   e. If no match: insert as a new line item

4. Orphaned existing line items (in SF but not in new QBO data):
   - If CMQBConnectorUtility.deleteExtraLineItemsSetting() = true:
       → data.remove() (hard delete)
   - If false:
       → Set Archived__c = true (soft delete / audit trail)

5. UpsertCustomRecords.upsertQBSalesReceiptItemRecords(lstAllLineItems)

This reconciliation ensures that sales-receipt line-item additions, modifications, and removals from QBO are accurately reflected in Salesforce on every sales-receipt webhook event.


Method: handleQBPayment

QB API Query: select * from Payment WHERE Id IN ('...') SF Target Object: cm_finance__Payment_Transaction__c Composite_Unique_Key format: {QB_Id}::Payment::{RealmId} (no entity type token in the key) Status on success: Payment Receipt PDF Pending (if getPaymentReceipt = true) or Successfully Synced

Additional fields set on payment records:

Field

Value

cm_finance__Amount_Captured__c

Set equal to cm_finance__Amount__c

cm_finance__Payment_Status__c

Settled


4.4 UpsertCustomRecords

File: force-app/main/default/classes/UpsertCustomRecords.cls Access Modifier: public without sharing

Critical design note: This class is without sharing. All webhook sync DML bypasses record-level sharing rules. This is intentional — webhook processing runs as a system-level operation and must succeed regardless of the running user's record access.

Provides a single static upsert method per entity. All methods use Composite_Unique_Key__c as the external ID upsert key, except credentials which use Name.

Method

Target Object

Upsert Key

upsertQBCompanyRecords()

cm_finance__Quickbooks_Company__c

cm_finance__Composite_Unique_Key__c

upsertQBCustomerRecords()

cm_finance__Quickbooks_Customer__c

cm_finance__Composite_Unique_Key__c

upsertQBVendorRecords()

cm_finance__Quickbooks_Vendor__c

cm_finance__Composite_Unique_Key__c

upsertQBAccountRecords()

cm_finance__Quickbooks_Account__c

cm_finance__Composite_Unique_Key__c

upsertQBProductRecords()

cm_finance__Quickbooks_Product__c

cm_finance__Composite_Unique_Key__c

upsertQBInvoiceRecords()

cm_finance__Quickbooks_Invoice__c

cm_finance__Composite_Unique_Key__c

upsertQBInvoiceItemRecords()

cm_finance__Quickbooks_Invoice_Item__c

cm_finance__Composite_Unique_Key__c

upsertQBPaymentRecords()

cm_finance__Payment_Transaction__c

cm_finance__Composite_Unique_Key__c

upsertQBPaymentMethodRecords()

cm_finance__Payment_Method__c

cm_finance__Composite_Unique_Key__c

upsertQBSalesReceiptRecords()

cm_finance__Quickbooks_Sales_Receipt__c

cm_finance__Composite_Unique_Key__c

upsertQBCredentials()

cm_finance__Quickbooks_Credentials__c

Name (always FLS = false)

updateCredentials()

cm_finance__Quickbooks_Credentials__c

— (direct update, FLS = false)

All upsert methods delegate to Data.upsurt() with FLS enforcement inherited from CMQBConnectorUtility.enforceFLS.


5. Composite Unique Key Reference

The Composite_Unique_Key__c field is the external ID used for all upsert operations. It guarantees idempotency — running the same webhook notification twice produces the same result, not duplicates.

Entity

Key Format

Example

Customer

{QB_Id}::Customer::{RealmId}

123::Customer::9876543210

Vendor

{QB_Id}::Vendor::{RealmId}

456::Vendor::9876543210

Account

{QB_Id}::Account::{RealmId}

789::Account::9876543210

Product

{QB_Id}::Product::{RealmId}

321::Product::9876543210

Invoice

{QB_Id}::Invoice::{RealmId}

654::Invoice::9876543210

Invoice Item

Set by WS_Quickbooks.createInvoiceRecords()

Payment

{QB_Id}::Payment::{RealmId} (no entity token)

987::Payment::9876543210

Sales Receipt

{QB_Id}::SalesReceipt::{RealmId}

654::SalesReceipt::9876543210

Sales Receipt Item

Set by WS_Quickbooks.createSalesReceiptRecords()


Payment Method

{QB_Id}::PaymentMethod::{RealmId}

654::PaymentMethod::9876543210

Note on Payment key: The Payment key omits the entity type token (::Payment:: is still present as a literal separator, but the pattern is QB_Id::Payment::RealmId, not QB_Id::RealmId). This is consistent with how QueueableAutomateQBService sets payment keys and distinguishes payments from invoices at the same QB ID.


6. End-to-End Flow Diagrams

6.1 Normal Webhook (External QBO Change)

QuickBooks Online
  │  POST /services/apexrest/getUpdatesQB/*
  │  Header: RealmId: 9876543210
  │  Body: { "eventNotifications": [{ "dataChangeEvent": { "entities": [
  │            { "name": "Customer", "id": "123", "operation": "Update" },
  │            { "name": "Invoice",  "id": "456", "operation": "Create" }
  │          ]}}]}
  ▼
Webhook_QBChangeHandler.handleCallback()
  │
  ├── Parse JSON → mapEntitiesData = { "customer": ["123"], "invoice": ["456"] }
  ├── Read BatchSize from Custom Setting (default: 50)
  ├── Database.executeBatch(BatchQBSFCustomerWebhook(["123"], "9876543210"), 50)
  ├── Database.executeBatch(BatchQBSFInvoiceWebhook(["456"], "9876543210"), 50)
  └── res.statusCode = 200  [always, in finally block]

  [Asynchronous - separate transactions]

BatchQBSFCustomerWebhook.start()
  │
  ├── Query AsyncApexJob: is another BatchQBSFCustomerWebhook running?
  │       → No → return ["123"]
  │       → Yes → return [] (exits)
  │
BatchQBSFCustomerWebhook.execute(["123"])
  │
  ▼
QuickBooksIntegrationHelper.handleQBCustomer()
  │
  ├── Step 1: Resolve company by realmId "9876543210" → SF Company record found
  ├── Step 2: Query: Quickbooks_Customer__c WHERE QB_Id IN ["123"] AND Ignore_Webhook = true
  │               → No match (this was an external QBO change, not SF-originated)
  ├── Step 3: Live API callout: select * from Customer WHERE Id IN ('123')
  │               → QB returns full Customer data
  │               → Resolve parent customer QB ID → SF record ID (if sub-customer)
  │               → Set Composite_Unique_Key__c = "123::Customer::9876543210"
  │               → Set Status = "Awaiting Salesforce Sync"
  └── Step 4: UpsertCustomRecords.upsertQBCustomerRecords([customerRecord])
                  → Data.upsurt() on Composite_Unique_Key__c

6.2 Echo Suppression (Salesforce-Originated Change)

Salesforce Flow triggers → InvocableAutomateQBService
  │
  ├── Creates cm_finance__Quickbooks_Customer__c with:
  │       Quickbooks_Id__c = "123"
  │       Ignore_Webhook__c = true   ← FLAGGED
  │       Process_via_Batch__c = true
  │
  │ [Batch sync runs, pushes record to QBO]
  │
  ↓
QuickBooks Online receives the change and echoes it back as a webhook

Webhook_QBChangeHandler receives: entity Customer id="123" operation="Update"
  │
  ▼
QuickBooksIntegrationHelper.handleQBCustomer()
  │
  ├── Step 2: Query: QB_Id IN ["123"] AND Ignore_Webhook__c = true
  │               → MATCH FOUND (the SF-created record)
  │               → Remove "123" from lstRecordQBIds
  │               → Set Ignore_Webhook__c = false on that record
  │               → Add to lstAllCustomers
  │
  ├── lstRecordQBIds is now EMPTY → skip Step 3 (no API callout)
  │
  └── Step 4: UpsertCustomRecords.upsertQBCustomerRecords([customerRecord])
                  → Only updates Ignore_Webhook__c = false
                  → No redundant API callout, no duplicate record

6.3 Invoice Webhook with Line Item Reconciliation

QBO sends: Invoice id="456" operation="Update"
  │
  ▼
QuickBooksIntegrationHelper.handleQBInvoice()
  │
  ├── Resolve Company
  ├── Ignore-flag check → no match (external update)
  ├── QB API callout: select * from Invoice WHERE Id IN ('456')
  │     → Returns: Invoice with 3 line items (was 4, one was deleted in QB)
  │
  ├── Resolve 4 lookups:
  │     Customer QB ID → SF Customer record
  │     Product QB IDs → SF Product records (for each line item)
  │     Class QB IDs  → SF Class records
  │     Custom Field Def IDs → SF Custom Field records
  │
  ├── Decode custom fields: "defId::value" → actual value + SF lookup
  │
  ├── UpsertCustomRecords.upsertQBInvoiceRecords([invoice])
  │     → Upserts invoice header (create or update in SF)
  │
  ├── Fetch existing Invoice Items for this invoice from SF (4 items)
  │     → Build Map<Composite_Key, Item> of existing items
  │
  ├── For each of the 3 new line items from QBO:
  │     → Composite key match found for items 1, 2, 3 → carry SF Id (update)
  │     → Resolve Product lookup, Class lookup
  │
  ├── Orphaned item (old item 4, now gone from QBO):
  │     → Check deleteExtraLineItemsSetting()
  │         true  → data.remove() (hard delete)
  │         false → Set Archived__c = true (soft archive)
  │
  └── UpsertCustomRecords.upsertQBInvoiceItemRecords([3 line items])


7. Record Status Lifecycle (Webhook Path)

Entity

Status After Webhook Processing

Customer

Awaiting Salesforce Sync (success) / Failed

Vendor

Awaiting Salesforce Sync (success) / Failed

Account

Successfully Synced (success) / Failed

Product

Awaiting Salesforce Sync (success) / Failed

Invoice

Invoice PDF Pending or Awaiting Salesforce Sync (success) / Failed

Sales Receipt

Sales Receipt PDF Pending or Awaiting Salesforce Sync (success) / Failed

Payment

Payment Receipt PDF Pending or Successfully Synced (success) / Failed

Payment Method

Successfully Synced (success) / Failed

Ignore_Webhook__c lifecycle:

Trigger

Value

Record created by SF Automation Service (Invocable)

true

Record created by SF Batch Sync

true

Webhook receives echo of SF-originated change

Cleared to false

Webhook processes an external QBO change

Not set on new record (default false)


8. Error Handling Strategy

All handlers implement a three-tier error strategy:

Tier 1 — Company Not Found

Condition: cm_finance__Quickbooks_Company__c not found for the realmId
Action:    Create Failed records for ALL QB IDs in lstRecordQBIds
           (without even attempting an API callout)
           Upsert via UpsertCustomRecords

Tier 2 — API Response Failure

Condition: oAPIResponse is null, OR isSuccess = false
Action:    Create Failed records for all QB IDs remaining in lstRecordQBIds
           (the Ignore-flag-matched IDs are still upserted normally)
           Error message from oAPIResponse.errorMessage (if available) stored
           in Status_Description__c

Tier 3 — Unhandled Exception (outer catch)

Condition: Any unexpected exception
Action:    Create Failed records for all KB IDs in the original lstRecordQBIds
           (using this.realmId since company lookup may have failed)
           Attempt to upsert via UpsertCustomRecords (wrapped in try/catch)
           Debug log the exception with line number

Failed Record Fields:

Field

Value

Quickbooks_Id__c

The QB ID being processed

cm_finance__Quickbooks_Company_Realm_Id__c

realmId from the batch

Composite_Unique_Key__c

{QB_Id}::{EntityType}::{RealmId}

Status__c

Failed

Status_Description__c

Full error message + class name + line number

Ignore_Webhook__c

false (explicit on failure records)


9. Configuration Reference

cm_finance__Quickbooks_Common_Settings__c (Hierarchy Custom Setting)

Instance Name

Default

Effect

BatchSize

50

Batch size passed to Database.executeBatch() for all webhook batches

GenerateInvoicePDF

true

Invoice webhook sets status to Invoice PDF Pending if true, else Awaiting Salesforce Sync

GuestUsers

AutomatedProcess;CloudIntegrationUser;Guest

User types that bypass FLS enforcement in all webhook classes

cm_finance__Quickbooks_Credentials__c (Hierarchy Custom Setting)

Field

Purpose

Webhook_URL__c

The registered public URL pointing to /services/apexrest/getUpdatesQB/*

Send_Webhook_Updates__c

Enables or disables sending webhook-triggered data back to QB (used by AdminSetupController)

deleteExtraLineItemsSetting() (CMQBConnectorUtility)

Controls what happens to invoice line items in Salesforce that no longer exist in QBO after an invoice webhook update:

Value

Behaviour

true

Hard delete (data.remove())

false

Soft archive (Archived__c = true)


10. Concurrency and Idempotency Design

Concurrency Guard (per batch class)

The start() method of each batch class queries AsyncApexJob for other running instances of the same class. If a concurrent run is detected, the batch returns an empty iterable, causing it to exit without processing. This prevents:

  • Multiple simultaneous API callouts to QBO for the same entity type

  • DML lock contention on Composite_Unique_Key__c-based upserts

  • Duplicate or conflicting writes from back-to-back webhook deliveries

Idempotency (via Composite_Unique_Key__c)

Every handler builds a Composite_Unique_Key__c before upserting. Re-processing the same webhook notification for the same entity always produces an upsert (not a duplicate insert). The operation is safe to retry.

Echo Suppression (via Ignore_Webhook__c)

Records created or updated by Salesforce-side automation are flagged with Ignore_Webhook__c = true. When QBO echoes that change back as a webhook, the handler identifies these records in Step 2 and skips the live API callout — only clearing the flag. Without this mechanism, every SF-originated QB write would trigger a full round-trip API fetch, doubling the callout count.


11. Key Design Decisions

Decision

Rationale

Always return HTTP 200 from endpoint

QBO retries delivery on non-200 responses. Returning 200 immediately acknowledges receipt and prevents retries; async batch handles all actual work.

Database.Batchable<String> (not <sObject>)

QB IDs are strings, not SF records. Using a String iterable avoids a preliminary SOQL query just to get an iterable and keeps the batch construction simple.

Concurrency guard in start() rather than execute()

Prevents the batch from being started in a way that would process records while another instance is mid-flight. Checking in start() means zero DML is issued if a concurrent run is active.

UpsertCustomRecords is without sharing

Webhook sync is a system-level operation triggered by QBO. Record-level sharing restrictions should not prevent an entity created in QB from being synced into SF.

Deduplication only on Product, not other entities

Products are the only entity where both an Ignore-flag match (Step 2) and a fresh API response (Step 3) could generate records with the same Composite_Unique_Key__c in the same execution (e.g., a product that was SF-originated AND appears in the QBO query result). Other entities are designed to be mutually exclusive between Steps 2 and 3.

Line item reconciliation uses Archived flag option

Preserves audit history — deleted QB line items are not permanently removed from SF unless explicitly configured. Admins can choose hard delete vs. soft archive via deleteExtraLineItemsSetting.

Account status is Successfully Synced immediately

Chart of Accounts records do not go through further SF-side processing (no PDF generation, no parent sync). They are considered fully synced as soon as the webhook upsert completes.

Payment Composite Key has no entity type token in the positional sense, but is still distinct

QB_Id::Payment::RealmId is unique per design; payments are stored in a different object (cm_finance__Payment_Transaction__c) so cross-entity key collisions are impossible.