Technical Documentation - QuickBooks Budget Sync


1. Overview

The QuickBooks Budget Sync brings QuickBooks Online (QBO) Budgets into Salesforce so budget figures can be reported on alongside CRM and actuals data. Each QBO Budget is stored as a cm_finance__Quickbooks_Budget__c record, and every budget line (one amount per account/customer/class/department per period) is stored as a child cm_finance__Quickbooks_Budget_Detail__c record.

Budget sync is one-directional: QuickBooks → Salesforce. The connector only reads Budgets — there is no Push / create / update path from Salesforce to QuickBooks, no Custom Field Mapping, and no reverse field mapping for this entity.

Budgets reach Salesforce through two paths:

Path

Entry Point

When It Runs

Full Sync

BatchAllQBSFBudgetSync

Admin clicks Budget on the Sync Data from QuickBooks screen of the Admin Setup Console (or a scheduled run)

Webhook

Webhook_QBChangeHandler → BatchQBSFBudgetWebhook → QuickbooksIntegrationHelper.handleQBBudget()

QBO sends a change notification for a Budget entity (Create / Update / Void)

Both paths use the same service-layer methods in WS_Quickbooks to query QBO and to convert the response into Salesforce records.


2. Component Architecture

                 Admin Setup Console (adminSetup LWC)
                 "Sync Data from QuickBooks" → Budget tile
                               │
                               ▼
              AdminSetupController.syncQuickbooksData('Budget', companyId)
                               │
                               ▼
┌──────────────────────────────────────────┐        QuickBooks Online
│ BatchAllQBSFBudgetSync                   │        POST /services/apexrest/getUpdatesQB/*
│  execute(SchedulableContext)  → count    │        (entity name = "Budget")
│  start()   → concurrency guard + offsets │                 │
│  execute() → page of Budgets             │                 ▼
│  finish()  → Last_Sync_Config__c         │   ┌───────────────────────────────┐
└──────────────┬───────────────────────────┘   │ Webhook_QBChangeHandler       │
               │                               │  groups ids under key 'budget'│
               │                               └──────────────┬────────────────┘
               │                                              ▼
               │                               ┌───────────────────────────────┐
               │                               │ BatchQBSFBudgetWebhook        │
               │                               │  start() → concurrency guard  │
               │                               └──────────────┬────────────────┘
               │                                              ▼
               │                               ┌───────────────────────────────┐
               │                               │ QuickbooksIntegrationHelper   │
               │                               │  handleQBBudget()             │
               │                               └──────────────┬────────────────┘
               ▼                                              ▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ WS_Quickbooks                                                                │
│  queryBudgetCount(whereClause)       → select COUNT(*) from Budget           │
│  queryBudget(oRequestWrapper)        → select * from Budget ...              │
│  createBudgetResponseWrapper(...)    → QBO JSON → QBApiDataWrapper.Budget     │
│  createBudgetRecords(...)            → cm_finance__Quickbooks_Budget__c       │
│  createBudgetDetailRecords(...)      → cm_finance__Quickbooks_Budget_Detail__c│
└──────────────────────────────────────────────────────────────────────────────┘
               │
               ▼
   Data.upsurt / UpsertCustomRecords.upsertQBBudgetRecords()
                 UpsertCustomRecords.upsertQBBudgetDetailRecords()


3. QuickBooks API Usage

Budget uses the standard QBO Query endpoint (Query_Endpoint__c on cm_finance__Quickbooks_Credentials__c). No create, update or PDF endpoints are used.

Operation

Method

Query

Used By

Count Budgets

GET /v3/company/{realmId}/query

select COUNT(*) from Budget [whereClause]

BatchAllQBSFBudgetSync.execute(SchedulableContext)

Page of Budgets

GET /v3/company/{realmId}/query

select * from Budget [whereClause] startposition {n} maxresults {m}

BatchAllQBSFBudgetSync.execute(BatchableContext)

Budgets by Id

GET /v3/company/{realmId}/query

SELECT * FROM Budget WHERE Id IN ('id1','id2',...)

QuickbooksIntegrationHelper.handleQBBudget()

All calls refresh the access token first (refreshAccessToken()), send minorversion from Minor_Version__c, and call checkForCredentialsUpdate() afterwards to persist a rotated refresh token.

3.1 Sample QBO Budget Response

JSON
{
  "QueryResponse": {
    "Budget": [
      {
        "Id": "QB_BUDGET_1",
        "SyncToken": "0",
        "Name": "FY26 Budget",
        "StartDate": "2026-01-01",
        "EndDate": "2026-12-31",
        "BudgetType": "ProfitAndLoss",
        "BudgetEntryType": "Monthly",
        "Active": true,
        "sparse": false,
        "domain": "QBO",
        "MetaData": {
          "CreateTime": "2026-01-01T10:00:00-07:00",
          "LastUpdatedTime": "2026-01-15T10:00:00-07:00"
        },
        "BudgetDetail": [
          { "Amount": 1000.50, "BudgetDate": "2026-01-01", "AccountRef":    { "name": "Revenue",    "value": "1" } },
          { "Amount": 500.25,  "BudgetDate": "2026-02-01", "CustomerRef":   { "name": "Acme Corp",  "value": "2" } },
          { "Amount": 250.00,  "BudgetDate": "2026-03-01", "ClassRef":      { "name": "Consulting", "value": "3" } },
          { "Amount": 750.75,  "BudgetDate": "2026-04-01", "DepartmentRef": { "name": "Sales",      "value": "4" } }
        ]
      }
    ]
  }
}


4. Class Reference

4.1 BatchAllQBSFBudgetSync — Full Sync

File: force-app/main/default/classes/BatchAllQBSFBudgetSync.cls Access Modifier: global with sharing Implements: Database.Batchable<Object>, Database.AllowsCallouts, Schedulable, Database.Stateful

Pulls every Budget (optionally filtered) for one QuickBooks company, following the same count-first / paginate-in-chunks pattern as the other BatchAllQBSF*Sync classes.

Property

Description

companyId (global)

QuickBooks Realm Id of the company to sync. Must be set before execution.

budgetCount (private)

Total count returned by the COUNT query; drives the size of the iterable in start()

currentOffset (private, stateful)

Running offset used to compute startposition for each chunk

whereClause (static)

Read from CMQBConnectorUtility.getWhereClause('AllBudget')

execute(SchedulableContext sc) — Count & Dispatch

  1. Sets WS_Quickbooks.sCompanyId and oCredentials for companyId.

  2. Calls WS_Quickbooks.queryBudgetCount(whereClause) and reads QueryResponse.totalCount.

  3. Reads the BatchSize common setting (default 50) and caps it at 10.

  4. Starts new BatchAllQBSFBudgetSync(totalCount) with that batch size (skipped in tests).

Why the batch size is capped at 10: a single Budget can carry 200+ BudgetDetail lines. Each chunk upserts Budgets and all of their detail lines, so the number of Budgets per chunk is limited independently of the shared BatchSize setting to stay under the 10,000 DML rows per transaction limit.

start(Database.BatchableContext BC) — Concurrency Guard

Queries AsyncApexJob for another BatchAllQBSFBudgetSync in Processing status. If one is found it returns an empty list (the job exits without work). Otherwise it returns a placeholder list of budgetCount elements, so each chunk represents one page window.

execute(Database.BatchableContext BC, List<Object> lstRecords) — Page Processing

1. thisOffset = currentOffset; currentOffset += chunk size
2. Query: select * from Budget [whereClause] startposition {thisOffset + 1} maxresults {chunk size + 1}
3. WS_Quickbooks.queryBudget()
4. Resolve cm_finance__Quickbooks_Company__c by Company_ID__c = companyId
5. WS_Quickbooks.createBudgetRecords() → set Quickbooks_Company__c + Realm Id
6. Upsert Budgets on cm_finance__Composite_Unique_Key__c
7. Re-query Budgets by QuickBooks_Budget_Id__c + company → Map<QB Budget Id, SF Id>
8. WS_Quickbooks.createBudgetDetailRecords() → upsert on cm_finance__External_Key__c
9. Stamp Status__c on the Budgets and update them:
     detail upsert OK     → 'Successfully Synced'
     detail upsert failed → 'Failed' + Status_Description__c = 'Budget Detail sync failed: <error>'

API errors, a missing company, or unexpected exceptions are written to the debug log only (no Failed record is created on this path).

finish(Database.BatchableContext BC)

Updates the Budget entry of cm_finance__Last_Sync_Config__c on the company record with lastSyncedAt = today and status = 'Completed'. This drives the last-sync date shown on the Budget tile of the Admin Setup Console.

Usage:

// Same call the Admin Setup Console makes
BatchAllQBSFBudgetSync job = new BatchAllQBSFBudgetSync();
job.companyId = '<QuickBooks Realm Id>';
job.execute(null);   // runs the COUNT query and starts the batch

// Scheduling — companyId must be set on the instance before scheduling
BatchAllQBSFBudgetSync scheduled = new BatchAllQBSFBudgetSync();
scheduled.companyId = '<QuickBooks Realm Id>';
System.schedule('QB Budget Sync', '0 0 2 * * ?', scheduled);


4.2 BatchQBSFBudgetWebhook — Webhook Batch

File: force-app/main/default/classes/BatchQBSFBudgetWebhook.cls Access Modifier: public with sharing Implements: Database.Batchable<String>, Database.AllowsCallouts

Follows the identical pattern of the other BatchQBSF*Webhook classes.

Method

Behaviour

BatchQBSFBudgetWebhook(List<String> lstQBIds, String realmId)

Primary constructor used by Webhook_QBChangeHandler

start()

Concurrency guard on AsyncApexJob (ApexClass.Name = 'BatchQBSFBudgetWebhook'); returns an empty list if another run is Processing, otherwise the QB Budget Ids

execute()

Creates QuickbooksIntegrationHelper, sets lstRecordQBIds and realmId, calls handleQBBudget()

finish()

Debug log only

Webhook_QBChangeHandler dispatches this batch when the payload contains entities named Budget (lower-cased key budget) with operation create, update or void, using the BatchSize common setting as the batch size.


4.3 QuickbooksIntegrationHelper.handleQBBudget()

Step 1: Resolve company
  → cm_finance__Quickbooks_Company__c WHERE Company_ID__c = realmId AND Active__c = true
  → not found: debug log and return (no records written)

Step 2: Query QBO
  → SELECT * FROM Budget WHERE Id IN (<webhook ids>)
  → WS_Quickbooks.queryBudget()

Step 3: Budgets
  → WS_Quickbooks.createBudgetRecords()
  → set Quickbooks_Company__c, Realm Id, Composite_Unique_Key__c = {QB_Id}::Budget::{RealmId}
  → UpsertCustomRecords.upsertQBBudgetRecords()

Step 4: Budget Details
  → WS_Quickbooks.createBudgetDetailRecords(Map<QB Budget Id, SF Id>)
  → UpsertCustomRecords.upsertQBBudgetDetailRecords()

Step 5: Status
  → Budgets updated to 'Successfully Synced', Status_Description__c cleared

Failure handling:

Scenario

Result on existing cm_finance__Quickbooks_Budget__c records

API call failed / no Budget returned

Status__c = 'Failed', Status_Description__c = QBO error message (or Budget data not found from QuickBooks)

Unhandled exception

Status__c = 'Salesforce Sync Failed', Status_Description__c = 'Exception: <message> at Line Number: <n>'

Only Budgets that already exist in Salesforce are marked as failed — a Budget that has never been synced has no record to update.

Echo suppression: the Ignore_Webhook__c pattern used by other entities does not apply to Budget, because Salesforce never writes Budgets to QuickBooks. Every Budget webhook results in a live QBO query.


4.4 WS_Quickbooks — Budget Methods

Method

Returns

Purpose

queryBudget(QBRequestWrapper oRequestWrapper)

QBResponseWrapper

Runs oRequestWrapper.query against the QBO Query endpoint; populates lstBudgetResponse

queryBudgetCount(String whereClause)

QBResponseWrapper

Runs select COUNT(*) from Budget; raw body in oAPIResponse.apiResponseBody

createBudgetResponseWrapper(String) / (Map<String, Object>)

QBApiDataWrapper.Budget

Parses one QBO Budget, including MetaData timestamps and every BudgetDetail line

createBudgetRecords(QBResponseWrapper, String sCompanyId)

List<cm_finance__Quickbooks_Budget__c>

Maps wrappers to Salesforce Budget records (status Awaiting Salesforce Sync)

createBudgetDetailRecords(QBResponseWrapper, Map<String, Id> mapBudgetId)

List<cm_finance__Quickbooks_Budget_Detail__c>

Maps every BudgetDetail line to a child record and builds its External_Key__c

parseQBODateTime(String)

(private)

DateTime

Converts QBO ISO-8601 timestamps with UTC offsets (e.g. 2015-07-14T14:25:49-07:00) to the equivalent UTC instant

queryBudget error messages (set on oAPIResponse.errorMessage):

Message

Meaning

Budget Query is not found

oRequestWrapper.query was blank

Issue in Authentication :: <detail>

Access token refresh failed

Budget QueryResponse not found

HTTP 200 but no QueryResponse key

Budget Data not found

QueryResponse present but no Budget array, or the array is empty

Budget API Data not found

HTTP 200 with an empty body

<QBO fault> or <status> :: <body>

Non-200 response, parsed with CMQBConnectorUtility.handleAPIResponseError()

Exception <message> at Line Number :: <n>

Unexpected exception


4.5 QBApiDataWrapper — Budget Classes

QBApiDataWrapper.Budget

Field

Type

QBO Source

QB_Id

String

Id

QB_SyncToken

String

SyncToken

name

String

Name

startDate / endDate

Date

StartDate / EndDate

budgetType

String

BudgetType

budgetEntryType

String

BudgetEntryType

active

Boolean

Active

sparse

Boolean

sparse

domain

String

domain

createdDateTime / lastUpdatedTime

DateTime

MetaData.CreateTime / MetaData.LastUpdatedTime

budgetDetails

List<BudgetDetail>

BudgetDetail[]

QBApiDataWrapper.BudgetDetail — amount (Decimal), budgetDate (Date), lstReferenceDetail (List<ReferenceDetail>).

QBApiDataWrapper.ReferenceDetail — sReferenceType (AccountRef, CustomerRef, ClassRef or DepartmentRef), sName, sValue (QBO Id).

QBResponseWrapper.lstBudgetResponse holds the parsed Budgets.


4.6 UpsertCustomRecords — Budget Methods

Method

Target Object

Upsert Key

upsertQBBudgetRecords()

cm_finance__Quickbooks_Budget__c

cm_finance__Composite_Unique_Key__c

upsertQBBudgetDetailRecords()

cm_finance__Quickbooks_Budget_Detail__c

cm_finance__External_Key__c

Both delegate to Data.upsurt() with FLS enforcement from CMQBConnectorUtility.enforceFLS.


5. Data Model

5.1 cm_finance__Quickbooks_Budget__c (Quickbooks Budget)

Record name: Auto Number QB-{000000} Relationships: Lookup to QuickBooks Company (Quickbooks_Company__c)

Field

API Name

Type

Populated From

Budget Name

Name__c

Text(255)

Name

QuickBooks Budget Id

QuickBooks_Budget_Id__c

Text(255), External Id

Id

Composite Unique Key

Composite_Unique_Key__c

Text(255), External Id, Unique

{QB_Id}::Budget::{RealmId}

Sync Token

Sync_Token__c

Text(20)

SyncToken

Budget Type

Budget_Type__c

Picklist: ProfitAndLoss

BudgetType

Budget Entry Type

Budget_Entry_Type__c

Picklist: Monthly, Quarterly, Annually

BudgetEntryType

Start Date

Start_Date__c

Date

StartDate

End Date

End_Date__c

Date

EndDate

Active

Active__c

Checkbox

Active (defaults to false if absent)

Sparse

Sparse__c

Checkbox

sparse

Domain

Domain__c

Text(20)

domain

Created Time

Created_Time__c

DateTime

MetaData.CreateTime

Last Updated Time

Last_Updated_Time__c

DateTime

MetaData.LastUpdatedTime

Last Sync Date

Last_Sync_Date__c

DateTime

System.now() at sync time

Quickbooks Company

Quickbooks_Company__c

Lookup(Quickbooks Company)

Company matched by Realm Id

Quickbooks Company Realm Id

Quickbooks_Company_Realm_Id__c

Text(255)

Realm Id

Status

Status__c

Picklist

See 5.3

Status (image)

Status_Image__c

Formula (Text)

Status icon rendered from Status__c

Status Description

Status_Description__c

Long Text Area

Error details when a sync fails

5.2 cm_finance__Quickbooks_Budget_Detail__c (Quickbooks Budget Detail)

Record name: Auto Number QBD-{000000} Relationships: Master-Detail to Quickbooks Budget (Quickbooks_Budget__c) — detail records are deleted with their Budget.

Field

API Name

Type

Populated From

Quickbooks Budget

Quickbooks_Budget__c

Master-Detail

Parent Budget

Amount

Amount__c

Currency

BudgetDetail.Amount

Budget Date

Budget_Date__c

Date

BudgetDetail.BudgetDate (start of the period)

Account Ref Id / Name

Account_Ref_Id__c / Account_Ref_Name__c

Text(255)

AccountRef.value / .name

Customer Ref Id / Name

Customer_Ref_Id__c / Customer_Ref_Name__c

Text(255)

CustomerRef.value / .name

Class Ref Id / Name

Class_Ref_Id__c / Class_Ref_Name__c

Text(255)

ClassRef.value / .name

Department Ref Id / Name

Department_Ref_Id__c / Department_Ref_Name__c

Text(255)

DepartmentRef.value / .name

External Key

External_Key__c

Text(255), External Id

Built by createBudgetDetailRecords() — see 5.4

Name / Reference Type / Value

Name__c / Reference_Type__c / Value__c

Text / Picklist / Text

Present on the object but not populated by the current sync

The reference fields store the QBO Id and display name as text; they are not lookups to cm_finance__Quickbooks_Account__c, cm_finance__Quickbooks_Customer__c or cm_finance__Quickbooks_Class__c. Join on Quickbooks_Id__c in reports if needed.

5.3 Status Lifecycle

Status

Set By

Awaiting Salesforce Sync

Transient value set by createBudgetRecords() before upsert

Successfully Synced

Budget and all its detail lines upserted

Failed

Full sync: detail upsert failed. Webhook: QBO API call failed

Salesforce Sync Failed

Webhook: unhandled exception in handleQBBudget()

Awaiting QB Sync

Available in the picklist but not used (Budget is never pushed to QuickBooks)

5.4 Key Formats

Record

Key

Format

Example

Budget

Composite_Unique_Key__c

{QB_Id}::Budget::{RealmId}

12::Budget::9341452345

Budget Detail

External_Key__c

{QB_Budget_Id}[::AccountRef::{id}][::CustomerRef::{id}][::ClassRef::{id}][::DepartmentRef::{id}]::{BudgetDate}

12::AccountRef::1::2026-01-01

Only the reference segments present on a line are included. Re-running a sync therefore updates existing lines instead of duplicating them.


6. Configuration Reference

Setting

Location

Default

Effect on Budget Sync

BatchSize

cm_finance__Quickbooks_Common_Settings__c

50

Full sync: capped at 10 Budgets per chunk. Webhook: used as-is

BatchAllQBSFAllBudgetSync_WhereClause

cm_finance__CM_QB_Connector_Settings__mdt (Value__c)

(record not shipped — all Budgets synced)

Optional QBO WHERE clause appended to the COUNT and page queries, e.g. WHERE Active = true

GuestUsers

cm_finance__Quickbooks_Common_Settings__c

AutomatedProcess;CloudIntegrationUser;Guest

User types that bypass FLS enforcement (webhook runs as the Site Guest User)

Note on the metadata record name: CMQBConnectorUtility.getWhereClause('AllBudget') looks for the DeveloperName BatchAllQBSFAllBudgetSync_WhereClause (note the extra All), not BatchAllQBSFBudgetSync_WhereClause. Create the record with exactly that name to filter the full sync.

6.1 Site Guest User Access (Webhook)

Budget webhooks are processed as the Site Guest User. In addition to the steps in Post Installation Steps, grant the guest user profile:

  • Apex Class Access: cm_finance.BatchQBSFBudgetWebhook

  • Object Settings: cm_finance__Quickbooks_Budget__c and cm_finance__Quickbooks_Budget_Detail__c — Read, Create, Edit, View All Fields; field Read/Edit on all fields

6.2 Internal Users

Grant users who need to view Budgets Read access to both objects and their fields (via the CM QuickBooks Connector permission sets or a separate permission set), and add a Quickbooks Budgets tab and the Quickbooks Budget Details related list to the Budget page layout as required.


7. End-to-End Flow Diagrams

7.1 Full Sync

Admin clicks "Budget" on Sync Data from QuickBooks
  │
  ▼
AdminSetupController.syncQuickbooksData('Budget', realmId)
  │
  ▼
BatchAllQBSFBudgetSync.execute(null)
  ├── queryBudgetCount()  → totalCount = 25
  ├── batchSize = min(BatchSize, 10) = 10
  └── Database.executeBatch(new BatchAllQBSFBudgetSync(25), 10)
         │
         ├── start(): another run Processing? → yes: exit / no: 25 placeholders
         │
         ├── execute() chunk 1 → startposition 1,  Budgets 1–10
         ├── execute() chunk 2 → startposition 11, Budgets 11–20
         ├── execute() chunk 3 → startposition 21, Budgets 21–25
         │      each chunk: upsert Budgets → upsert Details → set Status
         │
         └── finish(): Last_Sync_Config__c.Budget = {lastSyncedAt: today, status: Completed}

7.2 Webhook

User edits a Budget in QuickBooks Online
  │
  ▼
QBO → POST /services/apexrest/getUpdatesQB/   (Header: RealmId)
  { "name": "Budget", "id": "12", "operation": "Update" }
  │
  ▼
Webhook_QBChangeHandler → mapEntitiesData = { "budget": ["12"] }
  → Database.executeBatch(new BatchQBSFBudgetWebhook(["12"], realmId), BatchSize)
  → HTTP 200
  │
  ▼
BatchQBSFBudgetWebhook.execute() → QuickbooksIntegrationHelper.handleQBBudget()
  ├── SELECT * FROM Budget WHERE Id IN ('12')
  ├── Upsert Budget  on 12::Budget::{RealmId}
  ├── Upsert Details on External_Key__c
  └── Status = 'Successfully Synced'


8. Known Limitations & Design Notes

Item

Detail

Read-only entity

No Salesforce → QuickBooks path; edit Budgets in QuickBooks Online

Removed lines are not cleaned up

Detail lines are upserted by External_Key__c only. A line removed from a QBO Budget (or whose account/customer/class/department changes) remains in Salesforce — unlike Invoice/Bill line reconciliation, there is no delete/archive step

Detail key has no Realm Id

External_Key__c does not include the Realm Id. If two connected companies contain Budgets with the same QBO Id, reference Ids and dates, their detail lines share a key

Full sync errors are log-only

API failures, missing company, and exceptions in BatchAllQBSFBudgetSync.execute() are only written to debug logs

Webhook batch size is not capped

The 10-Budget cap applies to the full sync only; a webhook delivering many large Budgets at once uses BatchSize

Concurrency guard is per class

A second full sync (even for another company) exits immediately while one is Processing — run companies sequentially

Unused fields

Name__c, Reference_Type__c, Value__c on Budget Detail and the Awaiting QB Sync status are not used


9. Test Classes

Test Class

Covers

WS_QuickbooksBudgetTest

queryBudget, queryBudgetCount, response parsing, record builders and error branches

BatchAllQBSFBudgetSyncTest

Full sync: count/dispatch, concurrency guard, page processing, finish()

BatchQBSFBudgetWebhookTest

Webhook batch and handleQBBudget()

QBBudgetSyncMockHttpCallout

HttpCalloutMock returning token, COUNT and Budget responses (one detail line per reference type), with flags to simulate failures