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.
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 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.
better-supabase sql add dealsThe 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:
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
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. 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:
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
A deal moves stage when stageId changes, through an edit or a board drag with 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
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
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 module installed, a planned engagement on the deal counts as its next step.
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 module installed, the owner and the team watch the deal, and winning or losing it notifies its watchers.
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
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
| 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 |
Last updated on
Engagements
Calls, emails, meetings, notes and tasks logged on one or more records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.
Work items
Issues and tasks on pipelines, with identifiers, parents without cycles, several assignees, priority 0 to 4, estimates, start, end and due dates, locking, projects, iterations, batched edits and a progress tree.