ClientManager · Engineering Reference

QuickBooks Online Integration

Solves the double-entry problem — the same Invoice/Payment/Bill re-keyed by hand into both CM and QuickBooks — by pushing what CM owns into a tenant's real QuickBooks Online company, on a bookkeeper's own say-so. QuickBooks stays the system of record for accounting; CM stays the front-end. Written 2026-09-02. Target: QuickBooks Online, not Desktop.

Phases A–F built 6 phases (A–F) 12 entities scoped 2 real structural blockers found
WHY THIS EXISTS — CM was deliberately never meant to compete with QuickBooks as an accounting platform ("double entry we what i wanted to prevent/minimize" — the founder's own words, years before this plan). Never automatic: in IRBS (the VB6 desktop predecessor), a bookkeeper/accountant decided what and when to push, and separately what and when to pull — nothing ran on a schedule. This plan keeps that exact discipline, and adds one new principle CM's own design calls for: since CM is meant to be the tenant's primary front-end (not one of two systems being kept in sync bidirectionally), a push always overwrites QuickBooks with CM's data — there's no drift detection, no comparison UI, by choice. Confirmed competitive angle (2026-09-02): push as much of QBO's real field surface as CM can capture, not just the minimum to avoid re-keying — a deeper, more complete QBO sync than competitors' (JobTread named directly) is a real differentiator, not just a checkbox integration.

Grounded facts

Verified 2026-09-02 — against real code and Intuit's own live docs, not memory or assumption.

The schema already anticipated this — far more than expected

A full audit of every column named like a sync-tracking field turned up 14 tables with dormant tracking columns already sitting in the schema, several more than a first pass (checking only C# edit models, not the raw database) had found:

TableColumn(s)Note
ProjectInfoAccountingLinkProjectID, AccountingLinkCustomerID, ProjectGUIDTwo separate ID slots — a Job needs its own QB id and its parent Customer's, confirming the sub-customer hierarchy was already understood
EmployeeEmployeeGUIDReal column, missed by an edit-model-only check
TimeRecordTxnIDReal column — an earlier "stale memory" correction was itself wrong
BillExternalRefID, TransactionGUIDTwo competing columns on one table
BillPayment, Invoice, CreditMemo, Organization, AssetExternalRefIDConsistent single-column pattern
Organization, Contact, JournalEntryItemEntityGUIDA second, different pattern — dormant, unclear original purpose
GLAccountAccountGUID, EntityGUIDMatches the Lists-tier "Chart of Accounts" pull-only entity from the IRBS manual
CheckInfoExternalReferenceMatches IRBS's "Checks" pull-only entity — previously assumed to have no CM equivalent
JournalEntryExternalRefIDNever in the IRBS Tier 1 push list — purpose not yet confirmed
CostAssemblyTxnIDNever scoped at all until this audit

None of these old columns store a QBO SyncToken (optimistic-concurrency token) — they were shaped for the older QBXML desktop SDK, which never had this concept. A new link table is needed regardless of whether the old columns get reused.

Real structural blockers — not solved by a link table alone

EntityQBO requiresThe gap
Invoice / PaymentEvery line must reference a real QBO Item — free-text lines are rejectedNeeds an Item-mapping setup screen first. Confirmed by the user: IRBS solved this exact problem with a real "Payment Item Mapping" screen (RBS Payment Item → QB Service Item) — the direct precedent to rebuild.
Credit Memo (AR)Line itemsCM's CreditMemo is header-only — zero CreditMemoItem rows or model anywhere in the codebase. Not sync-ready until that's built as its own feature.

Real data-access pattern (beyond OAuth), confirmed against Intuit's own .NET SDK guide

Every actual API call - not just the OAuth handshake - goes through: OAuth2RequestValidator (wraps the access token) → a ServiceContext(realmId, IntuitServicesType.QBO, oauthValidator) with IppConfiguration.MinorVersion.Qbo set and IppConfiguration.BaseUrl.Qbo explicitly pointed at sandbox or production. Reads use QueryService<T> (e.g. new QueryService<CompanyInfo>(serviceContext).ExecuteIdsQuery("SELECT * FROM CompanyInfo")) - the same real-query mechanism the pre-write SyncToken refresh in this plan already relies on. Writes go through the SDK's DataService<T> counterpart (Add/Update) - confirm its exact signatures via the package's own XML docs before building Phase B, same technique already used for OAuth2Client.

Real Intuit OAuth2 specifics, confirmed live against developer.intuit.com

Design decisions

One new canonical link table, not more ad-hoc columns

Given 14 tables already have inconsistent dormant columns (four different naming conventions, no SyncToken anywhere), and QBO's REST API requires a SyncToken per record that none of them track — a single new QuickBooksEntityLink table (SiteID/EntityType/LocalRecordID/QuickBooksID/SyncToken) is the source of truth for every synced entity, present and future. The old columns are left alone, unused by this feature.

CM's push always wins — no drift detection

Confirmed with the user: CM is the front-end of record, not one of two systems kept in sync. A Customer/Job edited directly in QuickBooks is not detected or reconciled, by choice. The only handling needed is purely technical: before any update push, re-read the entity from QBO to get its current SyncToken so the write doesn't fail — CM's data still overwrites regardless of what that read returns.

Never automatic — matches IRBS exactly, both directions

Every entity type gets an Include checkbox; only checked rows push when "Synchronize" is clicked. This mirrors IRBS's own dashboard precisely, where both pull (the read-only "QuickBooks Records" mirror, used for matching) and push were separate, explicit, bookkeeper-triggered button clicks — never scheduled, never silent.

Lazy token refresh, not a background service

Access tokens are checked and refreshed on demand before each API call, avoiding this codebase's first BackgroundService for something that doesn't need proactive scheduling. If the refresh token itself lapses, the connection needs re-authorization — an expected state, not an error to engineer around.

Entity & transaction plan

All 11 of IRBS's real "Integration Items," plus Customer — the foundation entity IRBS's own hierarchy rule (no Transaction without a Customer and Job) requires before anything else can sync.

PlanEntity / TransactionStatus
A(none — OAuth2 connection only)Infrastructure
BCustomer, JobNo structural blocker — the starting point
CInvoice, Payment (Receive Payment)Blocked on the Item-mapping screen first
Credit MemoHeld back — no line-item model exists in CM yet
DVendor, BillBill's AP line-item/GL-account structure still unverified
EEmployee, Time TrackingTimeActivity needs new TimeRecord columns; Employee is clean
FEstimate — Detail, Summary, Job Cost (all 3)Hardest — lossy flatten from CM's Category/Item hierarchy

Not yet phased — found in the schema audit but not part of IRBS's own push list, purpose unconfirmed: GLAccount (matches the Lists-tier "Chart of Accounts," pull-only), CheckInfo (matches "Checks," pull-only), JournalEntry/JournalEntryItem, CostAssembly, Asset, Contact.EntityGUID.

Field-level mapping, per entity

The real check for "did CM capture everything QBO needs" — not just does the entity exist, but does every field QBO requires (or that CM would want to push) have a real CM column to source it from. Gaps are called out inline, not glossed over.

Customer — CM Organization/Contact(Person)

QBO's real, full Customer field list confirmed 2026-09-02 against Intuit's own API reference (not guessed) - both tables below checked against it, in both directions.

CM → QBO — what CM already has, and where it goes:

CM fieldQBO field
Organization.CompanyNameCompanyName / DisplayName
Contact.FirstName / LastName (when no company)GivenName / FamilyName
Contact.NamePrefix / NameSuffix / MiddleNameTitle / Suffix / MiddleName
MainEmail / ContactEmailPrimaryEmailAddr.Address
MainPhonePrimaryPhone.FreeFormNumber
Contact.MobilePhone (Person only) / Organization.AltPhoneMobile / AlternatePhone
Organization.MainFaxFax
Organization.WebsiteWebAddr
Organization.NameOnCheckPrintOnCheckName
Organization.TaxIdentity / Contact.FederalTaxNo/StateTaxNoPrimaryTaxIdentifier / SecondaryTaxIdentifier
PaymentTermIDSalesTermRef
Contact.PreferredContactMethodIDPreferredDeliveryMethod
IsActiveActive
Address/City/StateRegion/PostalCode/CountryIDBillAddr (Line1/City/CountrySubDivisionCode/PostalCode/Country)
(none) — new QuickBooksEntityLink rowId / SyncToken (QBO's own, returned on create)

QBO → CM — every real QBO Customer field, checked against CM's schema in the other direction. This is the direction that actually surfaces what's missing, since walking outward from CM's own fields can only ever confirm what CM already has a place for:

QBO fieldCM status
DefaultTaxCodeRefClosed 2026-09-02Organization.DefaultTaxCodeID -> Ref_Tax, live in CompanyEditForm.razor
CustomerTypeRefClosed 2026-09-02 — new Ref_CustomerType table (seeded Residential/Commercial), Organization.CustomerTypeID
BillWithParentMissing
CurrencyRefNot applicable — CM is single-currency throughout
OpenBalanceDateMissing — edge case, only matters for seeding an opening balance
TaxableClosed 2026-09-02Organization.Taxable, defaults true
PaymentMethodRef (customer-level default)Closed 2026-09-02Organization.DefaultPaymentMethodID -> Ref_PaymentMethod
TaxExemptionReasonIdMissing
ResaleNumMissing
ShipAddr (distinct from BillAddr)Not missing outright — a real child table (OrganizationAddressRepository) supports multiple typed addresses; needs the right address-type row selected, not a new column
Id / SyncToken / MetaData / FullyQualifiedName / Level / BalanceWithJobs / Balance / IsProject / SourceSystem-computed or QBO-internal — no CM source needed by design, not a gap

Net for Customer, per the "push as much as possible" strategy below: DefaultTaxCodeRef/Taxable (ties to the already-known state-tax simplification), CustomerTypeRef, and PaymentMethodRef are real, worth-closing gaps — each just needs one new CM column/lookup, not a redesign. ResaleNum/BillWithParent/OpenBalanceDate/TaxExemptionReasonId are lower-value (resale/tax-exemption certificates, opening-balance seeding) but still cheap single-column additions if a real tenant ever asks. None of these are architecturally hard — the QBO → CM tables in this doc are the actual punch list.

Job — CM ProjectInfo, pushed as a QBO Customer with ParentRef

CM fieldQBO field
ProjectNameDisplayName
(resolved via the Customer's own link row)ParentRef — the dormant AccountingLinkCustomerID column already anticipated needing this
Job-site Address fields on ProjectInfoBillAddr (job site can genuinely differ from the Customer's own mailing address)
(none)Job-specific status — no QBO REST equivalent, that was a QBXML Desktop concept only

Vendor — CM Organization (VendorTypeID set)

Real QBO field list confirmed 2026-09-02.

CM → QBO:

CM fieldQBO field
CompanyNameDisplayName / CompanyName
ContactEmail / MainPhone / AltPhonePrimaryEmailAddr / PrimaryPhone / MobileAlternatePhone
MainFax / WebsiteFax / WebAddr
NameOnCheck / TaxIdentityPrintOnCheckName / TaxIdentifier
PaymentTermIDTermRef
IsEligibleFor1099Vendor1099 exact match
IsActive / Address fieldsActive / BillAddr

QBO → CM:

QBO fieldCM status
CostRate / BillRateMissing — same gap as Employee, no billing-rate field on Organization
VendorPaymentBankDetailMissing — real, meaningful gap if ACH vendor payment ever matters, not just a niche field
AcctNum (vendor's own account-number reference)Missing
OtherContactInfoMissing, low priority (generic contact-info list)
T4AEligible / T5018Eligible / HasTPARCanada-specific tax reporting — not applicable for a US-based tenant
CurrencyRef / Source / Balance / Id/SyncToken/MetaDataNot applicable / system-computed, not a gap

Employee — CM Employee (+ linked Contact)

Real QBO field list confirmed against Intuit's live docs, 2026-09-02. Better finding than a first pass assumed: Employee.ContactID links to a real Contact row with NamePrefix/MiddleName/NameSuffix/DOB/GenderID - so the "split EmployeeName into GivenName/FamilyName" problem mostly isn't real work, it's reading the already-linked Contact record instead of parsing a string.

CM → QBO:

CM fieldQBO field
Contact.FirstName/LastName/NamePrefix/MiddleName/NameSuffix (via ContactID)GivenName/FamilyName/Title/MiddleName/Suffix
EmailPrimaryEmailAddr.Address
MobilePhoneMobile.FreeFormNumber
HireDate / EmploymentEndDateHiredDate / ReleasedDate
IsActiveActive
Last4SSNSSN partial only - CM stores last 4 digits by design, QBO's field wants the full number; likely stays unpopulated on push rather than a real gap to close
Contact.DOB / GenderID (via ContactID)BirthDate / Gender
EmployeeGUID (dormant, unwired)Id

QBO → CM:

QBO fieldCM status
PrimaryAddrAvailable via linked Contact.Address, not a new column
CostRate / BillRate / BillableTimeMissing — no payroll/billing-rate fields on CM's Employee
EmployeeNumber (employer's own id, distinct from QBO's Id)Missing
PrintOnCheckNameMissing
JobTitle (CM has this)No QBO home — QBO's own Title field is a name honorific (Mr./Mrs.), not a job title; correctly stays CRM-only
Organization (bool, org vs. person), Id/SyncToken/MetaData/V4IDPseudonymNot applicable / system-computed, not a gap

Invoice — CM Invoice + InvoiceItem

Real QBO field list confirmed 2026-09-02 (a genuinely long one - Invoice is QBO's richest transaction entity).

CM → QBO:

CM fieldQBO field
ProjectID (via the Job's link row)CustomerRef
TransactionDate / DueDate / ShipDateTxnDate / DueDate / ShipDate exact matches
PaymentTermID / ShipMethodIDSalesTermRef / ShipMethodRef
InvoiceTotal / SalesTaxTotalTotalAmt / TxnTaxDetail.TotalTax QBO computes these from Line items itself — don't push as fixed values, let QBO derive them
MemoNotes (one field)CustomerMemo + PrivateNote real gap — two QBO fields (client-visible vs. internal), CM has one; risk of leaking internal notes to the client if not split before sync
ReferenceNumber / ExternalRefIDDocNumber CM already has a real field for this, less of a gap than a first pass assumed
Address/City/etc.BillAddr
InvoiceItem rows (ItemName/Quantity/UnitRate/Amount)Line[], each a SalesItemLineDetailrequires ItemRef, the Item-mapping blocker from above

QBO → CM:

QBO fieldCM status
ProjectRef (QBO's newer, separate Projects feature - NOT the Customer:Job hierarchy)Not applicable — confirmed Premium/Projects API requires Silver+ tier, out of scope on the free Builder tier this whole plan targets
ClassRefClosed 2026-09-02 — new Ref_Class table, Invoice.ClassID, live in InvoiceGrid.razor
DepartmentRef (Invoice)Closed 2026-09-02 — new Ref_Department table, Invoice.DepartmentID
DepositToAccountRef / Deposit (upfront deposit amount)Missing — real gap if a tenant collects deposits against an invoice
BillEmailCc / BillEmailBccMissing
CustomField[] (up to 3, QBO-side)Interesting angle, not built — CM has its own custom-field system (FieldDefinition); mapping a couple of CM's own custom fields into these 3 slots would be a real "push more than competitors" opportunity worth a future look, not a gap to just close mechanically
ShipAddr (distinct from BillAddr)CM's Invoice has one address set, not a separate ship-to — partial gap
TrackingNumMissing, low priority
PrintStatus / EmailStatusNo direct field, but CM's own Communications log arguably already tracks "was this sent" a different way
AllowOnline*Payment flags (Affirm/ACH/PayPal/CreditCard)Not applicable — CM already handles online payment collection itself via Stripe, doesn't need QBO's own payment links
DepartmentRef / ApplyTaxAfterDiscount / CurrencyRef/ExchangeRate / TransactionLocationTypeNot applicable (Department tracking not scoped, single-currency, US-only)
Id/SyncToken/MetaData/TotalAmt/Balance/InvoiceLink/TaxExemptionRef/RecurDataRef/DeliveryInfo/HomeBalance/HomeTotalAmt/FreeFormAddress, deprecated flagsSystem-computed, deprecated, or read-only — not a gap

Payment — CM PaymentReceipt

Real QBO field list confirmed 2026-09-02 - a short one, Payment is a simple entity.

CM → QBO:

CM fieldQBO field
PaymentDateTxnDate
PaymentAmountTotalAmt
InvoiceIDLine[].LinkedTxn (TxnId = the Invoice's own QBO id, TxnType = Invoice) — only works once that Invoice is itself synced
ProjectID (via the linked Invoice)CustomerRef
PaymentMethodIDPaymentMethodRef
ReferenceNumberPaymentRefNum
IsCreditCard / StripePaymentIntentIDCreditCardPayment not applicable — that QBO field is specifically for payments run through Intuit's own Payments API; CM already collects online payment via its own Stripe integration

QBO → CM:

QBO fieldCM status
DepositToAccountRefMissing — same gap as Invoice
PrivateNoteMissing — no notes field on PaymentReceipt
ProjectRef (newer Projects feature) / CurrencyRef/ExchangeRateNot applicable — Premium tier / single-currency, same as Invoice
UnappliedAmt / TaxExemptionRef / Id/SyncToken/MetaData / TxnSourceRead-only, system-computed, or internal — not a gap

Bill — CM Bill + BillItem

Real QBO field list confirmed 2026-09-02. Resolves the "unverified" flag from Phase D: a direct SQL check confirms BillItem (BillItemID/BillID/ItemID/Qty/UnitPrice/LineTotalAmount/SourcePOItemID) is real - item-based, cleanly matching QBO's ItemBasedExpenseLineDetail. A real Ref_BillableStatus lookup table also exists, matching QBO's own BillableStatus line concept almost exactly.

CM → QBO:

CM fieldQBO field
VendorCompanyIDVendorRef
BillDate / DueDateTxnDate / DueDate
PaymentTermID / ReferenceNoSalesTermRef / DocNumber
TotalAmount / TaxAmountTotalAmt / TxnTaxDetail computed from Line, same caveat as Invoice
PayableAccountIDAPAccountRef
SourcePOIDLinkedTxn (TxnType = PurchaseOrder) — only once the PO itself is synced (not yet in this plan's scope)
ProjectIDLine-level AccountBasedExpenseLineDetail.CustomerRef / ItemBasedExpenseLineDetail.CustomerRef QBO tracks the billable-to-job reference per line, not on the Bill header
BillItem rows (ItemID/Qty/UnitPrice/LineTotalAmount)Line[], ItemBasedExpenseLineDetail — needs the same Item-mapping screen as Invoice

QBO → CM:

QBO fieldCM status
Line-level BillableStatusReal match — CM's Ref_BillableStatus lookup, just needs wiring into BillItem if not already there
PrivateNoteMissing — no notes field on CM's Bill
DepartmentRefClosed 2026-09-02Bill.DepartmentID -> the same Ref_Department table, live in VendorBillGrid.razor
CurrencyRef/ExchangeRate / IncludeInAnnualTPARNot applicable (single-currency, Australia-specific)
Id/SyncToken/MetaData / HomeBalance/RecurDataRef/BalanceSystem-computed or read-only — not a gap

Time Tracking — CM TimeRecord

Real QBO field list confirmed 2026-09-02.

CM → QBO:

CM fieldQBO field
UserIDEmployeeRef (NameOf = Employee) — the Employee must already be synced
ProjectIDCustomerRef required only if billable
WorkDateTxnDate
WorkDurationHours / Minutes
ClassID / DepartmentID (closed 2026-09-02)ClassRef / DepartmentRef — real dedicated columns now, via Ref_Class/Ref_Department. CategoryID (CM's existing cost category) stays separate, not reused for this.
TxnID (dormant, confirmed real)Id

QBO → CM:

QBO fieldCM status
IsBillable-related: BillableStatusNot directly writable — it's read-only on QBO's side, auto-set to HasBeenBilled only when a real Invoice links to this TimeActivity; CM's IsBillable flag informs whether CustomerRef/HourlyRate are supplied, it isn't pushed as a field itself
HourlyRate / CostRateMissing — real gap, HourlyRate is actually required by QBO when billable
DescriptionMissing
TaxableMissing
StartTime/EndTime/BreakHours/BreakMinutesCM only stores total WorkDuration, no start/end/break breakdown — not a blocker (QBO accepts Hours alone), but a real richness gap
PayrollItemRefNot applicable on the free Builder tier — confirmed Payroll Compensation is a Premium API, Silver+ only. CM's dormant PayrollItemID column would only matter if that tier were adopted later.
VendorRef path (NameOf=Vendor)Not applicable — CM's TimeRecord.UserID always implies an internal staff Employee, no subcontractor-time-tracking concept today
Id/SyncToken/MetaDataSystem-computed, not a gap

Estimate — deliberately not detailed here

Phase F. QBO's Estimate is a flat priced-line list; CM's ProjectEstimate is a Category/Item cost hierarchy with per-category markup QBO never sees. A field-by-field table would be premature before the flatten strategy itself is designed — that's the actual work of Phase F, not a mapping exercise like the entities above.

Reference lists — QBO "Lists" vs. CM's Ref_* tables

Every *Ref field across the entity tables above (SalesTermRef, PaymentMethodRef, ClassRef, DepartmentRef, APAccountRef, ItemRef...) points at one of QBO's own Lists resources. Since SiteID=1 is CM's core/default tenant (every other SiteID reads core+its own additions), seeding SiteID=1's Ref_* tables with QBO's real standard values means any tenant who later connects QBO starts from an already-compatible list, not an empty one.

QBO ListCM tableStatus
TermRef_PaymentTermReal gap found and closed 2026-09-02. CM's SiteID=1 had exactly one term, misspelled ("Due Unpon Receipt"). Fixed the typo and seeded QBO's real confirmed standard terms (Due on receipt, Net 10, Net 15, Net 60 - pulled from Intuit's own live sample data; Net 30 added too, the term named directly in QBO's own field description as the canonical example). CM's Ref_PaymentTerm is label-only today (no DueDays/DiscountPercent columns like QBO's real Term object) - fine for display and for pushing a name, but computing an actual due date from the term still has to happen CM-side.
PaymentMethodRef_PaymentMethodAlready well-aligned - checked CM's real SiteID=1 list (American Express, Cash, Check, Credit Card, Diners, Mastercard, Unspecified, Venmo, Visa) against QBO's real confirmed defaults (American Express, Cash, Check, Diners Club, ...) - same core set, just "Diners" vs. "Diners Club" naming to reconcile if being strict. Not touched.
Class / DepartmentRef_Class / Ref_DepartmentBuilt 2026-09-02 - both tables created, wired onto Invoice.ClassID/DepartmentID, Bill.DepartmentID, TimeRecord.ClassID/DepartmentID (repository only, no UI yet for TimeRecord). Deliberately unseeded - unlike Term/PaymentMethod, QBO ships no universal defaults for these, every tenant defines their own via the same quick-add "+" already used everywhere else.
TaxCodeRef_TaxReal table already exists and is wired to InvoiceItem.TaxID - not re-audited against QBO's specific TaxCode shape in this pass.
Account (Chart of Accounts)GLAccountReal table already exists, used for APAccountRef/DepositToAccountRef mapping - not re-audited field-by-field in this pass.
ItemCostItemThe Item-mapping blocker already covered above (Phase C prerequisite) - this is the one Lists gap with a real, already-scoped plan to close it, not just a seed-data fix.

Phasing

Phase A — OAuth2 connectionShipped

QuickBooksConnection table (encrypted tokens via IDataProtectionProvider, mirroring the app's existing auth-cookie protection setup), QuickBooksOAuthService wrapping the SDK's OAuth2Client, an /api/quickbooks/oauth-callback endpoint, and a connect/disconnect admin page (QuickBooksSetup.razor, /admin/quickbooks-connect) gated behind its own RBAC grant. The real OAuth round-trip itself still needs the user's own Client ID/Secret via dotnet user-secrets - not yet verified end-to-end against the real sandbox.

Phase B — Customer/Job foundation + review queueShipped 2026-09-02

QuickBooksEntityLink table, a new ProjectInfo.ExcludeFromAccountingIntegration gate (default excluded, mirrors IRBS's own default) with a checkbox on the Project edit form, QuickBooksSyncService (Customer/Job push via the SDK's DataService, pre-write SyncToken refresh so CM's data always wins), and the real review-and-approve queue UI (QuickBooksSync.razor, /admin/quickbooks-sync) — Include checkboxes, Select All/Clear All, Posted/Failed status, a Synchronize button per entity type. Customer candidates are gated to Companies that own at least one accounting-ready Project; Job candidates further require their parent Company already be linked. RBAC-verified live (blocked before grant, correct "Not Connected"/candidate-list states after). The actual push against a real QBO sandbox still needs Phase A's OAuth round-trip completed first.

Phase C — Invoice / Payment / Credit Memo (AR)Shipped 2026-09-02 (Invoice/Payment)

QuickBooksItemMapping.razor (/admin/quickbooks-items) crosswalks CM's CostItem catalog to a tenant's live QBO Item list via QuickBooksEntityLink (EntityType "Item") - the real structural prerequisite, since QBO rejects a free-text invoice line. An Invoice won't push until every line's CostItem is mapped; the sync queue names exactly which line descriptions are unmapped rather than failing opaquely. QuickBooksSyncService gained Invoice and Payment candidates/push, same pattern as B (pre-write SyncToken refresh, CM always wins). Known gaps, deliberately deferred, not silent: ClassRef/DepartmentRef/PaymentMethodRef/SalesTermRef aren't pushed (no CM→QBO crosswalk exists yet for Class/Department/PaymentMethod/Term, even though the local Ref_Class/Ref_Department/etc. tables exist); MemoNotes pushes to PrivateNote only, never CustomerMemo - CM has no client/internal note split today, and the safe default is never risking an internal note reaching anything QBO shows a client; QBO's own tax engine independently computes tax from the pushed lines rather than CM's SalesTaxTotal being force-fed, so totals may not match until tax settings are reconciled; creating a brand-new QBO Item from CM (the doc's original "fallback" idea) isn't built - mapping only works against Items that already exist in the tenant's QBO company, since a new Service Item needs an IncomeAccountRef/Chart-of-Accounts choice this phase doesn't collect. Credit Memo remains out of scope - confirmed still header-only with zero line items anywhere in the schema (re-verified 2026-09-02), so it structurally can't push to QBO's Line[]-based CreditMemo.

Phase D — Bill / Vendor (AP)Shipped 2026-09-02 (Vendor/Bill)

Confirmed a genuine mechanical repeat of C, not a new mapping shape: BillItem.ItemID references the exact same CostItem catalog InvoiceItem uses, so Bill lines reuse Phase C's Item crosswalk directly (QBO's ItemBasedExpenseLineDetail instead of SalesItemLineDetail, same ItemRef). A Vendor pushes as its own QBO Vendor entity (Companies with VendorTypeID set) - distinct from Customer/Job, with no Project-readiness gate, since a vendor exists independently of any Project. Bill's "unmapped line" gate mirrors Invoice's exactly. Deliberately not built: BillPayment sync. QBO's BillPayment requires a real BankAccountRef (Check) or credit-card detail, and CM has no Chart-of-Accounts-to-QBO crosswalk today - a genuine structural blocker (like Credit Memo in Phase C), not a guessed-and-skipped detail. ClassRef/DepartmentRef/SalesTermRef remain unpushed on Bill for the same reason as Invoice (Phase C).

Phase E — Employee / Time TrackingShipped 2026-09-02

Employee pushes as QBO's core Employee entity (confirmed via SDK reflection: basic Employee needs no Payroll subscription, distinct from the Payroll Compensation API). Real bug avoided during grounding: TimeRecord.UserID looked like the natural Employee link, but the live schema's FK confirms it actually points at Users (the login table) - the real Employee comes from TimeRecord.TimeHeaderIDTimeHeader.EmployeeID, the same join the rest of the app already uses. TimeActivity pushes as a plain internal time entry only - no CustomerRef/HourlyRate/Taxable, since CM has no hourly-rate field and QBO requires one for billable time (the doc's own earlier field-mapping research below already flagged this). WorkDuration (decimal hours) splits cleanly into QBO's separate int Hours/Minutes.

Phase F — EstimateShipped 2026-09-02

Resolved cleanly, not the ordeal originally feared: EstimateItem.ItemID references the same CostItem catalog Invoice/Bill already crosswalk, so lines reuse Phase C's Item mapping directly. QBO's Estimate object was confirmed via SDK reflection to have no field at all for CM's per-component Material/Labor/Sub/Equipment cost+markup breakdown - only the final quoted price per line crosses over (Line.Amount = ExtPriceTotal, the exact formula EstimateItemGridRow already computes and Report Builder's grouped-section engine already proved correct against real data). AcceptedDate maps directly to QBO's own AcceptedDate field - a genuine 1:1 match, not a gap. Same Job-must-be-synced-first and unmapped-line gates as Invoice. This completes every entity type the current plan scopes - Customer, Job, Item, Invoice, Payment, Vendor, Bill, Employee, TimeActivity, Estimate all have real push logic and a review-queue UI now; only Credit Memo (Phase C) and BillPayment (Phase D) remain genuine, documented structural blockers.

Verification approach

Critical files

Full implementation-level detail (exact method signatures, SQL, file-by-file steps) lives in the working plan file, not duplicated here — this doc is the durable reference for the research and decisions behind it.