# Deals

> Deals on pipelines with amounts in minor units, a company and contacts, a deal team, line items, forecast calls with attainment, close-date slips, a next step, locking and batched edits that reject per change.

Source: https://bettersupabase.com/docs/blocks/deals

`deals` is the sales record: a deal for a company, with contacts, an owner and a team, an amount in minor units and a close date, moving through a [pipeline](/docs/blocks/pipelines) from qualified to won or lost. It brings the fields a sales team works with every day: a motion (new logo, expansion, renewal), a priority, the rep's forecast call, the next step, risk flags and a lock for deals finance has countersigned.

Reading needs `deals.read`, creating and editing needs `deals.write`, and deleting someone else's deal needs `deals.manage`. Admins get every permission, and members get `deals.read` and `deals.write`.

```bash
better-supabase sql add deals
```

The module needs `tenant`, `access`, `subjects` and `pipelines`. Installing it binds the `deal` type in the pipelines module with the categories `open`, `won` and `lost`, and a first pipeline of Qualified (20%), Discovery (35%), Proposal (55%), Negotiation (75%), Closed won and Closed lost. Set `sql.modules.pipelines.options.subjects.deal` to change the stages or add a rotting limit; your keys are merged over the defaults.

Register deals and the parties they point at in `config.subjects`:

```ts title="better-supabase.config.ts"
import { dealsSubjects } from "better-supabase/blocks/deals";
import { partiesSubjects } from "better-supabase/blocks/parties";

export default defineConfig({
  subjects: { ...partiesSubjects(), ...dealsSubjects({ url: "/deals/{id}" }) },
});
```

## Create and edit [#create-and-edit]

```ts
import { createDeals } from "better-supabase/blocks/deals";

const deals = createDeals({ transport });
const deal = await deals
  .create(
    organizationId,
    {
      name: "Growth plan, 60 seats",
      closeOn: Temporal.PlainDate.from("2026-11-30"),
      amount: 4_800_000n,
      currency: "EUR",
      company: { type: "company", id: acmeId },
      motion: "new_logo",
    },
    [{ type: "person", id: adaId, role: "Champion", primary: true }],
  )
  .orThrow();
```

A deal needs a name and a close date. It gets a number per organization and an identifier such as `DEAL-1`, or the format of a `deal` entity in [number-sequences](/docs/blocks/number-sequences). Its company and contacts are records in `config.subjects` (the parties types `company` and `person` by default, or set `options.company` and `options.contact`). Each must be in the organization and readable by the caller (`DEAL_COMPANY_FORBIDDEN`, `DEAL_CONTACT_FORBIDDEN`), so an app can point deals at its own customers table.

`update(changes)` applies a batch of cell edits, `{ dealId, field, value }`, each on its own. A change that fails is returned in `rejected` with its hint and reason, and the others stay applied, the shape a data grid's batched edit needs:

```ts
const { accepted, rejected } = await deals
  .update([
    { dealId, field: "stageId", value: proposalStageId },
    { dealId, field: "closeOn", value: Temporal.PlainDate.from("2026-12-15") },
    { dealId, field: "forecast", value: "commit" },
  ])
  .orThrow();
```

The rules the table enforces for every writer:

| Rule                                                                                            | Hint                                           |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| A locked deal takes only its note                                                               | `DEAL_LOCKED`                                  |
| The open date never moves                                                                       | `DEAL_OPENED_ON_FIXED`                         |
| A closed deal is called by its stage, so its forecast doesn't change                            | `DEAL_FORECAST_CLOSED`                         |
| A close date that moves later is a slip: `slippedFrom` keeps the first date, `pushCount` counts | none                                           |
| Motions and priorities come from `options.motions` and `options.priorities`                     | `DEAL_MOTION_INVALID`, `DEAL_PRIORITY_INVALID` |

## Stages, winning and losing [#stages-winning-and-losing]

A deal moves stage when `stageId` changes, through an edit or a board drag with [rank](/docs/blocks/rank) (`dealsSubjects()` groups the board by `stage_id`). The pipelines module checks the move and keeps the stage history. A won or lost stage sets `outcome`, which stamps `closedAt` and records `deal.closed` with the outcome. `win(dealId, note)` and `lose(dealId, reason)` move the deal to its pipeline's first won or lost stage, and `reopen(dealId, stageId?)` moves it back to an open stage.

## Contacts, team and lines [#contacts-team-and-lines]

`setContacts` replaces the contacts, with one primary. `setMembers` replaces the deal team; each member watches the deal and, with `options.readAll: false`, can see it. Without `readAll` (the default is `true`), a member sees only deals they own or are on, and `deals.manage` sees all.

`setLines(dealId, lines)` replaces the line items: a description, a quantity, a unit amount in minor units, a discount and a tax rate in basis points, and an optional product reference. It returns the totals, and with `amountSource: "line_items"` the deal's amount follows the lines' subtotal. A locked deal's lines don't change.

## Forecast and review [#forecast-and-review]

```ts
const call = await deals
  .forecast(organizationId, {
    since: Temporal.PlainDate.from("2026-10-01"),
    until: Temporal.PlainDate.from("2026-12-31"),
    currency: "EUR",
    target: 60_000_000n,
  })
  .orThrow();
// { won, commit, bestCase, pipeline, notCalled, omit, later, attainment, gap, owners }
```

`forecast` sums one currency in a period: deals won in it, open deals closing in it by forecast category (`notCalled` when the rep has made no call), and `later` for open deals that close after it. `attainment` is won plus commit over the target, and `owners` splits the same sums per owner. `health(pipelineId)` lists the open deals with their days in stage, whether they are rotting, whether the close date slipped, whether there is no next step, and whether the next step is overdue. With the [engagements](/docs/blocks/engagements) module installed, a planned engagement on the deal counts as its next step.

## Events [#events]

| Event                                                                            | Data                                                                     |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `deal.created`, `deal.updated`, `deal.reopened`, `deal.archived`, `deal.deleted` | `organizationId`, `dealId`, `subjectType`, `subjectId`, `name`, `fields` |
| `deal.closed`                                                                    | the same, with `outcome`, `amount`, `currency` and the lost `reason`     |

With the [notifications](/docs/blocks/notifications) module installed, the owner and the team watch the deal, and winning or losing it notifies its watchers.

## Hooks [#hooks]

The module calls `before_deal_create(tenant, attrs jsonb)`, `after_deal_create(deal uuid)`, `before_deal_update(deal uuid, field text, value jsonb)` and `after_deal_update(deal uuid, field text)`. A `before_` hook raises to refuse; in `update`, that refuses only its change.

## Wire it to ReUI [#wire-it-to-reui]

The ReUI CRM Pipeline Workspace sends every gesture through one `actions.write(writes)` call with `{ dealId, field, value }` writes: map it to `update`, with ReUI's `title` as `name`, `closeDate` as `closeOn`, `forecast` as `forecast` and `nextAt` and `nextStep` as they are, and show `rejected` reasons in its toast. Board lanes come from the pipeline's stages with their probability and rotting limit, the Forecast Call's lanes from `forecast`, and its slipped, rotting, no-next-step and overdue chips from `health`. The Deal Desk's line items map to `setLines`, and its Mark won and Mark lost buttons to `win` and `lose`.

## Errors [#errors]

| Hint                    | Cause                                                        |
| ----------------------- | ------------------------------------------------------------ |
| `DEAL_FORBIDDEN`        | the caller lacks `deals.write`                               |
| `DEAL_ATTRS_INVALID`    | a missing name or close date, or a field with a bad value    |
| `DEAL_NOT_FOUND`        | no deal the caller can edit                                  |
| `DEAL_FIELD_UNKNOWN`    | `update` names a field it doesn't write                      |
| `DEAL_OUTCOME_INVALID`  | close as `won` or `lost`                                     |
| `DEAL_STAGE_MISSING`    | the pipeline has no stage for that outcome                   |
| `DEAL_CHANGES_INVALID`  | `changes` isn't a list                                       |
| `DEAL_CONTACTS_INVALID` | contacts aren't a list, or name two primaries                |
| `DEAL_MEMBERS_INVALID`  | members aren't a list                                        |
| `LINES_RECORD_INVALID`  | no such deal, or it is locked                                |
| `LINES_INVALID`         | a line lacks a description, a positive quantity or an amount |