diff --git a/apps/api/src/deals/deals.contracts.ts b/apps/api/src/deals/deals.contracts.ts
index a80b38b56..b4b1b9f26 100644
--- a/apps/api/src/deals/deals.contracts.ts
+++ b/apps/api/src/deals/deals.contracts.ts
@@ -1,4 +1,10 @@
-import { DealStage } from "@crm/db";
+import {
+ CalendarStatus,
+ DealStage,
+ InvoiceStatus,
+ PaymentStatus,
+ QuoteStatus,
+} from "@crm/db";
import { FIELD_ENTITIES, FIELD_TYPES } from "@crm/db/fields";
import { z } from "zod";
import { bulkIdsInput } from "../crm/bulk";
@@ -41,6 +47,24 @@ const stageEnum = z.enum(
Object.values(DealStage) as [DealStage, ...DealStage[]],
);
+const quoteStatusEnum = z.enum(
+ Object.values(QuoteStatus) as [QuoteStatus, ...QuoteStatus[]],
+);
+
+const invoiceStatusEnum = z.enum(
+ Object.values(InvoiceStatus) as [InvoiceStatus, ...InvoiceStatus[]],
+);
+
+const paymentStatusEnum = z.enum(
+ Object.values(PaymentStatus) as [PaymentStatus, ...PaymentStatus[]],
+);
+
+const calendarStatusEnum = z.enum(
+ Object.values(CalendarStatus) as [CalendarStatus, ...CalendarStatus[]],
+);
+
+const nullableDateTime = z.string().nullable().optional();
+
export const dealCreateInput = z.object({
name: z.string().trim().min(1, "A deal needs a name."),
companyId: z.string().min(1, "A deal belongs to a company."),
@@ -61,6 +85,19 @@ const dealUpdateInput = z.object({
amountCents,
currency: currencyCode.optional(),
expectedCloseDate: z.string().nullable().optional(),
+ quoteStatus: quoteStatusEnum.optional(),
+ invoiceStatus: invoiceStatusEnum.optional(),
+ paymentStatus: paymentStatusEnum.optional(),
+ calendarStatus: calendarStatusEnum.optional(),
+ googleCalendarEventId: z.string().nullable().optional(),
+ quoteSentAt: nullableDateTime,
+ invoiceRequestedAt: nullableDateTime,
+ invoiceSentAt: nullableDateTime,
+ depositPaidAt: nullableDateTime,
+ fullyPaidAt: nullableDateTime,
+ calendarAddedAt: nullableDateTime,
+ depositAmountCents: amountCents,
+ balanceAmountCents: amountCents,
fields: recordFieldValues.optional(),
});
@@ -230,11 +267,24 @@ export const dealDetailOutput = z.object({
description: z.string().nullable(),
stage: stageEnum,
currency: z.string(),
+ quoteStatus: quoteStatusEnum,
+ invoiceStatus: invoiceStatusEnum,
+ paymentStatus: paymentStatusEnum,
+ calendarStatus: calendarStatusEnum,
+ googleCalendarEventId: z.string().nullable(),
+ quoteSentAt: z.string().nullable(),
+ invoiceRequestedAt: z.string().nullable(),
+ invoiceSentAt: z.string().nullable(),
+ depositPaidAt: z.string().nullable(),
+ fullyPaidAt: z.string().nullable(),
+ calendarAddedAt: z.string().nullable(),
closedReason: z.string().nullable(),
company: dealCompanyDetailOutput,
owner: dealOwnerOutput,
fields: z.array(recordFieldOutput),
amountCents: z.number().nullable(),
+ depositAmountCents: z.number().nullable(),
+ balanceAmountCents: z.number().nullable(),
baseAmountCents: z.number().nullable(),
reportingCurrency: z.string(),
fxRate: z.number().nullable(),
diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts
index 4a4d01c86..d89215550 100644
--- a/apps/api/src/deals/deals.service.ts
+++ b/apps/api/src/deals/deals.service.ts
@@ -202,6 +202,19 @@ export class DealsService {
stageChangedAt: true,
amount: true,
currency: true,
+ quoteStatus: true,
+ invoiceStatus: true,
+ paymentStatus: true,
+ calendarStatus: true,
+ googleCalendarEventId: true,
+ quoteSentAt: true,
+ invoiceRequestedAt: true,
+ invoiceSentAt: true,
+ depositPaidAt: true,
+ fullyPaidAt: true,
+ calendarAddedAt: true,
+ depositAmount: true,
+ balanceAmount: true,
baseAmount: true,
fxRate: true,
fxRateAt: true,
@@ -226,9 +239,17 @@ export class DealsService {
const {
contacts,
amount,
+ depositAmount,
+ balanceAmount,
baseAmount,
fxRate,
fxRateAt,
+ quoteSentAt,
+ invoiceRequestedAt,
+ invoiceSentAt,
+ depositPaidAt,
+ fullyPaidAt,
+ calendarAddedAt,
archivedAt,
...rest
} = deal;
@@ -237,10 +258,18 @@ export class DealsService {
...rest,
fields: await this.fields.valuesFor("DEAL", id),
amountCents: toCents(amount),
+ depositAmountCents: toCents(depositAmount),
+ balanceAmountCents: toCents(balanceAmount),
baseAmountCents: toCents(baseAmount),
reportingCurrency: await this.conversion.reportingCurrency(),
fxRate: fxRate?.toNumber() ?? null,
fxRateAt: fxRateAt?.toISOString() ?? null,
+ quoteSentAt: dateIso(quoteSentAt),
+ invoiceRequestedAt: dateIso(invoiceRequestedAt),
+ invoiceSentAt: dateIso(invoiceSentAt),
+ depositPaidAt: dateIso(depositPaidAt),
+ fullyPaidAt: dateIso(fullyPaidAt),
+ calendarAddedAt: dateIso(calendarAddedAt),
stageChangedAt: deal.stageChangedAt.toISOString(),
expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null,
closedAt: deal.closedAt?.toISOString() ?? null,
@@ -330,6 +359,46 @@ export class DealsService {
if (input.expectedCloseDate !== undefined) {
data.expectedCloseDate = parseDate(input.expectedCloseDate);
}
+ if (input.quoteStatus !== undefined) data.quoteStatus = input.quoteStatus;
+ if (input.invoiceStatus !== undefined) {
+ data.invoiceStatus = input.invoiceStatus;
+ }
+ if (input.paymentStatus !== undefined) {
+ data.paymentStatus = input.paymentStatus;
+ }
+ if (input.calendarStatus !== undefined) {
+ data.calendarStatus = input.calendarStatus;
+ }
+ if (input.googleCalendarEventId !== undefined) {
+ data.googleCalendarEventId =
+ input.googleCalendarEventId === null
+ ? null
+ : blankToNull(input.googleCalendarEventId);
+ }
+ if (input.quoteSentAt !== undefined) {
+ data.quoteSentAt = parseDate(input.quoteSentAt);
+ }
+ if (input.invoiceRequestedAt !== undefined) {
+ data.invoiceRequestedAt = parseDate(input.invoiceRequestedAt);
+ }
+ if (input.invoiceSentAt !== undefined) {
+ data.invoiceSentAt = parseDate(input.invoiceSentAt);
+ }
+ if (input.depositPaidAt !== undefined) {
+ data.depositPaidAt = parseDate(input.depositPaidAt);
+ }
+ if (input.fullyPaidAt !== undefined) {
+ data.fullyPaidAt = parseDate(input.fullyPaidAt);
+ }
+ if (input.calendarAddedAt !== undefined) {
+ data.calendarAddedAt = parseDate(input.calendarAddedAt);
+ }
+ if (input.depositAmountCents !== undefined) {
+ data.depositAmount = fromCents(input.depositAmountCents);
+ }
+ if (input.balanceAmountCents !== undefined) {
+ data.balanceAmount = fromCents(input.balanceAmountCents);
+ }
if (input.amountCents !== undefined || input.currency !== undefined) {
const current = await this.db.deal.findUnique({
@@ -889,3 +958,7 @@ function parseDate(value: string | null | undefined): Date | null {
}
return date;
}
+
+function dateIso(value: Date | null): string | null {
+ return value?.toISOString() ?? null;
+}
diff --git a/apps/api/test/deals-contracts.spec.ts b/apps/api/test/deals-contracts.spec.ts
new file mode 100644
index 000000000..a996a2c2e
--- /dev/null
+++ b/apps/api/test/deals-contracts.spec.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "bun:test";
+import { dealUpdateArgs } from "../src/deals/deals.contracts";
+
+describe("deal update contract", () => {
+ it("accepts operational booking fields on the existing update payload", () => {
+ const parsed = dealUpdateArgs.parse({
+ id: "deal_123",
+ data: {
+ quoteStatus: "SENT",
+ quoteSentAt: "2026-08-28T09:10:11.000Z",
+ invoiceStatus: "REQUESTED",
+ invoiceRequestedAt: "2026-08-28T09:20:00.000Z",
+ paymentStatus: "DEPOSIT_PAID",
+ depositAmountCents: 125_000,
+ calendarStatus: "ADDED",
+ calendarAddedAt: "2026-08-28T10:30:00.000Z",
+ googleCalendarEventId: "event-props-calendar-id",
+ },
+ });
+
+ expect(parsed.data).toMatchObject({
+ quoteStatus: "SENT",
+ invoiceStatus: "REQUESTED",
+ paymentStatus: "DEPOSIT_PAID",
+ calendarStatus: "ADDED",
+ depositAmountCents: 125_000,
+ googleCalendarEventId: "event-props-calendar-id",
+ });
+ });
+
+ it("rejects operational booking statuses outside the allowed values", () => {
+ const parsed = dealUpdateArgs.safeParse({
+ id: "deal_123",
+ data: {
+ quoteStatus: "APPROVED",
+ invoiceStatus: "PAID",
+ paymentStatus: "PARTIAL",
+ calendarStatus: "SKIPPED",
+ },
+ });
+
+ expect(parsed.success).toBe(false);
+ });
+});
diff --git a/apps/api/test/deals.spec.ts b/apps/api/test/deals.spec.ts
new file mode 100644
index 000000000..3bbe160fd
--- /dev/null
+++ b/apps/api/test/deals.spec.ts
@@ -0,0 +1,151 @@
+import { afterAll, beforeAll, describe, expect, it } from "bun:test";
+import { db } from "@crm/db";
+import type { AgentTriggerService } from "../src/agent/agent-trigger.service";
+import { ActivityStampService } from "../src/crm/activity-stamp.service";
+import { ConversionService } from "../src/currency/conversion.service";
+import { dealUpdateArgs } from "../src/deals/deals.contracts";
+import { DealsService } from "../src/deals/deals.service";
+import { FieldsService } from "../src/fields/fields.service";
+import { withDiscardedCrmEvents } from "./agent-trigger.stub";
+
+const suffix = process.env.TEST_RUN_ID ?? "deals-spec";
+const ownerId = `owner-${suffix}`;
+const domain = `deals-${suffix}.test`;
+
+const agent = {
+ withCrmEvents: withDiscardedCrmEvents,
+} as unknown as AgentTriggerService;
+
+const deals = new DealsService(
+ db,
+ agent,
+ new ActivityStampService(db),
+ new ConversionService(db),
+ new FieldsService(db, { fieldBackfill: async () => undefined } as never),
+);
+
+let companyId: string;
+let dealId: string;
+
+async function clean() {
+ await db.deal.deleteMany({ where: { company: { domain } } });
+ await db.company.deleteMany({ where: { domain } });
+ await db.user.deleteMany({ where: { id: ownerId } });
+}
+
+beforeAll(async () => {
+ await clean();
+
+ await db.user.create({
+ data: {
+ id: ownerId,
+ name: "Booking Rep",
+ email: `${ownerId}@example.test`,
+ emailVerified: true,
+ },
+ });
+
+ const company = await db.company.create({
+ data: { name: `Booking Co ${suffix}`, domain },
+ select: { id: true },
+ });
+ companyId = company.id;
+
+ const deal = await deals.create({
+ name: `Event booking ${suffix}`,
+ companyId,
+ ownerId,
+ amountCents: 250_000,
+ currency: "USD",
+ });
+ dealId = deal.id;
+});
+
+afterAll(clean);
+
+describe("deal booking status fields", () => {
+ it("starts existing deals on safe booking defaults", async () => {
+ const deal = await deals.byId(dealId);
+
+ expect(deal).toMatchObject({
+ quoteStatus: "NOT_READY",
+ invoiceStatus: "NOT_REQUESTED",
+ paymentStatus: "UNPAID",
+ calendarStatus: "NOT_ADDED",
+ googleCalendarEventId: null,
+ quoteSentAt: null,
+ invoiceRequestedAt: null,
+ invoiceSentAt: null,
+ depositPaidAt: null,
+ fullyPaidAt: null,
+ calendarAddedAt: null,
+ depositAmountCents: null,
+ balanceAmountCents: null,
+ });
+ });
+
+ it("updates booking fields through the existing deal update contract", async () => {
+ const quoteSentAt = "2026-08-28T09:10:11.000Z";
+ const invoiceRequestedAt = "2026-08-28T09:20:00.000Z";
+ const depositPaidAt = "2026-08-28T10:00:00.000Z";
+ const calendarAddedAt = "2026-08-28T10:30:00.000Z";
+
+ const input = dealUpdateArgs.parse({
+ id: dealId,
+ data: {
+ name: `Updated event booking ${suffix}`,
+ amountCents: 375_000,
+ currency: "USD",
+ quoteStatus: "SENT",
+ quoteSentAt,
+ invoiceStatus: "REQUESTED",
+ invoiceRequestedAt,
+ paymentStatus: "DEPOSIT_PAID",
+ depositPaidAt,
+ depositAmountCents: 125_000,
+ balanceAmountCents: 250_000,
+ calendarStatus: "ADDED",
+ calendarAddedAt,
+ googleCalendarEventId: "event-props-calendar-id",
+ },
+ });
+
+ await deals.update(input.id, input.data);
+
+ const deal = await deals.byId(dealId);
+
+ expect(deal).toMatchObject({
+ name: `Updated event booking ${suffix}`,
+ amountCents: 375_000,
+ currency: "USD",
+ quoteStatus: "SENT",
+ quoteSentAt,
+ invoiceStatus: "REQUESTED",
+ invoiceRequestedAt,
+ paymentStatus: "DEPOSIT_PAID",
+ depositPaidAt,
+ depositAmountCents: 125_000,
+ balanceAmountCents: 250_000,
+ calendarStatus: "ADDED",
+ calendarAddedAt,
+ googleCalendarEventId: "event-props-calendar-id",
+ });
+ });
+
+ it("lets calendar failure change independently from booked stage", async () => {
+ await deals.setStage({ id: dealId, stage: "CLOSED_WON" }, ownerId);
+
+ await deals.update(dealId, {
+ calendarStatus: "FAILED",
+ calendarAddedAt: null,
+ googleCalendarEventId: null,
+ });
+
+ const deal = await deals.byId(dealId);
+
+ expect(deal.stage).toBe("CLOSED_WON");
+ expect(deal.calendarStatus).toBe("FAILED");
+ expect(deal.calendarAddedAt).toBeNull();
+ expect(deal.googleCalendarEventId).toBeNull();
+ });
+});
diff --git a/apps/app/components/crm/record-sheet/deal-sheet.tsx b/apps/app/components/crm/record-sheet/deal-sheet.tsx
index 681a15ec6..432f55f98 100644
--- a/apps/app/components/crm/record-sheet/deal-sheet.tsx
+++ b/apps/app/components/crm/record-sheet/deal-sheet.tsx
@@ -120,6 +120,31 @@ const DATE_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
};
+const QUOTE_STATUS_LABELS = {
+ NOT_READY: "Not Ready",
+ READY: "Ready",
+ SENT: "Sent",
+ REJECTED: "Rejected",
+} as const;
+
+const INVOICE_STATUS_LABELS = {
+ NOT_REQUESTED: "Not Requested",
+ REQUESTED: "Requested",
+ SENT: "Sent",
+} as const;
+
+const PAYMENT_STATUS_LABELS = {
+ UNPAID: "Unpaid",
+ DEPOSIT_PAID: "Deposit Paid",
+ FULLY_PAID: "Fully Paid",
+} as const;
+
+const CALENDAR_STATUS_LABELS = {
+ NOT_ADDED: "Not Added",
+ ADDED: "Added",
+ FAILED: "Failed",
+} as const;
+
export function DealSheet({ dealId }: { dealId: string }) {
const trpc = useTRPC();
const openRecord = useOpenRecord();
@@ -292,6 +317,8 @@ function DealOverview({ deal }: { deal: Deal }) {
) : null}
+
+
}>
+
+
+
+
+
+
+ {deal.depositAmountCents === null ? (
+
+ ) : (
+
+ {formatMoney(deal.depositAmountCents, currency)}
+
+ )}
+
+
+ {deal.balanceAmountCents === null ? (
+
+ ) : (
+
+ {formatMoney(deal.balanceAmountCents, currency)}
+
+ )}
+
+ {deal.googleCalendarEventId ? (
+
+ {deal.googleCalendarEventId}
+
+ ) : null}
+
+
+ );
+}
+
+function BookingStatusProperty({
+ label,
+ value,
+ date,
+}: {
+ label: string;
+ value: string;
+ date: string | null;
+}) {
+ return (
+
+
+ {value}
+ {date ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+function statusLabel>(
+ labels: T,
+ value: string,
+): string {
+ return labels[value] ?? value;
+}
+
function WhereItStands({ deal }: { deal: Deal }) {
const openRecord = useOpenRecord();
diff --git a/apps/app/lib/deal-stage.ts b/apps/app/lib/deal-stage.ts
index 758f08f24..ce347cf47 100644
--- a/apps/app/lib/deal-stage.ts
+++ b/apps/app/lib/deal-stage.ts
@@ -17,12 +17,12 @@ type DealStagePresentation = Record<
>;
const PRESENTATION: DealStagePresentation = {
- DEMO_BOOKED: { label: "Demo booked", tone: "neutral" },
- QUALIFIED_TO_BUY: { label: "Qualified to buy", tone: "info" },
- DECISION_MAKER_BOUGHT_IN: { label: "Decision maker in", tone: "info" },
- CONTRACT_SENT: { label: "Contract sent", tone: "warning" },
- CLOSED_WON: { label: "Closed won", tone: "success" },
- CLOSED_LOST: { label: "Closed lost", tone: "error" },
+ DEMO_BOOKED: { label: "New Enquiry", tone: "neutral" },
+ QUALIFIED_TO_BUY: { label: "Pricing In Progress", tone: "info" },
+ DECISION_MAKER_BOUGHT_IN: { label: "Quote Ready", tone: "info" },
+ CONTRACT_SENT: { label: "Quote Sent", tone: "warning" },
+ CLOSED_WON: { label: "Booked", tone: "success" },
+ CLOSED_LOST: { label: "Lost / Cancelled", tone: "error" },
UNQUALIFIED_TO_BUY: { label: "Unqualified", tone: "neutral" },
};
diff --git a/packages/db/prisma/migrations/20260828120000_event_booking_statuses/migration.sql b/packages/db/prisma/migrations/20260828120000_event_booking_statuses/migration.sql
new file mode 100644
index 000000000..ed8824e56
--- /dev/null
+++ b/packages/db/prisma/migrations/20260828120000_event_booking_statuses/migration.sql
@@ -0,0 +1,22 @@
+CREATE TYPE "QuoteStatus" AS ENUM ('NOT_READY', 'READY', 'SENT', 'REJECTED');
+
+CREATE TYPE "InvoiceStatus" AS ENUM ('NOT_REQUESTED', 'REQUESTED', 'SENT');
+
+CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'DEPOSIT_PAID', 'FULLY_PAID');
+
+CREATE TYPE "CalendarStatus" AS ENUM ('NOT_ADDED', 'ADDED', 'FAILED');
+
+ALTER TABLE "deal"
+ ADD COLUMN "quoteStatus" "QuoteStatus" NOT NULL DEFAULT 'NOT_READY',
+ ADD COLUMN "invoiceStatus" "InvoiceStatus" NOT NULL DEFAULT 'NOT_REQUESTED',
+ ADD COLUMN "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
+ ADD COLUMN "calendarStatus" "CalendarStatus" NOT NULL DEFAULT 'NOT_ADDED',
+ ADD COLUMN "googleCalendarEventId" TEXT,
+ ADD COLUMN "quoteSentAt" TIMESTAMP(3),
+ ADD COLUMN "invoiceRequestedAt" TIMESTAMP(3),
+ ADD COLUMN "invoiceSentAt" TIMESTAMP(3),
+ ADD COLUMN "depositPaidAt" TIMESTAMP(3),
+ ADD COLUMN "fullyPaidAt" TIMESTAMP(3),
+ ADD COLUMN "calendarAddedAt" TIMESTAMP(3),
+ ADD COLUMN "depositAmount" DECIMAL(14,2),
+ ADD COLUMN "balanceAmount" DECIMAL(14,2);
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 82bc1f368..8f1338d3b 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -178,6 +178,31 @@ enum DealStage {
CLOSED_LOST
}
+enum QuoteStatus {
+ NOT_READY
+ READY
+ SENT
+ REJECTED
+}
+
+enum InvoiceStatus {
+ NOT_REQUESTED
+ REQUESTED
+ SENT
+}
+
+enum PaymentStatus {
+ UNPAID
+ DEPOSIT_PAID
+ FULLY_PAID
+}
+
+enum CalendarStatus {
+ NOT_ADDED
+ ADDED
+ FAILED
+}
+
enum ActivityType {
NOTE
CALL
@@ -931,6 +956,20 @@ model Deal {
closedAt DateTime?
closedReason String?
+ quoteStatus QuoteStatus @default(NOT_READY)
+ invoiceStatus InvoiceStatus @default(NOT_REQUESTED)
+ paymentStatus PaymentStatus @default(UNPAID)
+ calendarStatus CalendarStatus @default(NOT_ADDED)
+ googleCalendarEventId String?
+ quoteSentAt DateTime?
+ invoiceRequestedAt DateTime?
+ invoiceSentAt DateTime?
+ depositPaidAt DateTime?
+ fullyPaidAt DateTime?
+ calendarAddedAt DateTime?
+ depositAmount Decimal? @db.Decimal(14, 2)
+ balanceAmount Decimal? @db.Decimal(14, 2)
+
baseAmount Decimal? @db.Decimal(24, 4)
baseCurrency String?
fxRate Decimal? @db.Decimal(20, 10)