1. Overview
The Journal Entry Creation system provides a standalone form for creating QuickBooks Journal Entries directly from Salesforce without requiring a source record context. It is a purpose-built double-entry bookkeeping UI that enforces accounting balance constraints (debits must equal credits) before any callout is made.
The component, qbJournalLedger, is a standalone LWC surfaced through an Aura wrapper (QBJournalEntryAura) and navigates the user to the created cm_finance__Quickbooks_Journal_Entry__c record on success. The Apex backend (QuickbooksController) deserializes the submitted payload, calls WS_Quickbooks.createJournalLedger(), and persists the resulting Journal Entry and its line items in Salesforce via upsert on cm_finance__Composite_Unique_Key__c.
2. Component Architecture
┌────────────────────────────────────────────────────────────────────┐
│ Salesforce Page / Tab │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ QBJournalEntryAura (Aura wrapper) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ qbJournalLedger (LWC) │ │ │
│ │ │ │ │ │
│ │ │ Header: Company | Journal Date | Memo │ │ │
│ │ │ Line Items Table (dynamic rows): │ │ │
│ │ │ Account (record-picker) → getAccountType() │ │ │
│ │ │ Type (Debit/Credit) │ │ │
│ │ │ Amount | Description │ │ │
│ │ │ Customer/Vendor (conditional record-picker) │ │ │
│ │ │ → getEntityInfo() │ │ │
│ │ │ Totals: Debit | Credit | Difference (color-coded) │ │ │
│ │ │ Actions: Back | Save Journal Entry (guarded) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ @AuraEnabled Apex Calls │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ QuickbooksController │ │
│ │ getListCompanies() getAccountType() │ │
│ │ getEntityInfo() createJournalLedgerInQuickbooks()│ │
│ └───────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼──────────────────────────────────┐ │
│ │ Shared Service Layer │ │
│ │ WS_Quickbooks │ QBApiDataWrapper │ Data │ │
│ └───────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ QuickBooks Online API │
│ POST /v3/company/{realmId}/journalentry │
└────────────────────────────────────────────────────────────────────┘
3. LWC Component — qbJournalLedger
3.1 Component Identity
|
Property |
Value |
|---|---|
|
Component Name |
|
|
JS Class Name |
|
|
Aura Wrapper |
|
|
Location |
|
|
Apex Imports |
|
3.2 Entity Type Constants
The component uses four constants to represent the AR/AP classification state of each line's selected account. These drive conditional rendering of the Customer/Vendor picker column.
|
Constant |
Value |
Meaning |
|---|---|---|
|
|
|
Account type that requires a Customer entity |
|
|
|
Account type that requires a Vendor entity |
|
|
|
Account selected — no entity required |
|
|
|
Account is AR — Customer picker shown |
|
|
|
Account is AP — Vendor picker shown |
|
|
|
No account selected yet — placeholder shown |
3.3 Tracked State
|
Property |
Type |
Description |
|---|---|---|
|
|
String |
Transaction date, defaults to today ( |
|
|
String |
Internal memo (maps to QB |
|
|
Array |
|
|
|
String |
QB Realm ID of the active company |
|
|
String |
SF record ID of the active company |
|
|
Array |
Live array of line item objects (see shape below) |
|
|
Number |
Running debit total |
|
|
Number |
Running credit total |
|
|
Boolean |
Controls spinner and Save button disabled state |
|
|
String |
Inline error banner message |
|
|
Boolean |
Controls full form vs. no-company empty state |
|
|
Object |
|
3.4 Line Item Object Shape
Each line in lineItems carries both UI-helper fields and the QB API payload fields in the same object:
{
// ── Salesforce UI helpers ──────────────────────────
id : <auto-increment>, // unique key for for:each
accountSfId : null, // SF Id of selected QB Account
entitySfId : null, // SF Id of selected Customer/Vendor
entityType : ENTITY_UNKNOWN, // drives entity column rendering
requiresCustomer : false, // true for AR accounts
requiresVendor : false, // true for AP accounts
noEntityRequired : false, // true for all other account types
accountNotSelected: true, // true before any account is selected
// ── QB API payload fields ──────────────────────────
postingType : '', // 'Debit' | 'Credit'
amount : null, // Decimal (≥ 0.01)
description : '', // optional line description
accountQbId : '', // QB AccountRef.value (external ID)
accountName : '', // QB AccountRef.name
entityQbId : '', // QB Entity.EntityRef.value
entityName : '' // QB Entity.EntityRef.name
}
3.5 Initialization Flow
connectedCallback()
├─ _initLineItems() → create 2 blank lines (standard QB minimum)
└─ loadCompanies()
│
└─ getConnectedCompanies()
│
├─ result empty → companyFound = false (empty state shown)
└─ result non-empty:
├─ Build companyOptions []
├─ Auto-select first company (selectedCompanyId, selectedCompanyRecordId)
├─ _updateFilters() → set record-picker filter by company
└─ companyFound = true (form shown)
3.6 Account Selection Flow (per line)
Account selection is the most complex interaction because selecting an account determines whether the line needs a Customer, Vendor, or neither:
User selects account in lightning-record-picker
│
├─ accountId = null (account cleared):
│ Reset line: accountSfId/QbId/Name = null/'', entityType = ENTITY_UNKNOWN
│ Clear entity fields, set accountNotSelected = true
│
└─ accountId populated:
│
└─ getAccountType({ accountId }) [cacheable Apex call]
│
└─ result.accountType →
ACCOUNT_TYPE_AR ('Accounts Receivable') → entityType = ENTITY_CUSTOMER
ACCOUNT_TYPE_AP ('Accounts Payable') → entityType = ENTITY_VENDOR
anything else → entityType = ENTITY_NONE
│
Update line (immutable map pattern):
accountSfId, accountQbId (externalId), accountName
entityType, requiresCustomer, requiresVendor, noEntityRequired
accountNotSelected = false
clear previous entity selection
3.7 Entity (Customer / Vendor) Selection Flow (per line)
User selects Customer or Vendor in entity record-picker
│
├─ entitySfId = null (cleared):
│ Reset: entitySfId = null, entityQbId = '', entityName = ''
│
└─ entitySfId populated:
│
└─ getEntityInfo({ recordId: entitySfId, objectType: 'Customer'|'Vendor' })
│
└─ Update line:
entitySfId, entityQbId (externalId), entityName (displayName)
3.8 Line Item Management
|
Action |
Behaviour |
|---|---|
|
Add Line |
Appends a new |
|
Remove Line (>2 lines) |
Removes the line at |
|
Remove Line (≤2 lines) |
Removes the line then immediately appends a blank — minimum 2 lines always maintained |
The minimum-2-lines invariant matches standard QuickBooks Journal Entry behaviour (a valid journal entry requires at least one debit and one credit).
3.9 Computed Getters
|
Getter |
Returns |
Logic |
|---|---|---|
|
|
Boolean |
|
|
|
String |
|
|
|
String |
|
|
|
String |
Currency format of |
|
|
String |
Green ( |
|
|
Boolean |
|
3.10 Save Flow and Validation
handleSave()
│
├── [1] Header validation:
│ Company selected? → fail: 'Please select a company.'
│ journalDate populated? → fail: 'Journal Date is required.'
│
├── [2] Line validation (per-line):
│ accountSfId present? → fail: 'Line N: Account is required.'
│ postingType present? → fail: 'Line N: Type (Debit/Credit) is required.'
│ amount > 0? → fail: 'Line N: Amount must be greater than zero.'
│ requiresCustomer + no entitySfId? → fail: '...Customer name is required for AR lines.'
│ requiresVendor + no entitySfId? → fail: '...Vendor name is required for AP lines.'
│
├── [3] Balance validation:
│ abs(totalDebit - totalCredit) >= 0.01 → fail: 'Total Debits must equal Total Credits.'
│
├── [4] Build QB API payload:
│ {
│ TxnDate, PrivateNote,
│ Line: [ { DetailType, Amount, Description,
│ JournalEntryLineDetail: {
│ PostingType, AccountRef: {value, name, recordId},
│ Entity (AR/AP only): { Type, EntityRef: {value, name, recordId} }
│ }
│ }, ... ]
│ }
│
└── [5] createJournalLedgerInQuickbooks({ reqBody, companyId, companyRecordId })
│
├─ res.isSuccess = true:
│ _showToast('Success')
│ _initLineItems() (reset form)
│ NavigationMixin.Navigate → recordPage (res.recordId)
│
└─ res.isSuccess = false:
handleError(res.message) → toast
3.11 Template States
|
Condition |
Rendered UI |
|---|---|
|
|
|
|
|
Empty state: pulsing CSS rings, icon, 3-step setup instructions, "Go to Admin Setup" + "Retry" buttons |
|
|
Full form: header fields + line items table (only when |
|
|
Line items table hidden (table requires company-scoped filter) |
|
|
|
|
|
|
|
|
Static text: "Not required" |
|
|
Static text: "Please select QuickBooks Account first." |
|
|
Red alert banner inline above action buttons |
3.12 Record Picker Configuration
Account picker (cm_finance__Quickbooks_Account__c):
-
Display:
cm_finance__Name__c,cm_finance__Account_Type__c,cm_finance__Account_Sub_Type__c -
Matching:
cm_finance__Name__c,cm_finance__Account_Type__c -
Filter:
cm_finance__Quickbooks_Company__c = selectedCompanyRecordId(set by_updateFilters())
Customer picker (cm_finance__Quickbooks_Customer__c):
-
Display:
cm_finance__Fully_Qualified_Name__c,cm_finance__First_Name__c -
Matching:
cm_finance__Fully_Qualified_Name__c,cm_finance__First_Name__c -
Filter: same company filter
Vendor picker (cm_finance__Quickbooks_Vendor__c):
-
Display:
cm_finance__Display_Name__c,cm_finance__Email__c -
Matching:
cm_finance__Display_Name__c,cm_finance__Email__c -
Filter: same company filter
All three record pickers share the same filter object, ensuring users only see records belonging to the selected company.
4. Apex Controller — QuickbooksController (Journal Ledger Section)
4.1 getAccountType(String accountId) → Map<String, String>
Retrieves account metadata for a given cm_finance__Quickbooks_Account__c record. Called by the LWC immediately after account selection to determine whether the line needs a Customer or Vendor entity picker. Marked cacheable=true — the browser caches the result for the same accountId within a component lifecycle.
Query: SELECT Id, cm_finance__Account_Type__c, cm_finance__Account_Sub_Type__c, cm_finance__Name__c, cm_finance__Quickbooks_Id__c FROM cm_finance__Quickbooks_Account__c WHERE Id = :accountId
|
Response Key |
Source Field |
Description |
|---|---|---|
|
|
|
Used to set |
|
|
|
Returned for context (not currently used in entity logic) |
|
|
|
Stored as |
|
|
|
Stored as |
|
|
|
|---|---|
|
Access |
|
|
FLS |
Enforced via |
|
Error |
Throws |
4.2 getEntityInfo(String recordId, String objectType) → Map<String, String>
Retrieves the QB external ID and display name for a Customer or Vendor record selected in the entity picker. Also marked cacheable=true. The objectType parameter ('Customer' or 'Vendor') determines which sObject to query.
Customer path:
-
Query:
SELECT Id, cm_finance__Quickbooks_Id__c, cm_finance__Fully_Qualified_Name__c FROM cm_finance__Quickbooks_Customer__c WHERE Id = :recordId -
Returns:
{externalId: Quickbooks_Id__c, displayName: Fully_Qualified_Name__c}
Vendor path:
-
Query:
SELECT Id, cm_finance__Quickbooks_Id__c, cm_finance__Display_Name__c FROM cm_finance__Quickbooks_Vendor__c WHERE Id = :recordId -
Returns:
{externalId: Quickbooks_Id__c, displayName: Display_Name__c}
|
|
|
|---|---|
|
Access |
|
|
Returns |
|
|
Error |
Throws |
4.3 createJournalLedgerInQuickbooks(String reqBody, String companyId, String companyRecordId) → Map<String, Object>
The primary journal entry creation method. Deserializes the LWC-built JSON payload, builds a typed QBApiDataWrapper.JournalLedger, makes the QB API callout, and persists the result as Salesforce records.
Phase 1 — Payload Deserialization:
Parses the raw JSON string reqBody into a QBApiDataWrapper.JournalLedger:
-
oJournal.txnDate←TxnDate -
oJournal.privateNote←PrivateNote -
For each entry in
Line[]:-
Creates
QBApiDataWrapper.LinewithdetailType,amount,description -
Creates
QBApiDataWrapper.JournalEntryLineDetailwithpostingType -
Creates
QBApiDataWrapper.AccountReferenceforaccountRef(value, name, recordId) -
If
Entitykey present: createsQBApiDataWrapper.EntitywithentityTypeandentityRef(value, name, recordId)
-
SF ID Maps built during deserialization (used for backfill after callout):
accountQBId : Map<QB_Account_Id → SF_Account_RecordId>
customerQBId : Map<QB_Customer_Id → SF_Customer_RecordId>
vendorQBId : Map<QB_Vendor_Id → SF_Vendor_RecordId>
These maps are populated from the LWC payload's recordId fields so that, if the QB API response strips or omits recordId values, the method can restore them before persisting.
Phase 2 — API Callout:
WS_Quickbooks.sCompanyId = oWrapper.companyRealmId;
WS_Quickbooks.oCredentials = cm_finance__Quickbooks_Credentials__c.getInstance(oWrapper.companyRealmId);
QBApiDataWrapper.QBResponseWrapper oResponseWrapper = oClass.createJournalLedger(oWrapper);
Phase 3 — SF ID Backfill (on success):
After the callout, the QB API response is parsed into oResponseWrapper.lstJournalWrapperResponse. For every line in the response:
-
If
accountRef.recordIdis null butaccountRef.valueexists: restore fromaccountQBIdmap -
If
entityRef.recordIdis null:-
Customer line: restore from
customerQBIdmap -
Vendor line: restore from
vendorQBIdmap
-
This ensures SF record IDs are available on the line items before they are persisted, even if the QB API response does not echo them back.
Phase 4 — Record Parsing:
Calls WS_Quickbooks.createJournalLedgerRecords(oResponseWrapper), which returns:
-
lstJournalLedgers:List<cm_finance__Quickbooks_Journal_Entry__c> -
mapJournalLedgerLineItems:Map<QB_Journal_Id → List<cm_finance__Quickbooks_Journal_Entry_Item__c>>
Phase 5 — Journal Entry Persistence:
For each journal entry:
-
Sets
cm_finance__Parent_Record_Id__c = oWrapper.parentRecordId(currently null — component is standalone) -
Sets
cm_finance__Quickbooks_Company__c = companyRecordId -
Sets
cm_finance__Quickbooks_Company_Realm_Id__c = companyId -
Sets
cm_finance__Status__c = 'Successfully Synced' -
Sets
cm_finance__Private_Note__c = privateNote -
Sets
cm_finance__Composite_Unique_Key__c = QB_Id::JournalEntry::RealmId -
Routes to
lstAllUpsertLedgers(if QB_Id present) orlstAllInsertLedgers(fallback)
Upserts lstAllUpsertLedgers on cm_finance__Composite_Unique_Key__c. Inserts lstAllInsertLedgers via data.create(). Records the first created record's Id in mapResponse.put('recordId', ...) for LWC navigation.
Phase 6 — Line Item Persistence:
Builds mapQBIdToLedgerId (QB journal ID → SF journal record ID) from both upsert and insert lists. For each line item:
-
Sets
cm_finance__Quickbooks_Journal_Entry__c= parent SF journal record ID (frommapQBIdToLedgerId) -
Sets
cm_finance__Quickbooks_Company__candcm_finance__Quickbooks_Company_Realm_Id__c -
Sets
cm_finance__Composite_Unique_Key__c = QB_Journal_Id::Line_Id::RealmId
Upserts all line items on cm_finance__Composite_Unique_Key__c.
|
|
|
|---|---|
|
Access |
|
|
DML: Journal Entries |
Upsert on |
|
DML: Line Items |
Upsert on |
|
Returns |
|
|
No echo suppression |
Journal entries are not set with |
5. Data Model — QBApiDataWrapper (Journal Classes)
QBRequestWrapper
└─ oJournal : JournalLedger
QBResponseWrapper
└─ lstJournalWrapperResponse : List<JournalLedger>
JournalLedger
├─ txnDate : String
├─ QB_Id : String
├─ QB_SyncToken : String
├─ adjustment : Boolean
├─ privateNote : String
└─ lstJournalItems : List<Line>
Line (extends LineV)
├─ detailType : String ('JournalEntryLineDetail')
├─ amount : Decimal
├─ description : String
├─ lineId : String
├─ lineNumber : Decimal
├─ salesItemDetail : SalesLineItemDetail (unused for journals)
└─ journalEntryDetails : JournalEntryLineDetail
JournalEntryLineDetail
├─ postingType : String ('Debit' | 'Credit')
├─ accountRef : AccountReference {value: QB_Id, name, recordId: SF_Id}
└─ entityDetails : Entity (only for AR/AP lines)
Entity
├─ entityType : String ('Customer' | 'Vendor')
└─ entityRef : AccountReference {value: QB_Id, name, recordId: SF_Id}
AccountReference
├─ value : String (QB external ID)
├─ name : String (display name)
└─ recordId : String (Salesforce record ID)
6. QB API Payload Structure
The LWC constructs this JSON object and passes it as reqBody to createJournalLedgerInQuickbooks:
{
"TxnDate" : "2025-05-03",
"PrivateNote" : "Adjusting entry for Q1",
"Line": [
{
"DetailType" : "JournalEntryLineDetail",
"Amount" : 500.00,
"Description" : "Revenue recognition",
"JournalEntryLineDetail": {
"PostingType": "Credit",
"AccountRef" : {
"value" : "35",
"name" : "Sales of Product Income",
"recordId" : "a0B5g000001XyZEAA0"
}
}
},
{
"DetailType" : "JournalEntryLineDetail",
"Amount" : 500.00,
"Description" : "AR entry",
"JournalEntryLineDetail": {
"PostingType": "Debit",
"AccountRef" : {
"value" : "12",
"name" : "Accounts Receivable (A/R)",
"recordId" : "a0B5g000001AbCDAA0"
},
"Entity": {
"Type" : "Customer",
"EntityRef" : {
"value" : "58",
"name" : "Acme Corp",
"recordId" : "a0C5g000002KlMNAA0"
}
}
}
}
]
}
The recordId fields inside AccountRef and EntityRef are Salesforce-only additions — they are carried through the Apex deserialization for SF ID backfill and are not sent to the QB API.
7. End-to-End Flow Diagram
User opens qbJournalLedger component
│
▼
connectedCallback()
├─ _initLineItems() → 2 blank rows
└─ loadCompanies() → getListCompanies()
│
┌──────┴───────────────┐
No companies Companies found
│ │
Show empty state Auto-select first company
(Admin Setup CTA) _updateFilters()
Show form
│
User fills header (Date, Memo) + selects Company
│
User selects Account on a line
└─ getAccountType(accountId) [cached]
→ Set entityType on line
→ Show: Customer picker | Vendor picker | "Not required"
│
User selects Customer/Vendor (if AR/AP line)
└─ getEntityInfo(entitySfId, objectType) [cached]
→ Set entityQbId, entityName on line
│
User enters Amount, Type, Description
└─ handleLineFieldChange() → _calculateTotals()
→ Update totalDebit, totalCredit
→ differenceStyle: green if balanced, red if not
│
User clicks "Save Journal Entry"
(Button enabled only when debit = credit AND debit > 0)
│
handleSave()
├─ Validate header
├─ Validate each line (account, type, amount, entity)
├─ Validate debit = credit (tolerance 0.01)
├─ Build QB API payload JSON
└─ createJournalLedgerInQuickbooks(reqBody, companyId, companyRecordId)
│
┌──────┴────────────────────────┐
res.isSuccess = true res.isSuccess = false
│ │
Toast: Success handleError(res.message)
_initLineItems() → Toast: Error
Navigate to res.recordId
(cm_finance__Quickbooks_Journal_Entry__c record page)
8. Salesforce Object Reference
8.1 cm_finance__Quickbooks_Journal_Entry__c (Header)
|
Field |
Description |
|---|---|
|
|
QB-assigned journal entry ID |
|
|
|
|
|
Lookup to |
|
|
QB Realm ID string |
|
|
Source SF record ID (null for standalone journal entries) |
|
|
|
|
|
Internal memo from the form |
8.2 cm_finance__Quickbooks_Journal_Entry_Item__c (Line Items)
|
Field |
Description |
|---|---|
|
|
Parent lookup (resolved via |
|
|
QB-assigned line sequence ID |
|
|
|
|
|
Lookup to company |
|
|
QB Realm ID string |
8.3 Composite_Unique_Key__c Format
|
Entity |
Format |
|---|---|
|
Journal Entry |
|
|
Journal Entry Line Item |
|
9. Design Decisions
9.1 Immutable Line Item Updates
Every change to a line item is applied using the JavaScript immutable map pattern (lineItems.map((line, i) => i === index ? {...line, [field]: value} : line)). This ensures LWC reactive property tracking picks up the change without requiring @track on individual properties within the array, and prevents accidental mutation of sibling rows.
9.2 Per-Account-Selection Apex Callout
Rather than loading all account types up front, the component makes a fresh getAccountType() callout each time the user selects an account. The method is cacheable=true, meaning the platform caches identical responses within the component lifecycle. This avoids loading the full chart of accounts into memory while still providing instant feedback on whether a Customer or Vendor picker should appear.
9.3 SF ID Round-Trip via recordId in Payload
The LWC embeds Salesforce record IDs (accountSfId, entitySfId) inside the QB API payload object (AccountRef.recordId, EntityRef.recordId). These fields have no meaning to the QB API but allow the Apex method to build the accountQBId, customerQBId, and vendorQBId reverse-lookup maps during deserialization. If the QB response returns lines without recordIds (which can happen depending on the QB API version), the backfill step restores them before persisting line items — eliminating a separate post-callout query.
9.4 Balance Guard on Save Button
isSaveDisabled is a computed getter that disables the Save button the moment debits and credits diverge, providing instant visual feedback without requiring the user to attempt a save. The accounting constraint (debit = credit) is still re-validated server-side in handleSave() as a defence-in-depth measure.
9.5 Minimum Two Lines Invariant
When the user removes a line that would leave fewer than two rows, removeLine() immediately appends a blank line after the removal. This mirrors standard QB Journal Entry behaviour where the form always shows at least two lines and prevents a state where a user could submit a single-line (inherently unbalanced) entry.
9.6 Standalone Component — No Source Record Context
Unlike the invoice and product flows in cmQBConnectorMaster, qbJournalLedger operates without a source Salesforce record. oWrapper.parentRecordId is explicitly left null in the Apex method. This means journal entries created through this component have cm_finance__Parent_Record_Id__c = null — they are purely QB-originated transactions created from Salesforce, not mapped to any SF source object.
9.7 Aura Wrapper
QBJournalEntryAura.cmp wraps the LWC to enable placement in contexts that require an Aura component (e.g., certain utility bars or page layouts in older org configurations). It contains no logic of its own — all behaviour lives in the LWC.