Skip to content

Commit d607f4f

Browse files
authored
Merge pull request #2724 from trycompai/main
[comp] Production Deploy
2 parents de4d002 + f44380f commit d607f4f

21 files changed

Lines changed: 921 additions & 306 deletions

‎apps/api/.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai
4848
# Background checks
4949
BACKGROUND_CHECK_API_BASE_URL=https://glad-sturgeon-729.convex.site
5050
BACKGROUND_CHECK_API_KEY=
51+
MACED_API_KEY=mc_dev_dummy_api_key
5152
BACKGROUND_CHECK_WEBHOOK_SECRET=
5253
BACKGROUND_WH_ENDPOINT=
5354
STRIPE_BACKGROUND_CHECK_PRICE_ID=price_1TRWckCkFWhKYvHIA1GLv1sO

‎apps/api/src/background-checks/background-check-billing.service.ts‎

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
BadRequestException,
3-
Injectable,
4-
NotFoundException,
5-
} from '@nestjs/common';
1+
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
62
import { db } from '@db';
73
import { StripeService } from '../stripe/stripe.service';
84

@@ -14,20 +10,32 @@ export class BackgroundCheckBillingService {
1410
hasBilling: boolean;
1511
hasPaymentMethod: boolean;
1612
setupAt: Date | null;
13+
usage: {
14+
backgroundChecks: number;
15+
penetrationTests: number;
16+
};
1717
}> {
18-
const billing = await db.organizationBilling.findUnique({
19-
where: { organizationId },
20-
select: {
21-
stripeCustomerId: true,
22-
stripeBackgroundCheckPaymentMethodId: true,
23-
backgroundCheckPaymentMethodSetupAt: true,
24-
},
25-
});
18+
const [billing, backgroundChecks, penetrationTests] = await Promise.all([
19+
db.organizationBilling.findUnique({
20+
where: { organizationId },
21+
select: {
22+
stripeCustomerId: true,
23+
stripeBackgroundCheckPaymentMethodId: true,
24+
backgroundCheckPaymentMethodSetupAt: true,
25+
},
26+
}),
27+
db.backgroundCheckRequest.count({ where: { organizationId } }),
28+
db.securityPenetrationTestRun.count({ where: { organizationId } }),
29+
]);
2630

2731
return {
2832
hasBilling: !!billing,
2933
hasPaymentMethod: !!billing?.stripeBackgroundCheckPaymentMethodId,
3034
setupAt: billing?.backgroundCheckPaymentMethodSetupAt ?? null,
35+
usage: {
36+
backgroundChecks,
37+
penetrationTests,
38+
},
3139
};
3240
}
3341

@@ -60,9 +68,7 @@ export class BackgroundCheckBillingService {
6068
});
6169

6270
if (!session.url) {
63-
throw new BadRequestException(
64-
'Failed to create Stripe Checkout session.',
65-
);
71+
throw new BadRequestException('Failed to create Stripe Checkout session.');
6672
}
6773

6874
return { url: session.url };
@@ -112,9 +118,7 @@ export class BackgroundCheckBillingService {
112118

113119
const paymentMethodId = this.extractStripeId(setupIntent.payment_method);
114120
if (!paymentMethodId) {
115-
throw new BadRequestException(
116-
'Setup intent is missing a payment method.',
117-
);
121+
throw new BadRequestException('Setup intent is missing a payment method.');
118122
}
119123

120124
await stripe.customers.update(stripeCustomerId, {
@@ -157,9 +161,7 @@ export class BackgroundCheckBillingService {
157161
});
158162

159163
if (!billing) {
160-
throw new NotFoundException(
161-
'No billing record found for this organization.',
162-
);
164+
throw new NotFoundException('No billing record found for this organization.');
163165
}
164166

165167
const portalSession = await stripe.billingPortal.sessions.create({
@@ -249,15 +251,11 @@ export class BackgroundCheckBillingService {
249251
}
250252

251253
if (parsed.origin !== new URL(appUrl).origin) {
252-
throw new BadRequestException(
253-
'Redirect URL must belong to the application origin.',
254-
);
254+
throw new BadRequestException('Redirect URL must belong to the application origin.');
255255
}
256256
}
257257

258-
private extractStripeId(
259-
value: string | { id?: string } | null,
260-
): string | null {
258+
private extractStripeId(value: string | { id?: string } | null): string | null {
261259
if (!value) return null;
262260
if (typeof value === 'string') return value;
263261
return value.id ?? null;

‎apps/api/src/background-checks/background-check-payment.service.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe('BackgroundCheckPaymentService', () => {
7171
expect.objectContaining({
7272
amount: 1250,
7373
customer: 'cus_1',
74+
description: 'Comp AI - Background Check x1',
7475
payment_method: 'pm_1',
7576
}),
7677
{ idempotencyKey: 'background-check:org_1:mem_1:price_bg:pm_1' },

‎apps/api/src/background-checks/background-check-payment.service.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { BackgroundCheckBillingService } from './background-check-billing.servic
55

66
@Injectable()
77
export class BackgroundCheckPaymentService {
8+
private static readonly receiptDescription =
9+
'Comp AI - Background Check x1';
10+
811
private readonly logger = new Logger(BackgroundCheckPaymentService.name);
912

1013
constructor(
@@ -43,6 +46,7 @@ export class BackgroundCheckPaymentService {
4346
customer: billing.stripeCustomerId,
4447
amount: price.unitAmount,
4548
currency: price.currency,
49+
description: BackgroundCheckPaymentService.receiptDescription,
4650
payment_method: billing.stripeBackgroundCheckPaymentMethodId,
4751
off_session: true,
4852
confirm: true,

‎apps/api/src/background-checks/background-checks.service.spec.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jest.mock('@db', () => {
2727
backgroundCheckRequest: {
2828
findUnique: jest.fn(),
2929
findFirst: jest.fn(),
30+
count: jest.fn(),
3031
create: jest.fn(),
3132
upsert: jest.fn(),
3233
update: jest.fn(),
@@ -42,6 +43,9 @@ jest.mock('@db', () => {
4243
create: jest.fn(),
4344
upsert: jest.fn(),
4445
},
46+
securityPenetrationTestRun: {
47+
count: jest.fn(),
48+
},
4549
organization: {
4650
findUnique: jest.fn(),
4751
},
@@ -475,4 +479,37 @@ describe('background checks', () => {
475479
}),
476480
).resolves.toEqual({ url: 'https://checkout.stripe.com/c/session_1' });
477481
});
482+
483+
it('includes background check and penetration test usage in billing status', async () => {
484+
mockAsync<Awaited<ReturnType<typeof db.organizationBilling.findUnique>>>(
485+
mockedDb.organizationBilling.findUnique,
486+
).mockResolvedValueOnce({
487+
stripeCustomerId: 'cus_1',
488+
stripeBackgroundCheckPaymentMethodId: 'pm_1',
489+
backgroundCheckPaymentMethodSetupAt: new Date('2026-04-29T12:00:00.000Z'),
490+
} as Awaited<ReturnType<typeof db.organizationBilling.findUnique>>);
491+
mockAsync<number>(mockedDb.backgroundCheckRequest.count).mockResolvedValueOnce(4);
492+
mockAsync<number>(
493+
mockedDb.securityPenetrationTestRun.count,
494+
).mockResolvedValueOnce(2);
495+
496+
const service = new BackgroundCheckBillingService({
497+
getClient: jest.fn(),
498+
} as unknown as StripeService);
499+
500+
await expect(service.getStatus('org_1')).resolves.toMatchObject({
501+
hasBilling: true,
502+
hasPaymentMethod: true,
503+
usage: {
504+
backgroundChecks: 4,
505+
penetrationTests: 2,
506+
},
507+
});
508+
expect(mockedDb.backgroundCheckRequest.count).toHaveBeenCalledWith({
509+
where: { organizationId: 'org_1' },
510+
});
511+
expect(mockedDb.securityPenetrationTestRun.count).toHaveBeenCalledWith({
512+
where: { organizationId: 'org_1' },
513+
});
514+
});
478515
});

‎apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,58 @@ describe('applySync', () => {
339339
}));
340340
});
341341

342+
it('creates missing control row before reconciling RequirementMap drift when v1 and v2 both claim it', async () => {
343+
const tx = mockTx();
344+
tx.control.create.mockResolvedValue({
345+
id: 'ctl_created',
346+
controlTemplateId: 'ct_1',
347+
organizationId: 'org_1',
348+
name: 'C',
349+
description: 'D',
350+
archivedAt: null,
351+
});
352+
tx.control.findMany.mockResolvedValue([]);
353+
tx.requirementMap.findMany.mockResolvedValue([]);
354+
355+
const sameControl = { id: 'ct_1', name: 'C', description: 'D', requirementIds: ['rq_1'], policyIds: [], taskIds: [] };
356+
await applySync(tx, {
357+
instance: baseInstance as any,
358+
currentVersion: {
359+
id: 'fvr_v1',
360+
frameworkId: 'frk_soc2',
361+
manifest: manifest({
362+
requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }],
363+
controls: [sameControl],
364+
}),
365+
} as any,
366+
targetVersion: {
367+
id: 'fvr_v2',
368+
frameworkId: 'frk_soc2',
369+
manifest: manifest({
370+
requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }],
371+
controls: [sameControl],
372+
}),
373+
} as any,
374+
memberId: 'mem_1',
375+
});
376+
377+
expect(tx.control.create).toHaveBeenCalledWith(expect.objectContaining({
378+
data: expect.objectContaining({
379+
organizationId: 'org_1',
380+
controlTemplateId: 'ct_1',
381+
name: 'C',
382+
description: 'D',
383+
}),
384+
}));
385+
expect(tx.requirementMap.create).toHaveBeenCalledWith(expect.objectContaining({
386+
data: expect.objectContaining({
387+
controlId: 'ctl_created',
388+
requirementId: 'rq_1',
389+
frameworkInstanceId: 'frm_1',
390+
}),
391+
}));
392+
});
393+
342394
it('unarchives an existing archived RequirementMap row instead of creating a duplicate', async () => {
343395
const tx = mockTx();
344396
tx.control.findMany.mockResolvedValue([
@@ -414,6 +466,54 @@ describe('applySync', () => {
414466
expect(cpInsertCalled).toBe(true);
415467
});
416468

469+
it('creates missing task row before reconciling _ControlToTask drift when v1 and v2 both claim it', async () => {
470+
const tx = mockTx();
471+
tx.control.findMany.mockResolvedValue([
472+
{ id: 'ctl_1', controlTemplateId: 'ct_1', organizationId: 'org_1', name: 'C', description: 'D', archivedAt: null },
473+
]);
474+
tx.task.findMany.mockResolvedValue([]);
475+
tx.task.create.mockResolvedValue({
476+
id: 'tsk_created',
477+
taskTemplateId: 'tt_1',
478+
organizationId: 'org_1',
479+
title: 'T',
480+
description: 'D',
481+
frequency: null,
482+
department: null,
483+
archivedAt: null,
484+
});
485+
tx.$queryRaw.mockResolvedValue([]);
486+
487+
const sameControl = { id: 'ct_1', name: 'C', description: 'D', requirementIds: [], policyIds: [], taskIds: ['tt_1'] };
488+
const sameTask = { id: 'tt_1', name: 'T', description: 'D', frequency: null, department: null };
489+
await applySync(tx, {
490+
instance: baseInstance as any,
491+
currentVersion: {
492+
id: 'fvr_v1',
493+
frameworkId: 'frk_soc2',
494+
manifest: manifest({ controls: [sameControl], tasks: [sameTask] }),
495+
} as any,
496+
targetVersion: {
497+
id: 'fvr_v2',
498+
frameworkId: 'frk_soc2',
499+
manifest: manifest({ controls: [sameControl], tasks: [sameTask] }),
500+
} as any,
501+
memberId: 'mem_1',
502+
});
503+
504+
expect(tx.task.create).toHaveBeenCalledWith(expect.objectContaining({
505+
data: expect.objectContaining({
506+
organizationId: 'org_1',
507+
taskTemplateId: 'tt_1',
508+
title: 'T',
509+
description: 'D',
510+
}),
511+
}));
512+
const calls = tx.$executeRaw.mock.calls.map((c: unknown[]) => String(c[0]?.[0] ?? ''));
513+
const ctInsertCalled = calls.some((s: string) => s.includes('INSERT INTO "_ControlToTask"'));
514+
expect(ctInsertCalled).toBe(true);
515+
});
516+
417517
it('creates missing ControlDocumentType row when v1 and v2 both claim it but customer has no row (drift)', async () => {
418518
const tx = mockTx();
419519
tx.control.findMany.mockResolvedValue([

‎apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,17 @@ export async function applySync(
6767
};
6868

6969
// --- Controls ---
70-
for (const added of diff.controls.added) {
71-
if (ctlByTemplate.has(added.id)) continue;
70+
for (const targetControl of to.controls) {
71+
if (ctlByTemplate.has(targetControl.id)) continue;
7272
const created = await tx.control.create({
7373
data: {
7474
organizationId: ctx.instance.organizationId,
75-
controlTemplateId: added.id,
76-
name: added.name,
77-
description: added.description,
75+
controlTemplateId: targetControl.id,
76+
name: targetControl.name,
77+
description: targetControl.description,
7878
},
7979
});
80-
ctlByTemplate.set(added.id, created);
80+
ctlByTemplate.set(targetControl.id, created);
8181
undo.controls.created.push(created.id);
8282
summary.controlsAdded += 1;
8383
}
@@ -103,19 +103,19 @@ export async function applySync(
103103
}
104104

105105
// --- Tasks ---
106-
for (const added of diff.tasks.added) {
107-
if (taskByTemplate.has(added.id)) continue;
106+
for (const targetTask of to.tasks) {
107+
if (taskByTemplate.has(targetTask.id)) continue;
108108
const created = await tx.task.create({
109109
data: {
110110
organizationId: ctx.instance.organizationId,
111-
taskTemplateId: added.id,
112-
title: added.name,
113-
description: added.description,
114-
frequency: added.frequency as Frequency | null,
115-
department: added.department as Departments | null,
111+
taskTemplateId: targetTask.id,
112+
title: targetTask.name,
113+
description: targetTask.description,
114+
frequency: targetTask.frequency as Frequency | null,
115+
department: targetTask.department as Departments | null,
116116
},
117117
});
118-
taskByTemplate.set(added.id, created);
118+
taskByTemplate.set(targetTask.id, created);
119119
undo.tasks.created.push(created.id);
120120
summary.tasksAdded += 1;
121121
}

‎apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
Textarea,
1111
} from '@trycompai/design-system';
1212
import type { UseFormReturn } from 'react-hook-form';
13-
import { BillingCallout } from './BackgroundCheckWizardParts';
13+
import { BackgroundCheckSummary, BillingCallout } from './BackgroundCheckWizardParts';
1414
import type { BackgroundCheckFormValues } from './backgroundCheckForm';
1515

1616
export function BackgroundCheckDetailsForm({
@@ -37,6 +37,8 @@ export function BackgroundCheckDetailsForm({
3737
return (
3838
<form noValidate onSubmit={form.handleSubmit(onSubmit)}>
3939
<Stack gap="lg">
40+
<BackgroundCheckSummary />
41+
<div className="border-t" />
4042
{billingSetupComplete && (
4143
<BillingCallout
4244
title="Payment method saved"

0 commit comments

Comments
 (0)