Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 46 additions & 14 deletions apps/api/src/background-checks/background-check-billing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export class BackgroundCheckBillingService {
});

if (!session.url) {
throw new BadRequestException('Failed to create Stripe Checkout session.');
throw new BadRequestException(
'Failed to create Stripe Checkout session.',
);
}

return { url: session.url };
Expand All @@ -82,8 +84,13 @@ export class BackgroundCheckBillingService {
throw new BadRequestException('Checkout session is not complete.');
}

if (session.metadata?.organizationId && session.metadata.organizationId !== organizationId) {
throw new BadRequestException('Checkout session does not belong to this organization.');
if (
session.metadata?.organizationId &&
session.metadata.organizationId !== organizationId
) {
throw new BadRequestException(
'Checkout session does not belong to this organization.',
);
}

const stripeCustomerId = this.extractStripeId(session.customer);
Expand All @@ -98,12 +105,16 @@ export class BackgroundCheckBillingService {

const setupIntent = session.setup_intent;
if (!setupIntent || typeof setupIntent === 'string') {
throw new BadRequestException('Checkout session is missing a setup intent.');
throw new BadRequestException(
'Checkout session is missing a setup intent.',
);
}

const paymentMethodId = this.extractStripeId(setupIntent.payment_method);
if (!paymentMethodId) {
throw new BadRequestException('Setup intent is missing a payment method.');
throw new BadRequestException(
'Setup intent is missing a payment method.',
);
}

await stripe.customers.update(stripeCustomerId, {
Expand Down Expand Up @@ -146,7 +157,9 @@ export class BackgroundCheckBillingService {
});

if (!billing) {
throw new NotFoundException('No billing record found for this organization.');
throw new NotFoundException(
'No billing record found for this organization.',
);
}

const portalSession = await stripe.billingPortal.sessions.create({
Expand Down Expand Up @@ -192,16 +205,24 @@ export class BackgroundCheckBillingService {
return customer.id;
}

async getBackgroundCheckPrice(): Promise<{ id: string; unitAmount: number; currency: string }> {
async getBackgroundCheckPrice(): Promise<{
id: string;
unitAmount: number;
currency: string;
}> {
const priceId = process.env.STRIPE_BACKGROUND_CHECK_PRICE_ID;
if (!priceId) {
throw new BadRequestException('Background check pricing is not configured. Contact support.');
throw new BadRequestException(
'Background check pricing is not configured. Contact support.',
);
}

const stripe = this.stripeService.getClient();
const price = await stripe.prices.retrieve(priceId);
if (price.unit_amount === null || price.unit_amount === undefined) {
throw new BadRequestException('Background check pricing is not configured. Contact support.');
throw new BadRequestException(
'Background check pricing is not configured. Contact support.',
);
}

return {
Expand All @@ -213,7 +234,9 @@ export class BackgroundCheckBillingService {

private validateRedirectUrl(url: string): void {
const appUrl =
process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || process.env.BETTER_AUTH_URL;
process.env.NEXT_PUBLIC_APP_URL ||
process.env.APP_URL ||
process.env.BETTER_AUTH_URL;
if (!appUrl) {
throw new BadRequestException('App URL is not configured on the server.');
}
Expand All @@ -226,11 +249,15 @@ export class BackgroundCheckBillingService {
}

if (parsed.origin !== new URL(appUrl).origin) {
throw new BadRequestException('Redirect URL must belong to the application origin.');
throw new BadRequestException(
'Redirect URL must belong to the application origin.',
);
}
}

private extractStripeId(value: string | { id?: string } | null): string | null {
private extractStripeId(
value: string | { id?: string } | null,
): string | null {
if (!value) return null;
if (typeof value === 'string') return value;
return value.id ?? null;
Expand All @@ -254,8 +281,13 @@ export class BackgroundCheckBillingService {

const stripe = this.stripeService.getClient();
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted || customer.metadata?.organizationId !== organizationId) {
throw new BadRequestException('Checkout session does not belong to this organization.');
if (
customer.deleted ||
customer.metadata?.organizationId !== organizationId
) {
throw new BadRequestException(
'Checkout session does not belong to this organization.',
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export class BackgroundCheckPaymentService {
async charge(params: {
organizationId: string;
memberId: string;
}): Promise<{ paymentIntentId: string; status: string; amount: number; currency: string }> {
}): Promise<{
paymentIntentId: string;
status: string;
amount: number;
currency: string;
}> {
const billing = await db.organizationBilling.findUnique({
where: { organizationId: params.organizationId },
select: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { toast } from 'sonner';
import useSWR from 'swr';
import { BackgroundCheckReport } from './BackgroundCheckReport';
import {
type CustomBackgroundCheckAttachment,
type BackgroundCheckRecord,
type BackgroundCheckStatus,
type CustomBackgroundCheckAttachment,
isCompletedBackgroundCheck,
} from './backgroundCheckTypes';

Expand Down Expand Up @@ -48,17 +48,13 @@ export function BackgroundCheckStatusView({
CustomBackgroundCheckAttachment[],
Error,
readonly [string, string] | null
>(
customAttachmentsKey,
async ([endpoint, orgId]) => {
const response = await apiClient.get<CustomBackgroundCheckAttachment[]>(
endpoint,
orgId,
);
if (response.error) throw new Error(response.error);
return response.data ?? [];
},
);
>(customAttachmentsKey, async ([endpoint, orgId]) => {
const response = await apiClient.get<CustomBackgroundCheckAttachment[]>(endpoint, orgId);
if (response.error) {
throw new Error('Failed to load custom background check attachments');
}
return response.data ?? [];
});

const handleCopyCandidateLink = async () => {
if (!backgroundCheck.candidateUrl) return;
Expand Down Expand Up @@ -152,7 +148,7 @@ function CustomReportAttachments({
);

if (response.error || !response.data?.downloadUrl) {
toast.error(response.error ?? 'Failed to open background check');
toast.error('Failed to open background check');
return;
}

Expand All @@ -177,11 +173,7 @@ function CustomReportAttachments({
Uploaded {new Date(attachment.createdAt).toLocaleString()}
</Text>
</Stack>
<Button
type="button"
variant="outline"
onClick={() => handleDownload(attachment.id)}
>
<Button type="button" variant="outline" onClick={() => handleDownload(attachment.id)}>
Open
</Button>
</HStack>
Expand All @@ -192,11 +184,7 @@ function CustomReportAttachments({
);
}

function ComponentStatuses({
backgroundCheck,
}: {
backgroundCheck: BackgroundCheckRecord;
}) {
function ComponentStatuses({ backgroundCheck }: { backgroundCheck: BackgroundCheckRecord }) {
const statuses = COMPONENT_LABELS.flatMap(([key, label]) => {
const value = backgroundCheck[key];
return value ? [{ label, value }] : [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,16 @@ export function CustomBackgroundCheckUpload({
);

if (response.error || !response.data) {
toast.error(response.error ?? 'Failed to upload background check');
toast.error('Failed to upload background check');
return;
}

toast.success('Custom background check attached');
setSelectedFile(null);
if (inputRef.current) inputRef.current.value = '';
await onUploaded(response.data);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to upload background check');
} catch {
toast.error('Failed to upload background check');
} finally {
setIsUploading(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function renderSection(props?: Partial<Parameters<typeof EmployeeBackgroundCheck
describe('EmployeeBackgroundCheck', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(apiClient.get).mockReset();
vi.mocked(apiClient.post).mockReset();
window.sessionStorage.clear();
navigationMock.pathname = '/org_1/people/mem_1';
navigationMock.searchParams = new URLSearchParams();
Expand Down Expand Up @@ -188,9 +190,9 @@ describe('EmployeeBackgroundCheck', () => {
}),
'org_1',
);
expect(window.sessionStorage.getItem('background-check:org_1:mem_1:pending-request')).toContain(
'ada@example.com',
);
expect(
window.sessionStorage.getItem('background-check:org_1:mem_1:pending-request'),
).not.toContain('ada@example.com');
});

it('restores the pending check after Stripe setup before completing it', async () => {
Expand All @@ -204,8 +206,6 @@ describe('EmployeeBackgroundCheck', () => {
JSON.stringify({
organizationId: 'org_1',
memberId: 'mem_1',
employeeName: 'Ada Lovelace',
employeeEmail: 'ada@example.com',
requesterNotes: 'Recruiting requested an expedited check.',
}),
);
Expand Down Expand Up @@ -254,11 +254,15 @@ describe('EmployeeBackgroundCheck', () => {
'org_1',
);
expect(await screen.findByText('Payment method saved')).toBeInTheDocument();
expect(screen.getByDisplayValue('ada@example.com')).toBeInTheDocument();
expect(screen.getByLabelText('Personal email')).toHaveValue('');
expect(
screen.getByDisplayValue('Recruiting requested an expedited check.'),
).toBeInTheDocument();
expect(window.sessionStorage.getItem('background-check:org_1:mem_1:pending-request')).toContain(
'ada@example.com',
'Recruiting requested an expedited check.',
);

await user.type(screen.getByLabelText('Personal email'), 'ada@example.com');
await user.click(screen.getByRole('button', { name: /complete/i }));

await waitFor(() => {
Expand All @@ -280,7 +284,7 @@ describe('EmployeeBackgroundCheck', () => {
const user = userEvent.setup();
vi.mocked(apiClient.post)
.mockResolvedValueOnce({
error: 'Background check payment failed. Update billing and try again.',
error: 'Invalid API Key provided: PLACEHOLDER',
status: 402,
})
.mockResolvedValueOnce({ data: {}, status: 200 });
Expand All @@ -292,6 +296,10 @@ describe('EmployeeBackgroundCheck', () => {
expect(
await screen.findByRole('heading', { name: /update payment method/i }),
).toBeInTheDocument();
expect(screen.queryByText(/PLACEHOLDER/)).not.toBeInTheDocument();
expect(
screen.getByText('Payment failed. Update payment method and try again.'),
).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: /update payment method/i }));

Expand Down
Loading
Loading