Automatic CRM writeback on quote approval
Approved Dealops 2 quotes can now push to Salesforce or HubSpot through the same server-side path as the manual Sync button, guarded by a single org-scoped feature flag.
New autoSyncCrmOnApproval listener for terminal quote.approval_decided events where the decision is APPROVED.
It uses primary-quote state to decide whether an approved quote is safe to auto-push.
The manual Sync button and the listener now share executeCrmWriteBackForQuote.
That keeps stage gates, validation, CRM writeback, timestamping, events, and primary promotion on one path.
Server-only. No Admin UI toggle and no client changes.
The feature flag ships default-off in code; rollout is controlled per org through feature-flag evaluation, intended for Langchain first.
Before enabling an org, run the primary-quote backfill so legacy synced opportunities do not look unsynced.
Live Salesforce/HubSpot verification is still required before real-org enablement.
1. Why this exists
- A quote can be approved and still never reach Salesforce/HubSpot.
- The rep must remember to click Sync manually.
- An opportunity can have multiple quotes, but the CRM holds one quote’s numbers at a time.
- Approval can trigger CRM sync automatically.
- The synced quote becomes the opportunity’s primary quote.
- A different primary quote blocks auto-sync to avoid silently overwriting the CRM.
isPrimary before it pushes.
2. What changes
Single flag: useAutomaticCrmWriteback.
The removed org-setting toggle means customers cannot enable this through orgSettings.update.
New listener: apps/server/src/lib/platformEvents/listeners/autoSyncCrmOnApproval.ts.
Subscribes to quote.approval_decided and only acts on terminal approved v2 quote events.
Shared path: executeCrmWriteBackForQuote.ts.
The tRPC route now authorizes the request, then delegates the actual writeback to the shared service.
Primary op: promoteQuoteToPrimary.ts.
Demotes any previous primary, promotes the synced quote, and emits quote.primary_set only when the designation changes.
Primary-state decision table
| Opportunity state when approval event runs | Listener action | Reason |
|---|---|---|
| No non-deleted quote is primary | auto-sync | First CRM sync for this opportunity, or a legacy opportunity after backfill says no primary exists. |
| This quote is already primary | re-sync | Supports recall → edit → re-approve on the canonical quote. |
| A different quote is primary | skip | Prevents a new approved quote from silently overwriting the quote already represented in CRM. |
| This quote was already submitted at or after this event | skip duplicate | Protects against sequential duplicate event delivery. |
Meaningful files
packages/feature-flags/flags.ts, isAutomaticCrmWritebackEnabledForOrg.ts
autoSyncCrmOnApproval.ts, registered in listeners/index.ts, documented in platform-events README.
executeCrmWriteBackForQuote.ts extracts the old tRPC inline logic; trpc/router/crm/writeback.ts maps structured failures back to tRPC errors.
findPrimaryQuoteIdForOpportunity in pricingQuoteRepository.ts plus promoteQuoteToPrimary.ts.
platformEventDispatch.ts now enqueues listener jobs with deterministic jobIds.
2026_07_27__backfill_langchain_primary_quote.ts plans and applies primary quote correction for one org at a time.
3. How it works
APPROVED decision is the only trigger. Non-v2 events, non-terminal decisions, and rejections return before touching the flag or DB.
- Event payload is v2.
- Decision is
APPROVED. - Workflow is complete.
useAutomaticCrmWritebackis enabled for the org.- Quote exists and is not soft-deleted.
- Active approval still resolves to terminal
APPROVED. - Primary-state eligibility passes.
- Retry only when the result is
UNKNOWNandwritebackEntered !== true. - Do not retry after
executeWriteBackstarts. - Re-run eligibility before every pre-push retry.
- On terminal failure: notify once, return, do not rethrow.
Shared writeback path
crm.writeback.execute still performs request-scoped authorization through tRPC, then calls executeCrmWriteBackForQuote.
autoSyncCrmOnApproval performs event/flag/primary eligibility, then calls the same shared function with source: "auto_sync_on_approval".
submittedToCrmAt stamp → quote.crm_submitted → promoteQuoteToPrimary.
Dispatcher hardening
Duplicate or concurrent dispatch of the same platform event could enqueue duplicate listener jobs.
That is dangerous because CRM writeback deletes and recreates records, so it is not safely idempotent.
platformEventDispatch passes deterministic BullMQ IDs:
${jobName}__${eventId}
BullMQ collapses duplicate delivery to one job per listener per event.
4. Backfill and rollout shape
For each opportunity with synced quotes but no primary, mark the quote with the latest submittedToCrmAt as primary.
The script writes only with --apply and refuses to run without a non-empty --org.
Backfill sets isPrimary directly and does not emit quote.primary_set for historical corrections.
5. What it doesn’t change
- No Dealops 1 path is touched.
- No client/Admin UI toggle ships; this is server-only.
- No REST route is added; the manual path remains Dealops 2 tRPC.
- No DocuSign
sendEnvelopebehavior changes. - No HockeyStack writeback behavior changes.
- No “Generate Order Form” / no-approval CRM sync trigger is added.
- No primary-quote UI is added; verification still requires DB inspection.
- No live Salesforce/HubSpot end-to-end verification is included in the diff.
6. Risks / rollback / open questions
- Live CRM behavior is not yet verified. Tests use stubs; run the Langchain sandbox walkthrough before production enablement.
- Concurrent distinct approvals can still both push. The partial unique index can stop the second promotion, not the second CRM writeback. A claim column is deferred.
- Feature-flag source needs clarity at rollout. The diff defines the flag default-off in code with no baked-in allow-list; the PR text frames rollout as Langchain-only. Treat Langchain-only as an operational PostHog allow-list until code says otherwise.
- Downstream primary subscribers need review. Check
quote.primary_setwebhooks and deal-group reactors before enabling a new org.
Turn useAutomaticCrmWriteback off for the org.
The listener and promotion op both read the same gate, so the kill switch stops auto-push and shared promotion scaffolding together.
If auto-sync fails, the listener notifies once and does not retry forever.
The rep can still use the manual Sync button, which remains the supported recovery path.
Tests cover flag evaluation, listener decision cases, stale-event skips, pre-push retry, deterministic dispatcher job IDs, promotion statuses, and backfill planning.