Skip to content

Commit f99a717

Browse files
authored
Merge pull request #2704 from trycompai/lewis/comp-background-checks
[dev] [carhartlewis] lewis/comp-background-checks
2 parents bae58e9 + 6d56a2d commit f99a717

73 files changed

Lines changed: 7101 additions & 990 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/.env.example

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
BASE_URL="http://localhost:3333"
22
BETTER_AUTH_URL="http://localhost:3000"
3+
NEXT_PUBLIC_APP_URL="http://localhost:3000"
4+
NODE_EXTRA_CA_CERTS=/etc/ssl/cert.pem
35
PORT="3333"
46

57
APP_AWS_BUCKET_NAME=
@@ -43,4 +45,11 @@ RESEND_API_KEY=
4345
RESEND_FROM_SYSTEM= # e.g., noreply@mail.trycomp.ai
4446
RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai
4547

46-
SECURITY_HUB_ROLE_ASSUMER_ARN=
48+
# Background checks
49+
BACKGROUND_CHECK_API_BASE_URL=https://glad-sturgeon-729.convex.site
50+
BACKGROUND_CHECK_API_KEY=
51+
BACKGROUND_CHECK_WEBHOOK_SECRET=
52+
BACKGROUND_WH_ENDPOINT=
53+
STRIPE_BACKGROUND_CHECK_PRICE_ID=price_1TRWckCkFWhKYvHIA1GLv1sO
54+
55+
SECURITY_HUB_ROLE_ASSUMER_ARN=

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { StripeModule } from './stripe/stripe.module';
5252
import { AdminOrganizationsModule } from './admin-organizations/admin-organizations.module';
5353
import { AdminFeatureFlagsModule } from './admin-feature-flags/admin-feature-flags.module';
5454
import { TimelinesModule } from './timelines/timelines.module';
55+
import { BackgroundChecksModule } from './background-checks/background-checks.module';
5556

5657
@Module({
5758
imports: [
@@ -113,6 +114,7 @@ import { TimelinesModule } from './timelines/timelines.module';
113114
SecretsModule,
114115
SecurityPenetrationTestsModule,
115116
StripeModule,
117+
BackgroundChecksModule,
116118
AdminOrganizationsModule,
117119
AdminFeatureFlagsModule,
118120
TimelinesModule,

apps/api/src/attachments/upload-attachment.dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class UploadAttachmentDto {
3535
})
3636
@IsString()
3737
@IsNotEmpty()
38+
@MaxLength(134_217_728)
3839
@IsBase64()
3940
fileData: string;
4041

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Body, Controller, Get, HttpCode, Post, UseGuards } from '@nestjs/common';
2+
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
3+
import { OrganizationId } from '../auth/auth-context.decorator';
4+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
5+
import { PermissionGuard } from '../auth/permission.guard';
6+
import { RequirePermission } from '../auth/require-permission.decorator';
7+
import { BackgroundCheckBillingService } from './background-check-billing.service';
8+
import {
9+
BackgroundCheckBillingPortalDto,
10+
BackgroundCheckSetupSessionDto,
11+
BackgroundCheckSetupSuccessDto,
12+
} from './dto/background-check-billing.dto';
13+
14+
@ApiTags('Background Check Billing')
15+
@Controller({ path: 'background-check-billing', version: '1' })
16+
@UseGuards(HybridAuthGuard, PermissionGuard)
17+
@ApiSecurity('apikey')
18+
export class BackgroundCheckBillingController {
19+
constructor(private readonly billingService: BackgroundCheckBillingService) {}
20+
21+
@Get('status')
22+
@RequirePermission('organization', 'read')
23+
@ApiOperation({ summary: 'Get background check billing status' })
24+
async getStatus(@OrganizationId() organizationId: string) {
25+
return this.billingService.getStatus(organizationId);
26+
}
27+
28+
@Post('setup-session')
29+
@RequirePermission('organization', 'update')
30+
@HttpCode(200)
31+
@ApiOperation({ summary: 'Create a Stripe setup session for background checks' })
32+
async setupSession(
33+
@OrganizationId() organizationId: string,
34+
@Body() body: BackgroundCheckSetupSessionDto,
35+
) {
36+
return this.billingService.createSetupSession({
37+
organizationId,
38+
successUrl: body.successUrl,
39+
cancelUrl: body.cancelUrl,
40+
});
41+
}
42+
43+
@Post('setup-success')
44+
@RequirePermission('organization', 'update')
45+
@HttpCode(200)
46+
@ApiOperation({ summary: 'Handle successful background check billing setup' })
47+
async setupSuccess(
48+
@OrganizationId() organizationId: string,
49+
@Body() body: BackgroundCheckSetupSuccessDto,
50+
) {
51+
return this.billingService.handleSetupSuccess({
52+
organizationId,
53+
sessionId: body.sessionId,
54+
});
55+
}
56+
57+
@Post('portal')
58+
@RequirePermission('organization', 'update')
59+
@HttpCode(200)
60+
@ApiOperation({ summary: 'Create a Stripe billing portal session' })
61+
async portal(
62+
@OrganizationId() organizationId: string,
63+
@Body() body: BackgroundCheckBillingPortalDto,
64+
) {
65+
return this.billingService.createBillingPortalSession({
66+
organizationId,
67+
returnUrl: body.returnUrl,
68+
});
69+
}
70+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import {
2+
BadRequestException,
3+
Injectable,
4+
NotFoundException,
5+
} from '@nestjs/common';
6+
import { db } from '@db';
7+
import { StripeService } from '../stripe/stripe.service';
8+
9+
@Injectable()
10+
export class BackgroundCheckBillingService {
11+
constructor(private readonly stripeService: StripeService) {}
12+
13+
async getStatus(organizationId: string): Promise<{
14+
hasBilling: boolean;
15+
hasPaymentMethod: boolean;
16+
setupAt: Date | null;
17+
}> {
18+
const billing = await db.organizationBilling.findUnique({
19+
where: { organizationId },
20+
select: {
21+
stripeCustomerId: true,
22+
stripeBackgroundCheckPaymentMethodId: true,
23+
backgroundCheckPaymentMethodSetupAt: true,
24+
},
25+
});
26+
27+
return {
28+
hasBilling: !!billing,
29+
hasPaymentMethod: !!billing?.stripeBackgroundCheckPaymentMethodId,
30+
setupAt: billing?.backgroundCheckPaymentMethodSetupAt ?? null,
31+
};
32+
}
33+
34+
async createSetupSession({
35+
organizationId,
36+
successUrl,
37+
cancelUrl,
38+
}: {
39+
organizationId: string;
40+
successUrl: string;
41+
cancelUrl: string;
42+
}): Promise<{ url: string }> {
43+
this.validateRedirectUrl(successUrl);
44+
this.validateRedirectUrl(cancelUrl);
45+
46+
const stripe = this.stripeService.getClient();
47+
const customerId = await this.findOrCreateCustomer(organizationId);
48+
const price = await this.getBackgroundCheckPrice();
49+
50+
const session = await stripe.checkout.sessions.create({
51+
mode: 'setup',
52+
customer: customerId,
53+
currency: price.currency,
54+
success_url: successUrl,
55+
cancel_url: cancelUrl,
56+
metadata: {
57+
organizationId,
58+
source: 'comp-background-check',
59+
},
60+
});
61+
62+
if (!session.url) {
63+
throw new BadRequestException('Failed to create Stripe Checkout session.');
64+
}
65+
66+
return { url: session.url };
67+
}
68+
69+
async handleSetupSuccess({
70+
organizationId,
71+
sessionId,
72+
}: {
73+
organizationId: string;
74+
sessionId: string;
75+
}): Promise<{ success: true }> {
76+
const stripe = this.stripeService.getClient();
77+
const session = await stripe.checkout.sessions.retrieve(sessionId, {
78+
expand: ['setup_intent'],
79+
});
80+
81+
if (session.status !== 'complete') {
82+
throw new BadRequestException('Checkout session is not complete.');
83+
}
84+
85+
if (session.metadata?.organizationId && session.metadata.organizationId !== organizationId) {
86+
throw new BadRequestException('Checkout session does not belong to this organization.');
87+
}
88+
89+
const stripeCustomerId = this.extractStripeId(session.customer);
90+
if (!stripeCustomerId) {
91+
throw new BadRequestException('Checkout session is missing a customer.');
92+
}
93+
94+
await this.assertCustomerBelongsToOrganization({
95+
organizationId,
96+
stripeCustomerId,
97+
});
98+
99+
const setupIntent = session.setup_intent;
100+
if (!setupIntent || typeof setupIntent === 'string') {
101+
throw new BadRequestException('Checkout session is missing a setup intent.');
102+
}
103+
104+
const paymentMethodId = this.extractStripeId(setupIntent.payment_method);
105+
if (!paymentMethodId) {
106+
throw new BadRequestException('Setup intent is missing a payment method.');
107+
}
108+
109+
await stripe.customers.update(stripeCustomerId, {
110+
invoice_settings: {
111+
default_payment_method: paymentMethodId,
112+
},
113+
});
114+
115+
await db.organizationBilling.upsert({
116+
where: { organizationId },
117+
create: {
118+
organizationId,
119+
stripeCustomerId,
120+
stripeBackgroundCheckPaymentMethodId: paymentMethodId,
121+
backgroundCheckPaymentMethodSetupAt: new Date(),
122+
},
123+
update: {
124+
stripeCustomerId,
125+
stripeBackgroundCheckPaymentMethodId: paymentMethodId,
126+
backgroundCheckPaymentMethodSetupAt: new Date(),
127+
},
128+
});
129+
130+
return { success: true };
131+
}
132+
133+
async createBillingPortalSession({
134+
organizationId,
135+
returnUrl,
136+
}: {
137+
organizationId: string;
138+
returnUrl: string;
139+
}): Promise<{ url: string }> {
140+
this.validateRedirectUrl(returnUrl);
141+
142+
const stripe = this.stripeService.getClient();
143+
const billing = await db.organizationBilling.findUnique({
144+
where: { organizationId },
145+
select: { stripeCustomerId: true },
146+
});
147+
148+
if (!billing) {
149+
throw new NotFoundException('No billing record found for this organization.');
150+
}
151+
152+
const portalSession = await stripe.billingPortal.sessions.create({
153+
customer: billing.stripeCustomerId,
154+
return_url: returnUrl,
155+
});
156+
157+
return { url: portalSession.url };
158+
}
159+
160+
async findOrCreateCustomer(organizationId: string): Promise<string> {
161+
const existingBilling = await db.organizationBilling.findUnique({
162+
where: { organizationId },
163+
select: { stripeCustomerId: true },
164+
});
165+
166+
if (existingBilling) {
167+
return existingBilling.stripeCustomerId;
168+
}
169+
170+
const organization = await db.organization.findUnique({
171+
where: { id: organizationId },
172+
select: { name: true },
173+
});
174+
175+
if (!organization) {
176+
throw new NotFoundException('Organization not found.');
177+
}
178+
179+
const stripe = this.stripeService.getClient();
180+
const customer = await stripe.customers.create({
181+
name: organization.name,
182+
metadata: { organizationId },
183+
});
184+
185+
await db.organizationBilling.create({
186+
data: {
187+
organizationId,
188+
stripeCustomerId: customer.id,
189+
},
190+
});
191+
192+
return customer.id;
193+
}
194+
195+
async getBackgroundCheckPrice(): Promise<{ id: string; unitAmount: number; currency: string }> {
196+
const priceId = process.env.STRIPE_BACKGROUND_CHECK_PRICE_ID;
197+
if (!priceId) {
198+
throw new BadRequestException('Background check pricing is not configured. Contact support.');
199+
}
200+
201+
const stripe = this.stripeService.getClient();
202+
const price = await stripe.prices.retrieve(priceId);
203+
if (price.unit_amount === null || price.unit_amount === undefined) {
204+
throw new BadRequestException('Background check pricing is not configured. Contact support.');
205+
}
206+
207+
return {
208+
id: price.id,
209+
unitAmount: price.unit_amount,
210+
currency: price.currency,
211+
};
212+
}
213+
214+
private validateRedirectUrl(url: string): void {
215+
const appUrl =
216+
process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || process.env.BETTER_AUTH_URL;
217+
if (!appUrl) {
218+
throw new BadRequestException('App URL is not configured on the server.');
219+
}
220+
221+
let parsed: URL;
222+
try {
223+
parsed = new URL(url);
224+
} catch {
225+
throw new BadRequestException('Invalid redirect URL.');
226+
}
227+
228+
if (parsed.origin !== new URL(appUrl).origin) {
229+
throw new BadRequestException('Redirect URL must belong to the application origin.');
230+
}
231+
}
232+
233+
private extractStripeId(value: string | { id?: string } | null): string | null {
234+
if (!value) return null;
235+
if (typeof value === 'string') return value;
236+
return value.id ?? null;
237+
}
238+
239+
private async assertCustomerBelongsToOrganization({
240+
organizationId,
241+
stripeCustomerId,
242+
}: {
243+
organizationId: string;
244+
stripeCustomerId: string;
245+
}): Promise<void> {
246+
const billing = await db.organizationBilling.findUnique({
247+
where: { organizationId },
248+
select: { stripeCustomerId: true },
249+
});
250+
251+
if (billing?.stripeCustomerId === stripeCustomerId) {
252+
return;
253+
}
254+
255+
const stripe = this.stripeService.getClient();
256+
const customer = await stripe.customers.retrieve(stripeCustomerId);
257+
if (customer.deleted || customer.metadata?.organizationId !== organizationId) {
258+
throw new BadRequestException('Checkout session does not belong to this organization.');
259+
}
260+
}
261+
}

0 commit comments

Comments
 (0)