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)