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.
Verified 2026-09-02 — against real code and Intuit's own live docs, not memory or assumption.
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:
| Table | Column(s) | Note |
|---|---|---|
ProjectInfo | AccountingLinkProjectID, AccountingLinkCustomerID, ProjectGUID | Two separate ID slots — a Job needs its own QB id and its parent Customer's, confirming the sub-customer hierarchy was already understood |
Employee | EmployeeGUID | Real column, missed by an edit-model-only check |
TimeRecord | TxnID | Real column — an earlier "stale memory" correction was itself wrong |
Bill | ExternalRefID, TransactionGUID | Two competing columns on one table |
BillPayment, Invoice, CreditMemo, Organization, Asset | ExternalRefID | Consistent single-column pattern |
Organization, Contact, JournalEntryItem | EntityGUID | A second, different pattern — dormant, unclear original purpose |
GLAccount | AccountGUID, EntityGUID | Matches the Lists-tier "Chart of Accounts" pull-only entity from the IRBS manual |
CheckInfo | ExternalReference | Matches IRBS's "Checks" pull-only entity — previously assumed to have no CM equivalent |
JournalEntry | ExternalRefID | Never in the IRBS Tier 1 push list — purpose not yet confirmed |
CostAssembly | TxnID | Never 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.
| Entity | QBO requires | The gap |
|---|---|---|
| Invoice / Payment | Every line must reference a real QBO Item — free-text lines are rejected | Needs 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 items | CM'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. |
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.
https://appcenter.intuit.com/connect/oauth2 (client_id/response_type=code/scope=com.intuit.quickbooks.accounting/redirect_uri/state)POST https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer (Authorization: Basic base64(client_id:client_secret))POST https://developer.api.intuit.com/v2/oauth2/tokens/revoke (same auth, JSON body {"token": "..."})Install-Package IppDotNetSdkForQuickBooksApiV3. OAuth2Client(clientId, clientSecret, redirectUrl, environment) exposes GetAuthorizationURL, GetBearerTokenAsync, RefreshTokenAsync, RevokeTokenAsync — no hand-built HTTP needed.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.
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.
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.
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.
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.
| Plan | Entity / Transaction | Status |
|---|---|---|
| A | (none — OAuth2 connection only) | Infrastructure |
| B | Customer, Job | No structural blocker — the starting point |
| C | Invoice, Payment (Receive Payment) | Blocked on the Item-mapping screen first |
| Credit Memo | Held back — no line-item model exists in CM yet | |
| D | Vendor, Bill | Bill's AP line-item/GL-account structure still unverified |
| E | Employee, Time Tracking | TimeActivity needs new TimeRecord columns; Employee is clean |
| F | Estimate — 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.
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.
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 field | QBO field |
|---|---|
Organization.CompanyName | CompanyName / DisplayName |
Contact.FirstName / LastName (when no company) | GivenName / FamilyName |
Contact.NamePrefix / NameSuffix / MiddleName | Title / Suffix / MiddleName |
MainEmail / ContactEmail | PrimaryEmailAddr.Address |
MainPhone | PrimaryPhone.FreeFormNumber |
Contact.MobilePhone (Person only) / Organization.AltPhone | Mobile / AlternatePhone |
Organization.MainFax | Fax |
Organization.Website | WebAddr |
Organization.NameOnCheck | PrintOnCheckName |
Organization.TaxIdentity / Contact.FederalTaxNo/StateTaxNo | PrimaryTaxIdentifier / SecondaryTaxIdentifier |
PaymentTermID | SalesTermRef |
Contact.PreferredContactMethodID | PreferredDeliveryMethod |
IsActive | Active |
Address/City/StateRegion/PostalCode/CountryID | BillAddr (Line1/City/CountrySubDivisionCode/PostalCode/Country) |
(none) — new QuickBooksEntityLink row | Id / 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 field | CM status |
|---|---|
DefaultTaxCodeRef | Closed 2026-09-02 — Organization.DefaultTaxCodeID -> Ref_Tax, live in CompanyEditForm.razor |
CustomerTypeRef | Closed 2026-09-02 — new Ref_CustomerType table (seeded Residential/Commercial), Organization.CustomerTypeID |
BillWithParent | Missing |
CurrencyRef | Not applicable — CM is single-currency throughout |
OpenBalanceDate | Missing — edge case, only matters for seeding an opening balance |
Taxable | Closed 2026-09-02 — Organization.Taxable, defaults true |
PaymentMethodRef (customer-level default) | Closed 2026-09-02 — Organization.DefaultPaymentMethodID -> Ref_PaymentMethod |
TaxExemptionReasonId | Missing |
ResaleNum | Missing |
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 / Source | System-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.
ProjectInfo, pushed as a QBO Customer with ParentRef| CM field | QBO field |
|---|---|
ProjectName | DisplayName |
| (resolved via the Customer's own link row) | ParentRef — the dormant AccountingLinkCustomerID column already anticipated needing this |
Job-site Address fields on ProjectInfo | BillAddr (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 |
Organization (VendorTypeID set)Real QBO field list confirmed 2026-09-02.
CM → QBO:
| CM field | QBO field |
|---|---|
CompanyName | DisplayName / CompanyName |
ContactEmail / MainPhone / AltPhone | PrimaryEmailAddr / PrimaryPhone / Mobile–AlternatePhone |
MainFax / Website | Fax / WebAddr |
NameOnCheck / TaxIdentity | PrintOnCheckName / TaxIdentifier |
PaymentTermID | TermRef |
IsEligibleFor1099 | Vendor1099 exact match |
IsActive / Address fields | Active / BillAddr |
QBO → CM:
| QBO field | CM status |
|---|---|
CostRate / BillRate | Missing — same gap as Employee, no billing-rate field on Organization |
VendorPaymentBankDetail | Missing — real, meaningful gap if ACH vendor payment ever matters, not just a niche field |
AcctNum (vendor's own account-number reference) | Missing |
OtherContactInfo | Missing, low priority (generic contact-info list) |
T4AEligible / T5018Eligible / HasTPAR | Canada-specific tax reporting — not applicable for a US-based tenant |
CurrencyRef / Source / Balance / Id/SyncToken/MetaData | Not applicable / system-computed, not a gap |
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 field | QBO field |
|---|---|
Contact.FirstName/LastName/NamePrefix/MiddleName/NameSuffix (via ContactID) | GivenName/FamilyName/Title/MiddleName/Suffix |
Email | PrimaryEmailAddr.Address |
MobilePhone | Mobile.FreeFormNumber |
HireDate / EmploymentEndDate | HiredDate / ReleasedDate |
IsActive | Active |
Last4SSN | SSN 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 field | CM status |
|---|---|
PrimaryAddr | Available via linked Contact.Address, not a new column |
CostRate / BillRate / BillableTime | Missing — no payroll/billing-rate fields on CM's Employee |
EmployeeNumber (employer's own id, distinct from QBO's Id) | Missing |
PrintOnCheckName | Missing |
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/V4IDPseudonym | Not applicable / system-computed, not a gap |
Invoice + InvoiceItemReal QBO field list confirmed 2026-09-02 (a genuinely long one - Invoice is QBO's richest transaction entity).
CM → QBO:
| CM field | QBO field |
|---|---|
ProjectID (via the Job's link row) | CustomerRef |
TransactionDate / DueDate / ShipDate | TxnDate / DueDate / ShipDate exact matches |
PaymentTermID / ShipMethodID | SalesTermRef / ShipMethodRef |
InvoiceTotal / SalesTaxTotal | TotalAmt / 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 / ExternalRefID | DocNumber 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 SalesItemLineDetail — requires ItemRef, the Item-mapping blocker from above |
QBO → CM:
| QBO field | CM 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 |
ClassRef | Closed 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 / BillEmailBcc | Missing |
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 |
TrackingNum | Missing, low priority |
PrintStatus / EmailStatus | No 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 / TransactionLocationType | Not applicable (Department tracking not scoped, single-currency, US-only) |
Id/SyncToken/MetaData/TotalAmt/Balance/InvoiceLink/TaxExemptionRef/RecurDataRef/DeliveryInfo/HomeBalance/HomeTotalAmt/FreeFormAddress, deprecated flags | System-computed, deprecated, or read-only — not a gap |
PaymentReceiptReal QBO field list confirmed 2026-09-02 - a short one, Payment is a simple entity.
CM → QBO:
| CM field | QBO field |
|---|---|
PaymentDate | TxnDate |
PaymentAmount | TotalAmt |
InvoiceID | Line[].LinkedTxn (TxnId = the Invoice's own QBO id, TxnType = Invoice) — only works once that Invoice is itself synced |
ProjectID (via the linked Invoice) | CustomerRef |
PaymentMethodID | PaymentMethodRef |
ReferenceNumber | PaymentRefNum |
IsCreditCard / StripePaymentIntentID | CreditCardPayment 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 field | CM status |
|---|---|
DepositToAccountRef | Missing — same gap as Invoice |
PrivateNote | Missing — no notes field on PaymentReceipt |
ProjectRef (newer Projects feature) / CurrencyRef/ExchangeRate | Not applicable — Premium tier / single-currency, same as Invoice |
UnappliedAmt / TaxExemptionRef / Id/SyncToken/MetaData / TxnSource | Read-only, system-computed, or internal — not a gap |
Bill + BillItemReal 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 field | QBO field |
|---|---|
VendorCompanyID | VendorRef |
BillDate / DueDate | TxnDate / DueDate |
PaymentTermID / ReferenceNo | SalesTermRef / DocNumber |
TotalAmount / TaxAmount | TotalAmt / TxnTaxDetail computed from Line, same caveat as Invoice |
PayableAccountID | APAccountRef |
SourcePOID | LinkedTxn (TxnType = PurchaseOrder) — only once the PO itself is synced (not yet in this plan's scope) |
ProjectID | Line-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 field | CM status |
|---|---|
Line-level BillableStatus | Real match — CM's Ref_BillableStatus lookup, just needs wiring into BillItem if not already there |
PrivateNote | Missing — no notes field on CM's Bill |
DepartmentRef | Closed 2026-09-02 — Bill.DepartmentID -> the same Ref_Department table, live in VendorBillGrid.razor |
CurrencyRef/ExchangeRate / IncludeInAnnualTPAR | Not applicable (single-currency, Australia-specific) |
Id/SyncToken/MetaData / HomeBalance/RecurDataRef/Balance | System-computed or read-only — not a gap |
TimeRecordReal QBO field list confirmed 2026-09-02.
CM → QBO:
| CM field | QBO field |
|---|---|
UserID | EmployeeRef (NameOf = Employee) — the Employee must already be synced |
ProjectID | CustomerRef required only if billable |
WorkDate | TxnDate |
WorkDuration | Hours / 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 field | CM status |
|---|---|
IsBillable-related: BillableStatus | Not 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 / CostRate | Missing — real gap, HourlyRate is actually required by QBO when billable |
Description | Missing |
Taxable | Missing |
StartTime/EndTime/BreakHours/BreakMinutes | CM only stores total WorkDuration, no start/end/break breakdown — not a blocker (QBO accepts Hours alone), but a real richness gap |
PayrollItemRef | Not 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/MetaData | System-computed, not a gap |
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.
Ref_* tablesEvery *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 List | CM table | Status |
|---|---|---|
Term | Ref_PaymentTerm | Real 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. |
PaymentMethod | Ref_PaymentMethod | Already 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 / Department | Ref_Class / Ref_Department | Built 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. |
TaxCode | Ref_Tax | Real 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) | GLAccount | Real table already exists, used for APAccountRef/DepositToAccountRef mapping - not re-audited field-by-field in this pass. |
Item | CostItem | The 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. |
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.
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.
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.
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).
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.TimeHeaderID → TimeHeader.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.
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.
QuickBooksEntityLink after each Synchronize, plus visual confirmation in the sandbox company's own QBO web UI that the pushed Customer/Job actually appears correctly — not just a 200 response. Edit the same record locally, re-sync, confirm it updates the same QBO record rather than duplicating it.CostItemID line items) correctly surface as sync candidates, then the fixture was removed. Still needed once real credentials exist: map a real Item, push a real Invoice, confirm the "unmapped line" error fires correctly for an unmapped one, push a real Payment with a correct LinkedTxn, and visually confirm both appear right in the sandbox company's own QBO web UI.Vendor entity (not Customer) is what actually appears in the sandbox.UserID) correctly surface as sync candidates, then the fixture was removed. The WorkDuration→Hours/Minutes split was hand-verified against 4 sample values (6.00→6h0m, 5.00→5h0m, 7.5→7h30m, 4.25→4h15m), all exact. Still needed once real credentials exist: push a real Employee and TimeActivity, confirm it appears correctly in the sandbox as an unbilled internal time entry.ItemID populated, then the fixture was removed. The ExtPriceTotal formula itself was already proven correct against real data in Report Builder's own verification pass, not re-derived here. Still needed once real credentials exist: push a real Estimate, confirm the flattened line amounts land correctly in the sandbox and match CM's own totals.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.