Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
52a57ed
Merge pull request #74 from trycompai/main
carhartlewis Aug 7, 2026
1561073
chore: release release
github-actions[bot] Aug 7, 2026
5074fc4
Merge pull request #75 from trycompai/release-please--branches--release
carhartlewis Aug 7, 2026
3c20d80
Merge pull request #77 from trycompai/main
carhartlewis Aug 7, 2026
808b835
Merge pull request #79 from trycompai/main
carhartlewis Aug 7, 2026
407280a
Merge pull request #81 from trycompai/main
carhartlewis Aug 7, 2026
c26a08d
Merge pull request #84 from trycompai/main
carhartlewis Aug 7, 2026
d585dc3
Merge pull request #90 from trycompai/main
carhartlewis Aug 8, 2026
d0299d9
Merge pull request #98 from trycompai/main
carhartlewis Aug 11, 2026
7d4a573
Merge pull request #107 from trycompai/main
carhartlewis Aug 11, 2026
56f4eeb
Merge pull request #116 from trycompai/main
github-actions[bot] Aug 11, 2026
57001e6
Merge pull request #119 from trycompai/main
github-actions[bot] Aug 11, 2026
ad1d702
Merge pull request #122 from trycompai/main
github-actions[bot] Aug 11, 2026
fc0c594
Merge pull request #127 from trycompai/main
github-actions[bot] Aug 11, 2026
4ffe150
Merge pull request #130 from trycompai/main
github-actions[bot] Aug 11, 2026
14cd220
Merge pull request #135 from trycompai/main
github-actions[bot] Aug 11, 2026
f2484fb
Merge pull request #141 from trycompai/main
github-actions[bot] Aug 12, 2026
bb63520
Merge pull request #161 from trycompai/main
github-actions[bot] Aug 18, 2026
517d859
Merge pull request #165 from trycompai/main
github-actions[bot] Aug 20, 2026
b842bd6
Merge pull request #168 from trycompai/main
github-actions[bot] Aug 20, 2026
77089e4
Merge pull request #172 from trycompai/main
github-actions[bot] Aug 20, 2026
6d4793d
Merge pull request #177 from trycompai/main
github-actions[bot] Aug 21, 2026
09eff59
feat: add event booking statuses to deals
z-mailme Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion apps/api/src/deals/deals.contracts.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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."),
Expand All @@ -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(),
});

Expand Down Expand Up @@ -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(),
Expand Down
73 changes: 73 additions & 0 deletions apps/api/src/deals/deals.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -226,9 +239,17 @@ export class DealsService {
const {
contacts,
amount,
depositAmount,
balanceAmount,
baseAmount,
fxRate,
fxRateAt,
quoteSentAt,
invoiceRequestedAt,
invoiceSentAt,
depositPaidAt,
fullyPaidAt,
calendarAddedAt,
archivedAt,
...rest
} = deal;
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}
44 changes: 44 additions & 0 deletions apps/api/test/deals-contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
151 changes: 151 additions & 0 deletions apps/api/test/deals.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading