> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Hubspot flex pipeline

# HubSpot ↔ Flex pipeline & quote lifecycle

How a HubSpot sales-pipeline stage transition drives a Flex quote, with Soundcheck
as the integration layer that keeps deal stage, Flex quote status, and the
Soundcheck project in sync. This is the **design + implementation spec**; the
inbound data contract it builds on lives in `README.md`.

> **Status: Soundcheck side implemented.** The state machine, ingestion webhook,
> quote projection, management endpoints, and UI are built (see
> [Implementation status](#implementation-status)). The Flex/HubSpot API calls
> themselves remain in n8n and are not part of this codebase. The
> [current state vs. target](#current-state-vs-target) section records what the
> code looked like before this change.

## Goals

* **Single trigger point.** Entering **Quote Requested** in HubSpot is the only
  action that creates a quote in Flex. Every other transition is manual and owned
  by the sales lead.
* **Reduce duplicate manual entry** — venues, customers, deal names, dates are
  entered once and propagate.
* **Repeatable, scalable cross-system stages** with one canonical status map
  (below) that all three systems agree on.

## Architecture

The existing integration is **inbound only** — n8n fetches from Flex/HubSpot and
posts normalized data into Soundcheck. This spec adds an **outbound** path so a
HubSpot stage change creates/confirms/closes a Flex quote, and a **callback** so
the resulting Flex quote id flows back into Soundcheck.

```
                 ┌──────────────── inbound (exists) ───────────────┐
 HubSpot deals ─▶│ n8n ─HMAC POST─▶ /webhooks/n8n/crm-sync          │
 Flex records  ─▶│        (fetch, normalize, sign) ─▶ CRMSyncService├─▶ Postgres
                 └──────────────────────────────────────────────────┘

                 ┌──────────────── outbound (new) ─────────────────┐
 HubSpot stage ─▶│ deal-stage event ─▶ Soundcheck ─HMAC POST─▶ n8n  │
   transition    │   ─▶ Flex (create/confirm/close quote)           │
                 │   ─HMAC POST─▶ /webhooks/n8n/flex-quote-sync ─────┼─▶ project
                 └──────────────────  (quote id, status)  ──────────┘   status
```

Both directions reuse the two existing HMAC scopes (see `README.md`):
`X-Soundcheck-Trigger-Signature` (API → n8n, `N8N_TRIGGER_SECRET`) and
`X-Soundcheck-N8N-Signature` (n8n → API, `N8N_CRM_SYNC_SECRET`).

## Canonical cross-system status mapping

The single source of truth for how a deal's **business phase** appears in each
system. Soundcheck is responsible for keeping these consistent.

| Business phase | HubSpot deal stage | Flex quote status | Soundcheck project status | Meaning |
| - | - | - | - | - |
| Lead | Lead | None | None | Sales inquiry exists |
| Qualified | Discovery | None | None | Real opportunity, not quoting yet |
| Quote build | Quote Requested | Inquiry | Draft | Quote/project shell created |
| Quote out | Quote Submitted/In Review | Tentative or Inquiry | Draft or Active | Client reviewing; production involvement |
| Confirmed | Confirmed/In Production | Confirmed / Ready to Invoice / Invoice Created | Active | Client committed; ops/staffing begin |
| Event complete | Confirmed/In Production | Confirmed / Ready to Invoice / Invoice Created | Completed | Show occurred; closeout begins |
| Settled | Closed Won | Closed | Settled | Financials complete (except commission) |

Notes:

* **Confirmed → Event complete** is the same HubSpot stage and Flex status; only
  the Soundcheck project status advances (`Active` → `Completed`) once the show
  has occurred. That transition is **Soundcheck-driven**, not a HubSpot/Flex event.
* `None` means no quote/project exists yet — the Lead and Qualified phases are
  pure HubSpot.

## HubSpot pipeline stages

The restructured pipeline. Trigger column = what fires when a deal **enters** the
stage. Stage numbers are preserved from the source spec (the 6 → 9 gap is an
[open item](#open-questions)).

| # | Stage | Purpose | Trigger on entry |
| - | - | - | - |
| 1 | Marketing/Lead Intake/Inquiry | Lead exists; no outreach yet. Cold contact to be approached. | None |
| 2 | Discovery / Qualification | Two-way communication established; scoping the opportunity. RFPs park here until ready to quote. Requires constant follow-up. (Proposed: merge with the prior bucket — only 6 deals lived here as of 5/1/26.) | None |
| 3 | **Quote Requested** | Internal decision to quote, or client request. | **Create the quote header in Flex**: name `yyyymmdd – Client Name @ Venue`, account manager, client & venue (separate entry if needed). Prep date, return date, load-in, show start, load-out are adjusted manually. Header detail updates should sync across Flex ↔ Soundcheck ↔ HubSpot. |
| 4 | Quote Submitted/In Review | Quote delivered; client reviewing. Revisions and stalled deals live here. Manual advance by sales owner. | Flex moves inquiry → tentative (may be redundant). On inquiry → tentative, **populate the job in Soundcheck**. |
| 5 | Deal Won/Confirmed/In Production | Client signed; quote finalized in Flex. Work with Accounting to collect deposit/payments. | Quote → **Confirmed**; Flex emails the AM to **manually** move the quote to "Ready to Invoice". **Automated message to Staffing/Operations to send job offers.** |
| 6 | Closed – Won | Deal closed; commissions calculated and paid. Prereqs: all AP/AR complete (client payments, vendor payments, damages, change orders) and the show has occurred. | Quote → **closed**. |
| 9 | Closed – Lost | Deal lost. Flex quote closed/archived. | Quote → **cancelled**. |
| 10 | Event Postponed | Event delayed. | Quote → **cancelled** (preserve for reopening). |
| 11 | Backlog | Client cancels or stops responding. | Set a HubSpot task to follow up with the client **90 days** after entering this bucket. |

## HubSpot transition to Flex action

| HubSpot transition | Flex action |
| - | - |
| → Quote Requested | Create quote and import header from HubSpot: Contact, Company billing address, project date, project start time (as **Show Start**), project end time (as **Load-Out**). |
| → Quote Submitted/In Review | No action (manual send); project changes inquiry → tentative. |
| → Deal Won/Confirmed/In Production | Quote → **Confirmed**. Flex emails the AM → AM **manually** moves to "Ready to Invoice" (exploring removing this manual step / merging Flex statuses). |
| → Closed – Won | Close / archive quote. |
| → Closed – Lost | Cancel / archive quote. |
| → Event Postponed | Cancel quote; **preserve for reopening**. |

## Header-detail sync

These fields are entered once and kept in sync Flex ↔ Soundcheck ↔ HubSpot:

* Quote/job name `yyyymmdd – Client Name @ Venue`
* Account manager
* Client (customer)
* Venue
* Prep date, return date, load-in, **show start**, **load-out**

Show start maps to the Flex **Show Start** field and Soundcheck `Event.startTime`;
load-out maps to the Flex **Load-Out** field and Soundcheck `Event.endTime`.
Prep and return are the *equipment* window (truck out → truck back), are stored
on `flex_quotes` only, and never affect the gig's dates.

### The gig's date range: load-in to load-out

The crew's window is **load-in to load-out** — that is the gig, and the show is an
event inside it. Dating a gig from its show start hid the load-in day: a job that
loaded in Wednesday for a Thursday show read as a one-day Thursday gig, and the
day the crew actually reported was nowhere on it.

Both ends resolve to an **operating day**: the working day a call belongs to, in
the gig's timezone, where anything before **06:00 local counts as the previous
day's call**. A 2am strike is the end of Thursday's show, not a Friday gig —
calling it Friday turned every late show into a multiday gig and then froze its
dates under the [multiday date lock](#multiday-date-lock) as soon as anything went
out to crew. Run of Show already draws the line in the same place, rendering a
past-midnight item as the same gig day with a "(past midnight)" suffix.

| Soundcheck column | Source |
| - | - |
| `Event.eventDate` | operating day of **load-in**, falling back to show start, then load-out, then today |
| `Event.endDate` | operating day of **load-out** — `null` when it equals `eventDate` (a single-day gig) |
| `Event.startTime` | show start, as the local wall clock |
| `Event.endTime` | load-out, as the local wall clock |

Load-in is a **preference, not a requirement**: most quotes still arrive without
one, so the fallback chain keeps working and those gigs date from the show as
before. Both dates are stored as **midnight UTC of a calendar day**, never as the
source instant — `endDate` holding a raw load-out timestamp is what made a client
re-sending the same date read as a date *change*.

Worked examples, in local time:

| Load in | Show | Load out | Result |
| - | - | - | - |
| Aug 27, 08:00 | Aug 27, 19:00 | Aug 27, 23:00 | Single — Aug 27 |
| Aug 26, 10:00 | Aug 27, 10:00 | Aug 27, 15:00 | **Multi** — Aug 26–27 (load-in day + show day) |
| Aug 27, 16:00 | Aug 27, 22:00 | Aug 28, **02:00** | Single — Aug 27 (strike before 06:00) |
| Aug 27, 16:00 | Aug 27, 22:00 | Aug 28, **07:00** | **Multi** — Aug 27–28 (strike after 06:00) |
| — | Aug 27, 15:00 | Aug 27, 22:00 | Single — Aug 27 (no load-in; dates from the show) |
| — | — | Aug 28, 00:30 | Single — Aug 27 (lone overnight load-out belongs to the 27th) |

Implemented in `n8nFlexQuoteSyncOperation.ts` (`operatingDay`, `pipelineEventDate`,
`pipelineEventEndDate`).

### Multiday date lock

Once an invitation, crew offer, or availability request has gone out, a **multiday**
gig's dates are frozen — the API rejects the edit with `EventDatesLockedError`
("Dates on a multi-day event are locked…"). Single-day gigs stay reschedulable and
cascade the move to their staffing instead. See `updateEventWithDateCascade` in
`src/db/eventCrud.ts`.

Two things this compares as **calendar days, not instants**, and both were bugs:

* **Whether the dates changed at all.** `eventDate`/`endDate` hold a day as a
  timestamp and the writers disagree on the time of day — this pipeline stores
  midnight UTC, the web client re-sends every date normalised to noon UTC on every
  partial save, *including publishing a draft*. Compared as instants, re-stating
  27 August as 27 August read as a move, so a Flex-created multiday gig with an
  availability request out could never be published or edited again.
* **Whether to notify.** The same mismatch fired schedule-change SMS at crew for a
  gig whose date had not moved.

The audit trail is compared the same way, so a save that changed nothing does not
appear in a gig's history as a reschedule.

Schedule **ownership** is a separate signal: `Event.schedule_set_by_user_at`, set
in the same statement as the schedule change whenever a person actually moves it
(dates by calendar day, call times by instant — the same units as the audit
entries). `correctImportedEventTx` (`api-events/src/db/importedEventGuards.ts`) tests that column *inside* its own
UPDATE and leaves all four schedule fields alone when it is set.

It is a column rather than a lookup for a reason. An earlier version asked
`event_audit_log` whether a person had edited the schedule, then wrote — and lost
a race: the audit row lands *after* the user's update transaction has committed,
so a sync arriving in that gap read "nobody owns this" and overwrote a schedule
that was already saved. Reading a column the same statement writes leaves no gap
to arrive in, and needs no lock.

## Operational notes

* **Single trigger point.** Only entry into Quote Requested creates a Flex quote.
  All transitions are manual, owned by the sales lead.
* **Revisions stay inside the quote.** Quote Sent ↔ In Review loops do not
  re-trigger Flex; Flex versions internally.
* **Site visits live in Flex.** Modeled as a task or quote sub-state; no HubSpot
  visibility needed.
* **RFPs follow the standard path.** They sit in Discovery until ready to quote,
  then advance to Quote Requested.
* **Nothing in Flex is ever deleted.** A quote is the record of what was offered
  and at what price, so it is retired by status, never removed. Flex agrees:
  `DELETE /api/element/{id}` answers 200 with the id echoed back and leaves the
  element fully readable, so a delete would be a silent no-op even if it were the
  right operation. Retire a quote by processing its **Cancel Quote** workflow
  action — status is `readOnly` ("Status can only be changed via workflow"), so
  that action is the only way to move it.
* **Lost ≠ Postponed.** Both retire the quote, and neither destroys it. The
  difference is carried by the deal stage and the gig status, not by what happens
  to the Flex record: Lost cancels the gig, Postponed deliberately leaves the gig
  alone because the job is expected back with a new date.
* **Guardrail.** Add a confirmation prompt on entry to Quote Requested to prevent
  accidental quote creation.

## Current state vs. target

What exists in the codebase today versus what this spec requires. Inbound CRM-sync
machinery (handler, service, identity dedup, models) is production-ready; the
quote lifecycle and outbound writes are net-new.

| Concept | Today | Gap to close |
| - | - | - |
| Inbound webhook | `POST /webhooks/n8n/crm-sync` → `N8nCrmSyncRequest` | Extend payload to carry **deal stage** + **quote status**, or add a dedicated callback (below). |
| Outbound trigger | n8n trigger kinds `inventory`/`venues`/`hubspot`; route `POST /api/v1/organizations/{orgId}/integrations/sync/{kind}` | Add quote-lifecycle trigger kind(s) + webhook URL config. |
| Identity dedup | `ExternalCustomerIdentity`, `ExternalCustomerContactIdentity`, `ExternalVenueIdentity`, `ExternalEventIdentity`, `ExternalInventoryIdentity`; `ExternalProvider` = `HUBSPOT`/`FLEX`/`SPREADSHEET`/`PASTE`; `ExternalSyncDirection` = `INBOUND`/`OUTBOUND` (**OUTBOUND modeled but unused**) | Add `ExternalQuoteIdentity` and `ExternalLeadIdentity`; start using `OUTBOUND`. |
| Customer / Contact / Venue | Full models, actively synced (`Customer`, `CustomerContact`, `Venue`) | — (reused as-is). |
| Lead / deal | `LeadRequest` with `LeadStatus` = `PENDING`/`COMPLETED`; HubSpot deal id + stage are dropped into `LeadRequest.message` as **free text** | No structured deal-stage handling — needs a stage field + `ExternalLeadIdentity` keyed on `hubspot_deal_id`. |
| Flex quote | **No model.** Flex is read-only inbound (inventory/venues/events) | Net-new `FlexQuote` model + quote-status states (Inquiry / Tentative / Confirmed / Ready to Invoice / Invoice Created / Closed). |
| Event / job dates | `Event.eventDate`, `Event.endDate`, `Event.startTime`, `Event.endTime`, `Event.time`; plus `publishedAt`, `cancelledAt` | Load-in now drives `eventDate` (see [the date range](#the-gigs-date-range-load-in-to-load-out)), but only as a *day* — no `loadInTime` column, and no `prepDate` / `returnDate`. Add if Soundcheck must show the clock times. |
| Project status | `EventStatus` = `ACTIVE`/`ARCHIVED`/`UNARCHIVED`/`DRAFT`/`TEMPLATE` (no completed/settled state; settlement is tracked on `LedgerItem.settledAt`, not the event) | `Draft`→`DRAFT` and `Active`→`ACTIVE` map cleanly; **`Completed` and `Settled` are not project statuses today**. Needs new states. |

## Implementation status

Built in this change (Soundcheck side):

* **Schema** (`api-events/src/db/schema.prisma` + migration `20260603000000_hubspot_flex_pipeline`): `EventStatus` extended with `COMPLETED`/`SETTLED`; new `FlexQuoteStatus` and `DealStage` enums; new `FlexQuote` model (`flex_quotes`).
* **Status mapping** (`api-events/src/systems/n8n-flex-quote-sync/flexPipelineTypes.ts`): the canonical `(deal stage, quote status) → project status` function plus label/token normalizers.
* **Ingestion webhook** `POST /webhooks/n8n/flex-quote-sync` (`api-events/src/webhooks/n8nWebhook.ts` + `api-events/src/systems/n8n-flex-quote-sync/n8nFlexQuoteSyncOperation.ts`): HMAC + org-lock + `external_sync_events` idempotency reused from crm-sync; upserts the quote projection and advances the linked event's project status.
* **Outbound trigger**: `flex-quote` kind via `api-events/src/clients/n8nClient.ts` + `N8N_FLEX_QUOTE_WEBHOOK_URL`.
* **Management endpoints**: `GET /organizations/{orgId}/integrations/sync` (per-kind status) and `GET …/integrations/sync/history` (`api-events/src/systems/integrations-sync/integrationsSyncOperations.ts`).
* **Web**: the Integrations page now shows per-integration status badges + last-run and a sync-history table.

**Quote Requested is now wired end-to-end:**

* Soundcheck auto-creates a **Draft gig** when a gig-creating stage (DRAFT/ACTIVE) arrives with no linked event — `n8nFlexQuoteSyncOperation` calls `CreateDraftEvent` (title from the quote name, `show_start`→`startTime`, `load_out`→`endTime`, venue) and `LinkFlexQuoteEvent`. The response returns `soundcheck_event_id`.
* The pipeline service also **stamps the gig's HubSpot/Flex deep links** (`Event.ExternalLinks`, shown in gig details) from the `hubspot_deal_url` / `flex_element_url` the workflow sends in `metadata` — see `IntegrationsRepository.UpsertEventExternalLinks`. n8n cannot call the Clerk-authed `PUT /events/{id}/external-links`, so the HMAC-verified webhook is the populate path.
* n8n workflow `hubspot-flex-quote-requested.workflow.json` is **triggered in real time** by a HubSpot deal-stage webhook (plus the daily cron / Sync-now backfill), **find-or-creates** the Flex **Contact** (deal's primary contact) and **Location/Venue** (Venue Address), creates the Flex **quote** linking both (`clientId`/`venueId`), posts to the webhook, then **notifies Slack + email** with the three linked records — gated by a `Newly linked?` check so only the **first** sync notifies (a re-fired webhook or the daily backfill reuses the existing quote and does not re-notify).
* **Dates:** each Flex date maps from its **own** HubSpot deal field — **Load In** = `load_in` (date) + `load_in_time` (**datetime** — a true instant; see the load-time convention below) → Flex `loadInDate` (also opens the gig's range → `Event.eventDate`); **Load Out** = `load_out` + `load_out_time` → Flex `loadOutDate` (also the gig `endTime`, and closes the range → `Event.endDate` when its *operating day* is later than the start — see [the date range](#the-gigs-date-range-load-in-to-load-out)); **Show Start/End** = `project_date` + `project_start_time`/`project_end_time` → Flex `showStartDate`/`showEndDate` (Show Start is also the gig `startTime`, and the `eventDate` fallback when there is no load-in). The date+time pairs are **1:1 with Flex** in both directions: the Flex Tentative workflow writes `load_in`/`load_in_time` and `load_out`/`load_out_time` back onto the deal it creates (rendered in the venue's timezone). HubSpot date fields (midnight-UTC epoch millis) are parsed to the calendar date and given their clock — or `FLEX_DEFAULT_HOUR` when the time field is empty — in `FLEX_TIMEZONE`.
* **Location:** the workflow geocodes the **Venue Address** via Google's Geocoding REST API (`GOOGLE_MAPS_API_KEY`; n8n owns the call — the API never touches Google) and sends `location {address, location_name, place_id, latitude, longitude}` on the `flex-quote-sync` payload, so the auto-created gig gets a real, map-able place (`Event.placeId`/`latitude`/`longitude`/`location`) instead of `Location TBD`. No key / no match → the placeholder, same as before. The server stays a pass-through (no server-side geocoding), mirroring how the web gig form gets `place_id` from the client-side Google Places autocomplete.

**Duplicate-quote guard (2026-08-04).** A Flex-native quote's deal is created by the Tentative workflow **directly in the Quote Requested stage** (`HUBSPOT_TENTATIVE_STAGE` = `presentationscheduled` = the Quote Requested stage id), which is by design. The consequence is a guaranteed second pass: the HubSpot workflow **"Soundcheck Sync: Quote Requested"** (portal 21417821, flow 4400477887) emails `intake@` when a deal enters that stage, the email switchboard routes it to the Quote Requested workflow, and it runs against a deal whose quote already exists — roughly **60 s** after Tentative finishes (verified on n8n executions 9048→9050 on 2026-08-04 and 7522→7524 on 2026-07-20). This is not the HubSpot deal webhook; it's the email path, so it fires regardless of webhook subscriptions. The re-run is expected and normally takes the silent reuse path (`shouldNotify: !link.reused` — it posts nothing to Slack). The danger is the guard going blind. Two changes make it robust:

* The `Flex quote already exists?` check no longer trusts the HubSpot deal alone. It reads `existingFlexId` (the deal's `flex_quote_id`/`flex_quote_url`) **OR** `flex_quote_id` off the `flex-quote-sync` gig-create response — the `flex_quotes` projection is the system of record, keyed `(organization_id, hubspot_deal_id)`, and it's written before the deal exists. The reuse branch then re-stamps the deal, so a deal that lost its cross-link props self-heals.
* Tentative's deal write no longer puts the join keys in the same request as the data fields. The create now carries `dealname`/`dealstage`/`amount` **plus** `flex_quote_id`/`flex_quote_url`/`soundcheck_gig_url`/`soundcheck_last_synced_at`; the dates, clocks and `venue_address` follow in a separate best-effort PATCH whose failure is reported (`dataFieldsError`) instead of swallowed. Previously a single rejected value 400'd the whole POST and the fallback wrote a bare deal with no `flex_quote_id` and nothing to re-apply it — the exact condition behind the 2026-08-04 duplicate (`ee30143e` from Flex, then a second empty `f4a11079` on deal 340214938323). A cross-link failure now also surfaces as a ⚠️ row in the Slack/email notification.

**What actually broke on 2026-08-04** (n8n execution 9048): the deal was created at 15:31:09 and still had `flex_quote_id: null`, `flex_quote_url: null`, `soundcheck_gig_url: null` and every data field null when the Quote Requested run fetched it at 15:32:06 — a **failed write, not a race**; waiting longer would never have helped. Root cause: **`load_in_time` / `load_out_time` are HubSpot `datetime` properties, not text**, and Tentative was sending them a venue-TZ clock string (`"5:00 PM"`) copied from the `project_start_time` convention. HubSpot rejects that, 400ing the entire create, and the fallback wrote a bare deal. They were added to the create on 2026-07-15 and held no value on any deal in the portal, because every attempt to write them had failed. The 2026-07-20 create (execution 7522) succeeded only because that quote had no dates and so sent no clock fields at all — which also clears `soundcheck_last_synced_at` (ISO-8601, accepted) and the URL props as causes.

**Load-time convention (corrected 2026-08-04): `load_in_time` / `load_out_time` carry a true instant, not a clock.** Tentative writes `String(Date.parse(loadIn))` (epoch millis); `Map deal, dates & config` treats a real instant in that field — epoch millis or ISO-8601 — as the load time verbatim, so Flex `loadInDate`/`loadOutDate` round-trip exactly with no clock parsing and no `FLEX_DEFAULT_HOUR` guess (`2026-08-05T22:00:00.000Z` → `1785967200000` → `2026-08-05T22:00:00.000Z`). Legacy free-text clocks (`"5:00 PM"`, `"17:00"`) still parse and are applied to `load_in`/`load_out`; a date-only value in the time field is deliberately *not* treated as an instant (it would resolve to midnight). This differs from `project_date` + `project_start_time`, which stay a date + free-text clock pair because `project_start_time` really is a text property. Also fixed: the Quote Requested **switchboard entry** never requested `load_in_time`/`load_out_time` in its property list — the path the Ghostmane deal arrived on — so load times silently fell back to the default hour there.

**The replay path carries the links too.** The gig-create post uses a fixed per-deal `event_id` (`hubspot-<dealId>-quote-requested`), so a re-run of an unchanged deal — the daily backfill, a webhook redelivery — matches on both `event_id` and `payload_hash` and short-circuits in `BeginSyncEvent`. Every workflow does stamp `metadata.last_synced_at` on each delivery, which would give every redelivery a new hash and defeat the guard entirely (`BeginSyncEvent` reopens an event whenever the hash differs), so `hashCrmSyncPayload` drops `last_synced_at` / `synced_at` before hashing and sorts the remaining keys. Identical redeliveries hash identically; a real edit still changes the payload and still gets through. **Anything that must be recognised as a replay therefore cannot depend on the sync clock** — a "won" retry that arrives after the same deal was lost would otherwise take the cancelled gig back to active. A bare `{event_id, duplicate}` acknowledgement would blind the guard on exactly the repeated path it protects, so the duplicate branch now resolves the projection row and returns `quote_id` / `flex_quote_id` / `soundcheck_event_id` (`FindFlexQuoteLink`, mirrored as `findFlexQuoteLink` in api-events). A **failed** lookup is not reported as "nothing is linked" — the workflow can't tell those apart and reads an absent `flex_quote_id` as permission to create, so the request fails instead.

**Quote identity resolution prefers the deal match — `NULLS LAST` is load-bearing.** `flex_quotes.hubspot_deal_id` is nullable, so for a row without one the ordering predicate `(NULLIF($2,'') IS NOT NULL AND hubspot_deal_id = $2)` evaluates to `NULL`, not `false` — and Postgres `DESC` defaults to `NULLS FIRST`. Without `NULLS LAST` the non-matching row sorts *ahead* of the deal match, inverting the preference the `ORDER BY` exists to express: an event carrying both a deal id and a quote id resolves to the wrong row, which is precisely the "Flex-only row later claimed by a HubSpot deal" case the SELECT-then-write design was built for. This affected `flexQuoteFindSQL` (and its api-events twin) before this change; both were corrected alongside the new link read, found by the integration test rather than by review.

**The create branch requires positive evidence.** The gig-create post runs with `neverError: true`, so a 5xx/503/400 does not stop the workflow — "Soundcheck is down" arrives looking exactly like "no quote linked". The `Flex quote already exists?` guard now also routes away from the create branch whenever that post did not return a 2xx, and the reuse node no-ops when it has no id to reuse (the API COALESCE-preserves the row; the writeback skips the Flex properties). This also closes a pre-existing hazard: before, *any* failed gig-create post — including `503 Webhook is not configured` — still created a Flex quote, with no gig linked to it. A missing quote is recoverable on the next run; a duplicate is not.

**`quote_status` is omitted by the gig-create post.** It is COALESCE-merged server-side, so sending `Inquiry` there downgrades a Flex-native quote that is already Tentative/Confirmed — and that post runs *before* the reuse branch is known, so guarding only the later link post would be too late. New rows still default to `INQUIRY` in SQL, so omitting it only ever preserves an existing status.

Because the reuse path doesn't re-notify, a blind guard is invisible until a duplicate quote appears in Flex — the notification warning is what makes it observable.

**Flex create-quote API (verified against the live `mapache` spec, `/f5/v3/api-docs`):** a quote is an **element** — `POST /f5/api/element` with `ElementPersistRequest` (`definitionId`, `name`, `clientId`/`venueId` = existing Flex contact ids, `preparedDate`/`loadInDate`/`showStartDate`/`showEndDate`/`loadOutDate` as ISO-8601). Synchronous → `ElementKeyInfo.elementId`. Auth `X-Auth-Token` with element-create permission. Discover the Quote `definitionId` once via `GET /f5/api/financial-document-definition/identity`; create missing client/venue contacts via `POST /f5/api/contact`.

Flex client/venue contact auto-creation is now done in the Quote Requested workflow (search-before-create, idempotent). Still deferred (n8n): the confirm/close/cancel transition workflows (Won/Lost/Postponed). Quote-identity dedup is carried directly on `FlexQuote` (`(organization_id, hubspot_deal_id)` / `(organization_id, flex_quote_id)`) rather than separate `External*Identity` tables.

## Implementation plan

### 1. Project status states

Introduce the canonical project status `None / Draft / Active / Completed / Settled`.
Two options:

* **(Recommended) Extend `EventStatus`** with `COMPLETED` and `SETTLED`. `Draft`→
  `DRAFT`, `Active`→`ACTIVE` already exist; `SETTLED` needs a new completion/
  settlement marker on the event (settlement is currently tracked on
  `LedgerItem.settledAt`, not the event). Minimal new surface; reuses the
  gig/event as the "project".
* Add a separate `projectStatus` field on `Event` (or a new `Project` model) if
  project status must evolve independently of the archive/template lifecycle that
  `EventStatus` also encodes.

`None` is the absence of an Event (Lead/Qualified phases — only a `LeadRequest`).

### 2. Quote & lead modeling

* New **`FlexQuote`** model: `status` enum (Inquiry/Tentative/Confirmed/Ready to
  Invoice/Invoice Created/Closed), `externalId` (Flex quote id), header dates,
  links to `Event`/`Customer`/`Venue`, `organizationId`.
* **As built:** dedup is carried directly on `FlexQuote` via the unique pairs
  `(organization_id, hubspot_deal_id)` and `(organization_id, flex_quote_id)`,
  rather than separate `External*Identity` tables — the quote row *is* the
  identity. Add a dedicated `ExternalLeadIdentity` later if HubSpot deal → lead
  dedup needs to be structured independently of the quote.

### 3. Outbound trigger

* Add `N8NTriggerKind` values — `flex-quote-create`, `flex-quote-status` — and a
  `N8N_FLEX_QUOTE_WEBHOOK_URL` config var (mirroring
  `N8N_INVENTORY_WEBHOOK_URL` et al. in `api-events/src/config/config.ts`).
* Extend `api-events/src/clients/n8nClient.ts` and the `sync/{kind}` handler (`api-events/src/systems/integrations-sync/`). The trigger
  payload carries the HubSpot deal id, target Flex action, and header fields.

### 4. Trigger source & inbound ingestion

* **Key decision:** how a HubSpot stage transition reaches Soundcheck. Options:
  (a) HubSpot deal-stage webhook → n8n → Soundcheck inbound; or (b) n8n watches
  HubSpot and notifies Soundcheck. Recommend (a) to keep n8n as the single
  orchestrator and avoid polling.
* Map the incoming stage to the [canonical status table](#canonical-cross-system-status-mapping)
  and advance the project status accordingly.
* Add callback webhook **`POST /webhooks/n8n/flex-quote-sync`** (same HMAC scheme
  as crm-sync) so Flex → Soundcheck quote results (quote id, status) land back and
  update the `FlexQuote` row.

### 5. n8n workflow

Extend the deployed HubSpot↔Flex workflow (and the reference
`hubspot-flex-crm-sync.workflow.json`):

* On `dealstage = Quote Requested`, build the Flex quote with the
  [header fields](#header-detail-sync) and create it via Flex.
* Map subsequent transitions to the [Flex actions](#hubspot-transition-to-flex-action)
  (Confirmed, close/archive, cancel-and-preserve).
* Verify the trigger signature (the existing `Verify * Trigger` node pattern) and
  post the quote id back to the callback webhook.
* Preserve the **Lost vs Postponed** distinction — same Flex "cancelled", different
  HubSpot stage and Soundcheck handling (Postponed keeps the quote reopenable).

## Open questions

* **How stage transitions reach Soundcheck** (HubSpot webhook → n8n vs. poll) —
  the central architectural choice for §4.
* Confirm **"project" = Soundcheck event/gig** (this spec assumes so; there is no
  separate `Project` model today).
* Removing the manual **"Ready to Invoice"** step / merging Flex statuses.
* Merging **Discovery + Qualification** into one bucket.
* The pipeline **numbering gap** (6 → 9): are stages 7–8 intentionally reserved?
* **Site-visit modeling** in Flex (pre-quote task vs. quote sub-state) — finalize
  with the Flex implementation team.
* **Reopen workflow** for postponed events — to be documented separately.
* **Proposals** are out of scope for v1; revisit if RFP volume grows.

## Related

* Inbound data contract, HMAC scopes, env vars, operator runbook: `README.md`
* Engineering reference for the n8n workflow: [`hubspot-flex-crm-sync.mdx`](./hubspot-flex-crm-sync.mdx)
* User-facing companion: [n8n CRM Sync](https://docs.soundchecklive.io/crm/integrations/n8n-crm-sync.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.