Design specification for Deals and Quotes modules in CentraKit (pipeline, quoting, PDF, workflows).
Date: 2026-05-08 Author: Mick Nijboer Status: Historical draft — not the current implementation contract
This specification predates the greenfield cleanup. It preserves the original design discussion, including obsolete deals and year-scoped quote-numbering references; use current schema and architecture docs as the implementation source of truth.
Two new modules in CentraKit SaaS app:
/[orgSlug]/deals) — sales-pipeline. Kanban + list. One deal = one opportunity with a customer. Five fixed stages./[orgSlug]/quotes) — formal documents (offertes). Hang under a deal, or stand alone. Document with line items, optional rows, public-link acceptance, PDF export.The two appear as separate top-level menu items.
deal 1 → 0..n quote (linear versions, max one active)quote 0..1 → 1 deal (quote.deal_id nullable — standalone quotes allowed)quote → customer (required; customer_id denormalized on quote with quote.customer_id == deal.customer_id constraint when deal_id set)deal → customer (required), deal.workspace_id nullable (no UI in v1)task.deal_id nullable (tasks may belong to a deal)deal_products, deal_equipment — m:n link tablesIn scope (v1):
Out of scope (v1):
deals.pipeline_id)quotes.invoice_id)All tables organization-scoped, RLS by organization_id.
dealsid, organization_id, customer_id (req), workspace_id (null)
title, description
stage (enum: new|qualified|proposal|negotiation|won|lost)
expected_value (numeric, null), currency
expected_close_date (date, null)
owner_id (user, default current user)
position (int — kanban order within stage)
lost_reason (null), won_at (null), lost_at (null)
archived_at (null — soft delete)
created_by, updated_by, created_at, updated_atdeal_stage_historyid, deal_id, organization_id
from_stage, to_stage
actor_user_id (null)
reason (null)
created_atdeal_products, deal_equipment (m:n)id, deal_id, organization_id, product_id | equipment_id, position, created_atquotesid, organization_id, customer_id (req), deal_id (null, FK)
number (e.g. Q-2026-0001, generated at first send), version (int)
supersedes_quote_id (null)
status (draft|sent|accepted|rejected|expired|superseded)
title, intro (md), terms (md), internal_notes
valid_until (date), currency, vat_inclusive (bool — default false)
discount_kind (none|percentage|fixed), discount_value
subtotal_excl, vat_total, total_incl (cached, server-recomputed)
accept_token (unique-indexed, base64url 32 bytes)
public_message
sent_at, accepted_at, rejected_at, expired_at
accepted_by_name, accepted_by_email, accepted_ip, accepted_user_agent
accepted_optional_line_ids (jsonb array)
archived_at (null — soft delete for sent+ statuses)
created_by, updated_by, created_at, updated_at
(v2 hooks reserved: signature_provider, signature_envelope_id, signed_pdf_url, invoice_id)quote_linesid, quote_id, organization_id
kind (line|section_header)
line_type (product|labor|custom|null) -- null when kind=section_header
product_id (null, FK), task_id (null, FK — labor link, optional)
title, description
quantity, unit (uur/stuks/...)
unit_price, vat_rate
discount_kind (none|percentage|fixed), discount_value
is_optional (bool, default false)
position (int)
created_at, updated_atquote_status_historyid, quote_id, organization_id
from_status, to_status
actor_user_id (null)
actor_customer_name (null), actor_customer_email (null)
reason (null)
ip, user_agent
created_atTime-in-status derivable from history (now() - last entry.created_at).
quote_templates, quote_template_linesMirror of quotes / quote_lines without customer / deal / status / numbering / acceptance fields.
quote_number_sequencesorganization_id, year, last_number
PK (organization_id, year)Atomic increment via SQL function next_quote_number(org, year) using pg_advisory_xact_lock(hashtext(org_id::text || year::text)) then upsert + return.
tasks.deal_id nullable FKorganizations extended with: default_quote_validity_days (int, default 30), default_quote_terms (md), default_currency, default_vat_rate (numeric, default 21)new → qualified → proposal → negotiation → won
↘ lost (from any stage)deal_stage_history.won_at / lost_at + lost_reason populated.won does not auto-trigger anything in v1 (v2 → invoice workflow).draft → sent → accepted
↘ rejected
↘ expired (workflow-driven on valid_until)
↘ superseded (on new version)sent is irreversible — to amend, duplicate-as-new-version.sent. Drafts have no number.accepted does NOT auto-transition the parent deal to won (v2 workflow could).Public payload:
{ accepted: true, name, email, selectedOptionalLineIds: string[] }Server stores accepted_optional_line_ids on the quote. Lines are not mutated. Display layer (PDF, accepted view) filters non-selected optionals out. Cached totals are recomputed excluding non-selected optionals.
apps/saas/app/(external)/portal/quotes/[token]/
page.tsx ← Public quote view + accept-form
thanks/page.tsx
expired/page.tsx
api/accept/route.ts
api/reject/route.tsget_quote_by_token SECURITY DEFINER function.customer.language, fallback nl.valid_until < now: render expired view, no actions allowed.status != 'sent': render "no longer available" view.POST /api/v1/public/quotes/[token]/accept body { name, email, selectedOptionalLineIds }.
Atomic RPC accept_quote_by_token:
status='sent', valid_until > now, all selectedOptionalLineIds belong to quote.status='accepted', accepted_at, accepted_by_*, accepted_ip, accepted_user_agent, accepted_optional_line_ids.quote_status_history.Reject handler is analogous (POST /api/v1/public/quotes/[token]/reject, optional reason).
resend skill).apps/saas/emails/.quotes@centrakit.app, reply-to org email (no per-org DNS in v1).https://app.../portal/quotes/[token].react-pdf skill).Workflows replace cron and ad-hoc email handlers. Located in apps/saas/workflows/ (per Vercel Workflows convention; verify at implementation via workflow skill).
quoteLifecycle(quoteId)Started on first send.
valid_until - 7d.status === 'sent' → send "Reminder: expires in 7 days".valid_until.status === 'sent' → atomic transition to expired + notify org owner.Each step re-checks current status; if quote left sent, workflow becomes a no-op.
quoteAccepted(quoteId)Triggered by accept handler after RPC succeeds.
quoteRejected(quoteId)reject_reason.None — full trust in workflows for v1.
apps/saas/app/[locale]/(app)/[orgSlug]/
deals/
(tabs)/
layout.tsx ← DealsTabs (exists)
page.tsx ← Pipeline kanban (default)
all/page.tsx ← List
[dealId]/page.tsx ← Detail
create/page.tsx
actions.ts
quotes/
(tabs)/
layout.tsx ← QuotesTabs (active | drafts | all)
page.tsx ← Active
all/page.tsx
drafts/page.tsx
[quoteId]/
page.tsx ← Detail / edit
pdf/route.ts ← GET PDF stream
create/page.tsx ← Optional ?dealId=...
templates/
page.tsx
[templateId]/page.tsx
create/page.tsx
actions.tsPublic route (no locale, no orgSlug, no auth — token-only):
apps/saas/app/(external)/portal/quotes/[token]/
page.tsx
thanks/page.tsx
expired/page.tsx
api/accept/route.ts
api/reject/route.ts<DealsKanban>, <QuotesList>, <DealDetailBody>, <QuoteDetailBody>.withOrgCapability) inside islands, not in shells.moveDealStage(dealId, toStage, position).Sections (Card pattern):
Form-driven. Save + Cancel together in Section footer.
vat_inclusive toggle, discount.<KanbanBoard> — generic if not present.<LineItemEditor> — drag-drop, inline picker.<MoneyInput>, <VatRateSelect>, <MdEditor>.deals.*, quotes.*.useExtracted / getExtracted per page.next-intl extraction; NL + EN .po catalogs.Mirror existing rbac / users pattern (per Mirror roles/permissions pattern memory).
packages/work/src/deals/
schemas.ts errors.ts service.ts index.ts
packages/commerce/src/quotes/
schemas.ts errors.ts service.ts index.ts
packages/supabase/src/deals/
service.ts rpc-types.ts
packages/supabase/src/quotes/
service.ts rpc-types.ts
packages/core/ — wires both via createRequestServices
apps/saas/lib/deals/, lib/quotes/
apps/saas/components/deals/, components/quotes/
apps/saas/app/.../deals/actions.ts, .../quotes/actions.tsResult-typed methods (better-result):
DealService: list(filter), get(id), create(input), update(id, patch), moveStage(id, stage, position), archive(id), restore(id).QuoteService: list(filter), get(id), create(input), update(id, patch), send(id), markAccepted(id, payload), markRejected(id, payload), duplicateAsNewVersion(id), archive(id), getByToken(token), acceptByToken(token, payload, ip, ua), rejectByToken(token, payload).Server actions are thin wrappers gated by withOrgCapability. Public-link routes have no capability check — token is the credential.
All DDL via Supabase declarative schemas (per repo convention — never hand-author migrations).
supabase/schemas/
014_types_domain_enums.sql ← extend with deal_stage, quote_status, quote_line_kind, quote_line_type, discount_kind
042_tables_deals.sql ← deals, deal_stage_history, deal_products, deal_equipment
043_tables_quotes.sql ← quotes, quote_lines, quote_status_history, quote_number_sequences
044_tables_quote_templates.sql
057_functions_rbac.sql ← extend capabilities seed
058_functions_quotes.sql ← next_quote_number, get_quote_by_token, accept_quote_by_token, reject_quote_by_token
075_rls_deals.sql
076_rls_quotes.sql
077_rls_quote_templates.sqlMigrations generated via supabase db diff.
Capability keys (ASCII hyphens):
deals.view, deals.create, deals.update, deals.delete,
deals.move-stage, deals.transfer-ownership
quotes.view, quotes.create, quotes.update, quotes.delete,
quotes.send, quotes.mark-accepted, quotes.mark-rejected, quotes.duplicate
quote-templates.view, quote-templates.create,
quote-templates.update, quote-templates.delete| Capability | system-admin | owner | admin | member | viewer |
|---|---|---|---|---|---|
| deals.view | ✓ | ✓ | ✓ | ✓ | ✓ |
| deals.create | ✓ | ✓ | ✓ | ✓ | – |
| deals.update | ✓ | ✓ | ✓ | ✓ | – |
| deals.delete | ✓ | ✓ | ✓ | – | – |
| deals.move-stage | ✓ | ✓ | ✓ | ✓ | – |
| deals.transfer-ownership | ✓ | ✓ | ✓ | – | – |
| quotes.view | ✓ | ✓ | ✓ | ✓ | ✓ |
| quotes.create | ✓ | ✓ | ✓ | ✓ | – |
| quotes.update | ✓ | ✓ | ✓ | ✓ | – |
| quotes.delete | ✓ | ✓ | ✓ | – | – |
| quotes.send | ✓ | ✓ | ✓ | – | – |
| quotes.mark-accepted | ✓ | ✓ | ✓ | – | – |
| quotes.mark-rejected | ✓ | ✓ | ✓ | ✓ | – |
| quotes.duplicate | ✓ | ✓ | ✓ | ✓ | – |
| quote-templates.view | ✓ | ✓ | ✓ | ✓ | ✓ |
| quote-templates.create | ✓ | ✓ | ✓ | – | – |
| quote-templates.update | ✓ | ✓ | ✓ | – | – |
| quote-templates.delete | ✓ | ✓ | – | – | – |
Orgs may override defaults via existing RBAC overrides UI; orgs may also add custom roles.
deal_id set null.draft allowed hard delete; sent/accepted/rejected/expired/superseded use soft-delete (archived_at) for audit retention.vat_inclusive toggle with lines present — blocked.next_quote_number(org, year) serialized via pg_advisory_xact_lock.accept_token regenerate.accept_quote_by_token, next_quote_number) — real DB.quotes.signature_provider, quotes.signature_envelope_id, quotes.signed_pdf_urlquotes.invoice_id (nullable FK)deals.pipeline_id (nullable FK to future pipelines table; current "default" hardcoded)quote_lines.was_decremented_inventory_at for inventory automation