# Deals

> A sales pipeline over contacts — the three views, the six fixed stages, and why a deal is a contact rather than a record of its own.

Page: https://sdk.chatfuel.com/docs/modules/deals
Markdown: https://sdk.chatfuel.com/docs/modules/deals.md

There is no Deal entity in the Chatfuel API. A deal is a `Contact` whose `salesStageV2` is
set, so this module is three views over your contacts grouped by six fixed stages — and
everything that looks like a deal field (amount, close date, company) is a custom contact
attribute the module writes by name.

That is also why `deals` requires `contacts`: the record you are dragging around the board is
the same record the CRM edits.

## Installed as [#installed-as]

|            |                               |
| ---------- | ----------------------------- |
| Wizard id  | `deals`                       |
| Flag       | `--modules deals`             |
| Selection  | Default — `--yes` installs it |
| Requires   | `contacts`                    |
| Recommends | —                             |
| Skill      | `chatfuel-deals`              |

## Routes and views [#routes-and-views]

The view is a path segment; everything else is a query parameter. `board` is `DEFAULT_VIEW`,
so it has no segment of its own.

| Path              | What it shows                                                                                                |
| ----------------- | ------------------------------------------------------------------------------------------------------------ |
| `/deals`          | The kanban board: six columns, one query per column, drag to change stage.                                   |
| `/deals/table`    | The same deals as rows — search over name and phone, a stage subset, attribute conditions, sortable columns. |
| `/deals/forecast` | Deals per stage over a window, win rate, pipeline and weighted pipeline, CSV export, saved views.            |

The parameters the module owns:

| Parameter   | Value                                                                                                         |
| ----------- | ------------------------------------------------------------------------------------------------------------- |
| `deal`      | Contact id of the open deal. Opens beside the view — a drawer below 1280px, an inline second column above it. |
| `assignee`  | `any`, `unassigned`, `ai`, or `u:<UserAccountID>` for a person.                                               |
| `q`         | Search text.                                                                                                  |
| `stage`     | Comma list of `SalesStageV2` members.                                                                         |
| `unread`    | `1`, or absent.                                                                                               |
| `sort`      | `<attributeName>:asc` or `:desc`.                                                                             |
| `collapsed` | Comma list of the columns collapsed on the board.                                                             |
| `density`   | `comfortable` (the default) or `compact`.                                                                     |

The filter keys are shared across the three views, but the board cannot express most of them:
its query takes an assignee and nothing else, which is why the board's only filter control is
assignee. The table is where the rest apply.

Attribute predicates are deliberately not in the URL — they are unbounded in size and would
make a link unshareable, so they live in saved views instead. Neither is a default: a mount
that wrote the whole schema back would turn every shared link into six parameters of noise. A
`?view=` from an older link is still read once and dropped on the next write, and an
unrecognised value anywhere renders the default rather than throwing.

## The model [#the-model]

Six stages, fixed by the enum, in board order:

```
SalesStageV2: New | Sorting | Ready | WorkingOn | Won | Lost
```

`WorkingOn` renders as "Working on". The first four are `OPEN_STAGES` — everything still in
play, which is what a pipeline rollup covers.

The seven deal fields are custom contact attributes, and `contactAttributeUpdate` is
documented "Create or update custom contact attribute" — writing a value is what creates the
attribute. `lib/dealFields.ts` is the whole convention in one file:

| Field       | Attribute name     | Kind                                                                  |
| ----------- | ------------------ | --------------------------------------------------------------------- |
| Amount      | `deal amount`      | Plain decimal, no symbol and no separators                            |
| Currency    | `deal currency`    | ISO-4217, upper case; `EUR` when a deal has an amount and no currency |
| Close date  | `deal close date`  | Millisecond timestamp                                                 |
| Company     | `deal company`     | Text                                                                  |
| Probability | `deal probability` | Bare integer per cent                                                 |
| Source      | `deal source`      | Text                                                                  |
| Lost reason | `deal lost reason` | Text                                                                  |

Amount and close date are `CARD_FIELDS`: shown on the card and summed in the column rollup.
The rest live in the panel and in the table.

<Callout type="warn">
  There is no rename mutation for a custom attribute. Change an `attributeName` in
  `lib/dealFields.ts` after data exists and the old values are orphaned — and nothing errors:
  a name that does not exist yet is silently omitted from `contact.attributes(names:)` rather
  than reported, so the field reads empty on every deal. Add the old name to that
  field's `aliases` instead, which is what they are for.
</Callout>

The one timestamp the API keeps per deal is `lastSalesStageUpdateTime`, and it is the *last*
transition. The card's ageing bar is therefore "days since it last moved", never "days in this
stage" — two days in New, three in Sorting, five in Ready, fourteen in Working on. Won and
Lost have no threshold at all: a closed deal sitting still is not rot.

## Configuration [#configuration]

| Variable                     | Notes                                     |
| ---------------------------- | ----------------------------------------- |
| `CHATFUEL_TOKEN`             | Secret. Server-side only.                 |
| `VITE_CHATFUEL_WORKSPACE_ID` | The wizard's workspace step writes it.    |
| `CHATFUEL_API_BASE`          | Defaults to `https://panel.chatfuel.com`. |

Nothing here is specific to this module. [Environment variables](/docs/deploy/environments)
has what each one is and which side reads it.

## Permissions [#permissions]

| Object | Action | Required for                                                           |
| ------ | ------ | ---------------------------------------------------------------------- |
| People | View   | Reading the deals board                                                |
| People | Edit   | Moving cards between stages, editing deal fields, name, note and owner |

## Limits [#limits]

**A segment cannot be narrowed by stage.** `SegmentInput` has no sales-stage predicate. That
one gap costs two things: the table's attribute engine loses deal isolation (it returns
contacts, and a stage filter can only be applied client-side to the rows that loaded), and the
segment CSV export is every contact on the bot rather than the deals on screen. The other
export start, `csvContactExportStartByIDsList`, means "these deals" — and can only carry the
ids that are loaded, which is why the forecast offers to load the rest beside the export.

**There is no aggregation of any attribute — no SUM, no AVG.** Every money figure is a
client-side sum over the loaded rows and always renders its coverage (`60 of 128`). Mixed
currencies refuse to sum rather than print a wrong total.

**Four analytics are absent from the data, not from the roadmap.** With one timestamp per deal
and no stage-change history: no time-in-stage, no funnel conversion, no sales velocity or
average days to close, no cohorts. A window selects deals whose *last* update falls in it,
which is not the set that entered the pipeline in it.

**Within-column order cannot be persisted.** The board's sort is fixed to
`lastSalesStageUpdateTime` desc and `contactDealsConnection` has no `orderBy`, so a drop
indicator anywhere but the top of the target column would be a lie the next render corrects.

**There is no bulk mutation.** A multi-card drag is one `contactSetSalesStage` per card, a
partial failure is an ordinary outcome, and past 25 cards the drag is refused rather than
firing that many requests at a rate-limited bot.

**Only attribute-backed columns sort.** The server orders by an attribute name; stage, owner,
unread and last message have none. On the attribute engine every custom attribute reports
`dataType: string`, so `orderBy` sorts as text — `"9"` comes after `"1000"`.

**Saved views are per signed-in user.** `setUserStorageItem` is the only persistence this API
offers a client, so a teammate on the same bot sees none of yours.

The module's own ceilings, each picked against something the API does:

|                                            |                           |
| ------------------------------------------ | ------------------------- |
| Cards in a column page                     | 20                        |
| Pages the board auto-loads before a button | 3                         |
| Rows in a table page                       | 25                        |
| Cards in one drag                          | 25                        |
| Undo window                                | 60 seconds, one move deep |
| Saved views                                | 50                        |
| Saved view name                            | 60 characters             |
| Search kept in a saved view                | 200 characters            |
| Conditions in a saved filter               | 20                        |
| Values on a condition                      | 20                        |

<Cards>
  <Card title="Deals, as a product" href="/modules/deals" description="What the board, the table and the forecast do, and the questions this API cannot answer." />

  <Card title="Contacts" href="/docs/modules/contacts" description="Required alongside this one — the record a deal actually is." />

  <Card title="Inbox" href="/docs/modules/inbox" description="Where the conversation behind a deal happens; an edit there lands in the open panel." />

  <Card title="Your agent's notes" href="/docs/concepts/agent-skills" description="The wizard installs .claude/skills/chatfuel-deals/ (Codex: .agents/skills/chatfuel-deals/) — the board queries, the table's engines, and the analytics this API cannot support." />
</Cards>
