diff --git a/apps/api/.env.example b/apps/api/.env.example index 42ca4371fe..b153ce2c1b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,7 @@ RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai # Background checks BACKGROUND_CHECK_API_BASE_URL=https://glad-sturgeon-729.convex.site BACKGROUND_CHECK_API_KEY= +MACED_API_KEY=mc_dev_dummy_api_key BACKGROUND_CHECK_WEBHOOK_SECRET= BACKGROUND_WH_ENDPOINT= STRIPE_BACKGROUND_CHECK_PRICE_ID=price_1TRWckCkFWhKYvHIA1GLv1sO diff --git a/apps/api/src/background-checks/background-check-billing.service.ts b/apps/api/src/background-checks/background-check-billing.service.ts index 3d2c5f9ca6..21a1b8cd63 100644 --- a/apps/api/src/background-checks/background-check-billing.service.ts +++ b/apps/api/src/background-checks/background-check-billing.service.ts @@ -1,8 +1,4 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { db } from '@db'; import { StripeService } from '../stripe/stripe.service'; @@ -14,20 +10,32 @@ export class BackgroundCheckBillingService { hasBilling: boolean; hasPaymentMethod: boolean; setupAt: Date | null; + usage: { + backgroundChecks: number; + penetrationTests: number; + }; }> { - const billing = await db.organizationBilling.findUnique({ - where: { organizationId }, - select: { - stripeCustomerId: true, - stripeBackgroundCheckPaymentMethodId: true, - backgroundCheckPaymentMethodSetupAt: true, - }, - }); + const [billing, backgroundChecks, penetrationTests] = await Promise.all([ + db.organizationBilling.findUnique({ + where: { organizationId }, + select: { + stripeCustomerId: true, + stripeBackgroundCheckPaymentMethodId: true, + backgroundCheckPaymentMethodSetupAt: true, + }, + }), + db.backgroundCheckRequest.count({ where: { organizationId } }), + db.securityPenetrationTestRun.count({ where: { organizationId } }), + ]); return { hasBilling: !!billing, hasPaymentMethod: !!billing?.stripeBackgroundCheckPaymentMethodId, setupAt: billing?.backgroundCheckPaymentMethodSetupAt ?? null, + usage: { + backgroundChecks, + penetrationTests, + }, }; } @@ -60,9 +68,7 @@ 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 }; @@ -112,9 +118,7 @@ export class BackgroundCheckBillingService { 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, { @@ -157,9 +161,7 @@ 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({ @@ -249,15 +251,11 @@ 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; diff --git a/apps/api/src/background-checks/background-check-payment.service.spec.ts b/apps/api/src/background-checks/background-check-payment.service.spec.ts index 35f8fd3e75..b794294310 100644 --- a/apps/api/src/background-checks/background-check-payment.service.spec.ts +++ b/apps/api/src/background-checks/background-check-payment.service.spec.ts @@ -71,6 +71,7 @@ describe('BackgroundCheckPaymentService', () => { expect.objectContaining({ amount: 1250, customer: 'cus_1', + description: 'Comp AI - Background Check x1', payment_method: 'pm_1', }), { idempotencyKey: 'background-check:org_1:mem_1:price_bg:pm_1' }, diff --git a/apps/api/src/background-checks/background-check-payment.service.ts b/apps/api/src/background-checks/background-check-payment.service.ts index 950fb8c3df..17e0dc4501 100644 --- a/apps/api/src/background-checks/background-check-payment.service.ts +++ b/apps/api/src/background-checks/background-check-payment.service.ts @@ -5,6 +5,9 @@ import { BackgroundCheckBillingService } from './background-check-billing.servic @Injectable() export class BackgroundCheckPaymentService { + private static readonly receiptDescription = + 'Comp AI - Background Check x1'; + private readonly logger = new Logger(BackgroundCheckPaymentService.name); constructor( @@ -43,6 +46,7 @@ export class BackgroundCheckPaymentService { customer: billing.stripeCustomerId, amount: price.unitAmount, currency: price.currency, + description: BackgroundCheckPaymentService.receiptDescription, payment_method: billing.stripeBackgroundCheckPaymentMethodId, off_session: true, confirm: true, diff --git a/apps/api/src/background-checks/background-checks.service.spec.ts b/apps/api/src/background-checks/background-checks.service.spec.ts index 5cf3cff021..17dfcaa0e4 100644 --- a/apps/api/src/background-checks/background-checks.service.spec.ts +++ b/apps/api/src/background-checks/background-checks.service.spec.ts @@ -27,6 +27,7 @@ jest.mock('@db', () => { backgroundCheckRequest: { findUnique: jest.fn(), findFirst: jest.fn(), + count: jest.fn(), create: jest.fn(), upsert: jest.fn(), update: jest.fn(), @@ -42,6 +43,9 @@ jest.mock('@db', () => { create: jest.fn(), upsert: jest.fn(), }, + securityPenetrationTestRun: { + count: jest.fn(), + }, organization: { findUnique: jest.fn(), }, @@ -475,4 +479,37 @@ describe('background checks', () => { }), ).resolves.toEqual({ url: 'https://checkout.stripe.com/c/session_1' }); }); + + it('includes background check and penetration test usage in billing status', async () => { + mockAsync>>( + mockedDb.organizationBilling.findUnique, + ).mockResolvedValueOnce({ + stripeCustomerId: 'cus_1', + stripeBackgroundCheckPaymentMethodId: 'pm_1', + backgroundCheckPaymentMethodSetupAt: new Date('2026-04-29T12:00:00.000Z'), + } as Awaited>); + mockAsync(mockedDb.backgroundCheckRequest.count).mockResolvedValueOnce(4); + mockAsync( + mockedDb.securityPenetrationTestRun.count, + ).mockResolvedValueOnce(2); + + const service = new BackgroundCheckBillingService({ + getClient: jest.fn(), + } as unknown as StripeService); + + await expect(service.getStatus('org_1')).resolves.toMatchObject({ + hasBilling: true, + hasPaymentMethod: true, + usage: { + backgroundChecks: 4, + penetrationTests: 2, + }, + }); + expect(mockedDb.backgroundCheckRequest.count).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + }); + expect(mockedDb.securityPenetrationTestRun.count).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + }); + }); }); diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts index a2889fc5e8..5b5e359e76 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts @@ -339,6 +339,58 @@ describe('applySync', () => { })); }); + it('creates missing control row before reconciling RequirementMap drift when v1 and v2 both claim it', async () => { + const tx = mockTx(); + tx.control.create.mockResolvedValue({ + id: 'ctl_created', + controlTemplateId: 'ct_1', + organizationId: 'org_1', + name: 'C', + description: 'D', + archivedAt: null, + }); + tx.control.findMany.mockResolvedValue([]); + tx.requirementMap.findMany.mockResolvedValue([]); + + const sameControl = { id: 'ct_1', name: 'C', description: 'D', requirementIds: ['rq_1'], policyIds: [], taskIds: [] }; + await applySync(tx, { + instance: baseInstance as any, + currentVersion: { + id: 'fvr_v1', + frameworkId: 'frk_soc2', + manifest: manifest({ + requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }], + controls: [sameControl], + }), + } as any, + targetVersion: { + id: 'fvr_v2', + frameworkId: 'frk_soc2', + manifest: manifest({ + requirements: [{ id: 'rq_1', identifier: 'CC1', name: 'X', description: null }], + controls: [sameControl], + }), + } as any, + memberId: 'mem_1', + }); + + expect(tx.control.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + organizationId: 'org_1', + controlTemplateId: 'ct_1', + name: 'C', + description: 'D', + }), + })); + expect(tx.requirementMap.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + controlId: 'ctl_created', + requirementId: 'rq_1', + frameworkInstanceId: 'frm_1', + }), + })); + }); + it('unarchives an existing archived RequirementMap row instead of creating a duplicate', async () => { const tx = mockTx(); tx.control.findMany.mockResolvedValue([ @@ -414,6 +466,54 @@ describe('applySync', () => { expect(cpInsertCalled).toBe(true); }); + it('creates missing task row before reconciling _ControlToTask drift when v1 and v2 both claim it', async () => { + const tx = mockTx(); + tx.control.findMany.mockResolvedValue([ + { id: 'ctl_1', controlTemplateId: 'ct_1', organizationId: 'org_1', name: 'C', description: 'D', archivedAt: null }, + ]); + tx.task.findMany.mockResolvedValue([]); + tx.task.create.mockResolvedValue({ + id: 'tsk_created', + taskTemplateId: 'tt_1', + organizationId: 'org_1', + title: 'T', + description: 'D', + frequency: null, + department: null, + archivedAt: null, + }); + tx.$queryRaw.mockResolvedValue([]); + + const sameControl = { id: 'ct_1', name: 'C', description: 'D', requirementIds: [], policyIds: [], taskIds: ['tt_1'] }; + const sameTask = { id: 'tt_1', name: 'T', description: 'D', frequency: null, department: null }; + await applySync(tx, { + instance: baseInstance as any, + currentVersion: { + id: 'fvr_v1', + frameworkId: 'frk_soc2', + manifest: manifest({ controls: [sameControl], tasks: [sameTask] }), + } as any, + targetVersion: { + id: 'fvr_v2', + frameworkId: 'frk_soc2', + manifest: manifest({ controls: [sameControl], tasks: [sameTask] }), + } as any, + memberId: 'mem_1', + }); + + expect(tx.task.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + organizationId: 'org_1', + taskTemplateId: 'tt_1', + title: 'T', + description: 'D', + }), + })); + const calls = tx.$executeRaw.mock.calls.map((c: unknown[]) => String(c[0]?.[0] ?? '')); + const ctInsertCalled = calls.some((s: string) => s.includes('INSERT INTO "_ControlToTask"')); + expect(ctInsertCalled).toBe(true); + }); + it('creates missing ControlDocumentType row when v1 and v2 both claim it but customer has no row (drift)', async () => { const tx = mockTx(); tx.control.findMany.mockResolvedValue([ diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts index 652843a275..cf9f06b19e 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts @@ -67,17 +67,17 @@ export async function applySync( }; // --- Controls --- - for (const added of diff.controls.added) { - if (ctlByTemplate.has(added.id)) continue; + for (const targetControl of to.controls) { + if (ctlByTemplate.has(targetControl.id)) continue; const created = await tx.control.create({ data: { organizationId: ctx.instance.organizationId, - controlTemplateId: added.id, - name: added.name, - description: added.description, + controlTemplateId: targetControl.id, + name: targetControl.name, + description: targetControl.description, }, }); - ctlByTemplate.set(added.id, created); + ctlByTemplate.set(targetControl.id, created); undo.controls.created.push(created.id); summary.controlsAdded += 1; } @@ -103,19 +103,19 @@ export async function applySync( } // --- Tasks --- - for (const added of diff.tasks.added) { - if (taskByTemplate.has(added.id)) continue; + for (const targetTask of to.tasks) { + if (taskByTemplate.has(targetTask.id)) continue; const created = await tx.task.create({ data: { organizationId: ctx.instance.organizationId, - taskTemplateId: added.id, - title: added.name, - description: added.description, - frequency: added.frequency as Frequency | null, - department: added.department as Departments | null, + taskTemplateId: targetTask.id, + title: targetTask.name, + description: targetTask.description, + frequency: targetTask.frequency as Frequency | null, + department: targetTask.department as Departments | null, }, }); - taskByTemplate.set(added.id, created); + taskByTemplate.set(targetTask.id, created); undo.tasks.created.push(created.id); summary.tasksAdded += 1; } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx index 513f37194f..b1d6c8fefe 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx @@ -10,7 +10,7 @@ import { Textarea, } from '@trycompai/design-system'; import type { UseFormReturn } from 'react-hook-form'; -import { BillingCallout } from './BackgroundCheckWizardParts'; +import { BackgroundCheckSummary, BillingCallout } from './BackgroundCheckWizardParts'; import type { BackgroundCheckFormValues } from './backgroundCheckForm'; export function BackgroundCheckDetailsForm({ @@ -37,6 +37,8 @@ export function BackgroundCheckDetailsForm({ return (
+ +
{billingSetupComplete && ( void; + onOpenBilling: () => void; }) { return ( - - - - Streamline background checks now in Comp AI - +
+
+
+ +
+
+ + + + Launch price + + + $99{' '} + $49 per check + + + + The candidate receives a secure invite and Comp AI keeps the result attached to this + employee profile. + + {hasPaymentMethod ? ( + + ) : ( + + )} + {!hasPaymentMethod && ( + + You can also manage payment methods from{' '} + + Billing settings + + . + + )} + +
+
+
+ ); +} + +export function BackgroundCheckSummary() { + return ( + + +
+ + Employee Background Check + +
- Send an invite, collect candidate steps, and track verification status from the employee - profile. + Streamline employee background checks with Comp AI.
@@ -44,11 +105,6 @@ export function OverviewStep({
))}
-
- -
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index eba693cafe..d94ea6788c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -106,19 +106,26 @@ describe('EmployeeBackgroundCheck', () => { initialBillingStatus: { hasPaymentMethod: false, setupAt: null }, }); - expect(screen.getByText('Streamline background checks now in Comp AI')).toBeInTheDocument(); + expect(screen.getByText('Employee Background Check')).toBeInTheDocument(); + expect(screen.getByText('Required for Compliance')).toBeInTheDocument(); expect(screen.getByText('Full audited report / background check')).toBeInTheDocument(); - expect(screen.queryByText('Launch pricing')).not.toBeInTheDocument(); - expect(screen.queryByText('$49')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /get started/i })).toBeInTheDocument(); + expect( + screen.getByText('Streamline employee background checks with Comp AI.'), + ).toBeInTheDocument(); + expect(screen.getByText('$99')).toBeInTheDocument(); + expect(screen.getByText('$49 per check')).toBeInTheDocument(); + expect(screen.queryByText('$49', { selector: '[data-slot=\"badge\"]' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /set up billing/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /billing settings/i })).toHaveAttribute( + 'href', + '/org_1/settings/billing', + ); }); it('skips the overview when a payment method is already saved', () => { renderSection(); - expect( - screen.queryByText('Streamline background checks now in Comp AI'), - ).not.toBeInTheDocument(); + expect(screen.getByText('Employee Background Check')).toBeInTheDocument(); expect(screen.getByLabelText('Personal email')).toBeInTheDocument(); expect( screen.getByText('Your saved card will be charged $49 for this background check.'), @@ -157,9 +164,13 @@ describe('EmployeeBackgroundCheck', () => { 'org_1', ); }); + expect( + await screen.findByText(/an invitation has been sent to the employee/i), + ).toBeInTheDocument(); + expect(screen.getByText(/spam or junk folders/i)).toBeInTheDocument(); }); - it('starts billing setup from Complete when no payment method exists', async () => { + it('starts billing setup from the overview when no payment method exists', async () => { const user = userEvent.setup(); vi.mocked(apiClient.post).mockResolvedValueOnce({ data: {}, @@ -169,16 +180,14 @@ describe('EmployeeBackgroundCheck', () => { initialBillingStatus: { hasPaymentMethod: false, setupAt: null }, }); - await user.click(screen.getByRole('button', { name: /get started/i })); - await user.type(screen.getByLabelText('Personal email'), 'ada@example.com'); - await user.click(screen.getByRole('button', { name: /complete/i })); + await user.click(screen.getByRole('button', { name: /set up billing/i })); await waitFor(() => { expect(apiClient.post).toHaveBeenCalledWith( '/v1/background-check-billing/setup-session', expect.objectContaining({ successUrl: expect.stringContaining('background_check_billing=success'), - cancelUrl: expect.stringContaining('background_check_step=details'), + cancelUrl: 'http://localhost:3000/org_1/settings/billing', }), 'org_1', ); @@ -186,13 +195,13 @@ describe('EmployeeBackgroundCheck', () => { expect(apiClient.post).toHaveBeenCalledWith( '/v1/background-check-billing/setup-session', expect.objectContaining({ - successUrl: expect.stringContaining('/org_1/people/mem_1?'), + successUrl: expect.stringContaining('/org_1/settings/billing?'), }), 'org_1', ); expect( window.sessionStorage.getItem('background-check:org_1:mem_1:pending-request'), - ).not.toContain('ada@example.com'); + ).toBeNull(); }); it('restores the pending check after Stripe setup before completing it', async () => { diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index 9d0699554e..5caff177e8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -51,14 +51,10 @@ export function EmployeeBackgroundCheck({ const [requestConfirmation, setRequestConfirmation] = useState(null); const { hasPermission } = usePermissions(); - const { data: backgroundCheck, mutate: mutateBackgroundCheck } = - useSWR( + const { data: backgroundCheck, mutate: mutateBackgroundCheck } = useSWR( [`/v1/people/${employee.id}/background-check`, organizationId], async ([endpoint]) => { - const response = await apiClient.get( - endpoint, - organizationId, - ); + const response = await apiClient.get(endpoint, organizationId); if (response.error) throw new Error('Failed to load background check'); return response.data ?? null; }, @@ -101,20 +97,13 @@ export function EmployeeBackgroundCheck({ const writePendingRequest = useCallback( (values: BackgroundCheckFormValues) => { - writePendingBackgroundCheckRequest({ - organizationId, - memberId: employee.id, - values, - }); + writePendingBackgroundCheckRequest({ organizationId, memberId: employee.id, values }); }, [employee.id, organizationId], ); const clearPendingRequest = useCallback(() => { - clearPendingBackgroundCheckRequest({ - organizationId, - memberId: employee.id, - }); + clearPendingBackgroundCheckRequest({ organizationId, memberId: employee.id }); }, [employee.id, organizationId]); const requestBackgroundCheck = useCallback( @@ -142,7 +131,7 @@ export function EmployeeBackgroundCheck({ } setRequestConfirmation( - 'The saved payment method was charged and the candidate invite has been sent.', + 'An invitation has been sent to the employee. Ask them to check their inbox, including spam or junk folders.', ); toast.success('Background check invite sent'); await mutateBackgroundCheck(response.data, { revalidate: false }); @@ -179,10 +168,7 @@ export function EmployeeBackgroundCheck({ { revalidate: true }, ); - const pendingRequest = readPendingBackgroundCheckRequest({ - organizationId, - memberId: employee.id, - }); + const pendingRequest = readPendingBackgroundCheckRequest({ organizationId, memberId: employee.id }); if (!pendingRequest) { router.replace(pathname, { scroll: false }); return; @@ -193,8 +179,6 @@ export function EmployeeBackgroundCheck({ employeeEmail: form.getValues('employeeEmail') || '', requesterNotes: pendingRequest.requesterNotes ?? '', }); - setBillingSetupComplete(true); - router.replace(pathname, { scroll: false }); })(); }, [ @@ -212,16 +196,16 @@ export function EmployeeBackgroundCheck({ setIsOpeningBilling(true); if (values) writePendingRequest(values); - const backgroundCheckPath = `/${organizationId}/people/${employee.id}`; - const returnUrl = `${window.location.origin}${backgroundCheckPath}`; + const returnPath = hasPaymentMethod ? `/${organizationId}/people/${employee.id}` : `/${organizationId}/settings/billing`; + const returnUrl = `${window.location.origin}${returnPath}`; const endpoint = hasPaymentMethod ? '/v1/background-check-billing/portal' : '/v1/background-check-billing/setup-session'; const body = hasPaymentMethod ? { returnUrl } : { - successUrl: `${returnUrl}?background_check_billing=success&background_check_step=details&session_id={CHECKOUT_SESSION_ID}`, - cancelUrl: `${returnUrl}?background_check_step=details`, + successUrl: `${returnUrl}?background_check_billing=success&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: returnUrl, }; const response = await apiClient.post<{ url: string }>(endpoint, body, organizationId); @@ -257,7 +241,15 @@ export function EmployeeBackgroundCheck({ return ( <> {visibleWizardStep === 'overview' && ( - setWizardStep('details')} /> + setWizardStep('details')} + onOpenBilling={() => void handleOpenBilling()} + /> )} {visibleWizardStep === 'details' && ( {isDeactivated && canEdit && ( - + {isReactivating ? 'Reinstating...' : 'Reinstate Member'} )} {!isDeactivated && canEdit && ( - + Edit Roles @@ -359,7 +359,7 @@ export function MemberRow({ (member.fleetDmLabelId || (deviceStatus && deviceStatus !== 'not-installed')) && isCurrentUserOwner && ( { + onClick={() => { setDropdownOpen(false); setIsRemoveDeviceAlertOpen(true); }} @@ -371,7 +371,7 @@ export function MemberRow({ {!isDeactivated && canRemove && ( { + onClick={() => { setDropdownOpen(false); setIsRemoveAlertOpen(true); }} diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/BillingSettingsClient.test.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/BillingSettingsClient.test.tsx new file mode 100644 index 0000000000..b4aa714e9d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/BillingSettingsClient.test.tsx @@ -0,0 +1,150 @@ +import { apiClient } from '@/lib/api-client'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SWRConfig } from 'swr'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BillingSettingsClient } from './BillingSettingsClient'; + +const navigationMock = vi.hoisted(() => ({ + pathname: '/org_1/settings/billing', + replace: vi.fn(), + searchParams: new URLSearchParams(), +})); + +const permissionMock = vi.hoisted(() => ({ + canUpdateOrganization: true, +})); + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + hasPermission: (resource: string, action: string) => + resource === 'organization' && + action === 'update' && + permissionMock.canUpdateOrganization, + }), +})); + +vi.mock('@/lib/api-client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +vi.mock('next/navigation', () => ({ + usePathname: () => navigationMock.pathname, + useRouter: () => ({ replace: navigationMock.replace }), + useSearchParams: () => navigationMock.searchParams, +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +function renderBillingSettings({ + hasPaymentMethod = true, + backgroundChecks = 4, + penetrationTests = 2, +}: { + hasPaymentMethod?: boolean; + backgroundChecks?: number; + penetrationTests?: number; +} = {}) { + return render( + new Map() }}> + + , + ); +} + +describe('BillingSettingsClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.get).mockReset(); + vi.mocked(apiClient.post).mockReset(); + vi.mocked(apiClient.post).mockResolvedValue({ + data: { url: '#stripe-session' }, + status: 200, + }); + permissionMock.canUpdateOrganization = true; + navigationMock.searchParams = new URLSearchParams(); + }); + + it('opens the Stripe billing portal when a payment method is saved', async () => { + const user = userEvent.setup(); + renderBillingSettings({ hasPaymentMethod: true }); + + expect(screen.getByText('Billing set up')).toBeInTheDocument(); + expect(screen.getByText('Historical usage')).toBeInTheDocument(); + expect(screen.getByText('Penetration Tests')).toBeInTheDocument(); + expect(screen.getByText('Background Checks')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(screen.getByText(/update billing details, cards, and receipts/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /open stripe portal/i })); + + await waitFor(() => { + expect(apiClient.post).toHaveBeenCalledWith( + '/v1/background-check-billing/portal', + { returnUrl: 'http://localhost:3000/org_1/settings/billing' }, + 'org_1', + ); + }); + }); + + it('opens a Stripe setup session when no payment method is saved', async () => { + const user = userEvent.setup(); + renderBillingSettings({ hasPaymentMethod: false }); + + expect(screen.getByText('Payment method needed')).toBeInTheDocument(); + expect(screen.getByText(/background checks and penetration testing/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /add payment method/i })); + + await waitFor(() => { + expect(apiClient.post).toHaveBeenCalledWith( + '/v1/background-check-billing/setup-session', + { + successUrl: + 'http://localhost:3000/org_1/settings/billing?background_check_billing=success&session_id={CHECKOUT_SESSION_ID}', + cancelUrl: 'http://localhost:3000/org_1/settings/billing', + }, + 'org_1', + ); + }); + }); + + it('disables billing updates for read-only users', async () => { + const user = userEvent.setup(); + permissionMock.canUpdateOrganization = false; + renderBillingSettings({ hasPaymentMethod: true }); + + const button = screen.getByRole('button', { name: /open stripe portal/i }); + expect(button).toBeDisabled(); + await user.click(button); + + expect(apiClient.post).not.toHaveBeenCalled(); + expect( + screen.getByText('Ask an organization admin to update billing details.'), + ).toBeInTheDocument(); + }); + + it('falls back to zero usage for older billing status payloads', () => { + render( + new Map() }}> + + , + ); + + expect(screen.getAllByText('0')).toHaveLength(2); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/BillingSettingsClient.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/BillingSettingsClient.tsx new file mode 100644 index 0000000000..736481961a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/BillingSettingsClient.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { usePermissions } from '@/hooks/use-permissions'; +import { apiClient } from '@/lib/api-client'; +import { + Badge, + Button, + PageHeader, + PageLayout, + Section, + Stack, + Text, +} from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import type { BackgroundCheckBillingStatus } from './types'; + +interface BillingSettingsClientProps { + organizationId: string; + initialBillingStatus: BackgroundCheckBillingStatus; +} + +const defaultUsage = { + backgroundChecks: 0, + penetrationTests: 0, +}; + +export function BillingSettingsClient({ + organizationId, + initialBillingStatus, +}: BillingSettingsClientProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const handledSessionId = useRef(null); + const [isOpeningBilling, setIsOpeningBilling] = useState(false); + const { hasPermission } = usePermissions(); + const canManageBilling = hasPermission('organization', 'update'); + + const { data: billingStatus, mutate: mutateBillingStatus } = + useSWR( + ['/v1/background-check-billing/status', organizationId], + async ([endpoint]) => { + const response = await apiClient.get( + endpoint, + organizationId, + ); + if (response.error || !response.data) { + throw new Error('Failed to load billing status'); + } + return response.data; + }, + { + fallbackData: initialBillingStatus, + revalidateOnMount: false, + }, + ); + + const hasPaymentMethod = billingStatus?.hasPaymentMethod === true; + const usage = billingStatus?.usage ?? initialBillingStatus.usage ?? defaultUsage; + const statusLabel = hasPaymentMethod ? 'Billing set up' : 'Payment method needed'; + const actionLabel = hasPaymentMethod ? 'Open Stripe portal' : 'Add payment method'; + + useEffect(() => { + const sessionId = searchParams.get('session_id'); + const setupSucceeded = searchParams.get('background_check_billing') === 'success'; + if (!sessionId || !setupSucceeded || handledSessionId.current === sessionId) return; + + handledSessionId.current = sessionId; + void (async () => { + const setupResponse = await apiClient.post<{ success: true }>( + '/v1/background-check-billing/setup-success', + { sessionId }, + organizationId, + ); + + if (setupResponse.error) { + toast.error('Failed to save billing details'); + router.replace(pathname, { scroll: false }); + return; + } + + toast.success('Billing details saved'); + await mutateBillingStatus( + { + hasPaymentMethod: true, + setupAt: new Date().toISOString(), + usage, + }, + { revalidate: true }, + ); + router.replace(pathname, { scroll: false }); + })(); + }, [mutateBillingStatus, organizationId, pathname, router, searchParams]); + + const handleOpenBilling = async () => { + setIsOpeningBilling(true); + + const returnUrl = `${window.location.origin}/${organizationId}/settings/billing`; + const endpoint = hasPaymentMethod + ? '/v1/background-check-billing/portal' + : '/v1/background-check-billing/setup-session'; + const body = hasPaymentMethod + ? { returnUrl } + : { + successUrl: `${returnUrl}?background_check_billing=success&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: returnUrl, + }; + const response = await apiClient.post<{ url: string }>( + endpoint, + body, + organizationId, + ); + + if (response.data?.url) { + window.location.href = response.data.url; + return; + } + + toast.error('Failed to open Stripe billing'); + setIsOpeningBilling(false); + }; + + return ( + } + onClick={handleOpenBilling} + > + {actionLabel} + + } + /> + } + > +
+
+
+ + + Historical usage + + Completed paid services for this organization. + + +
+ + +
+
+
+
+
+
+ +
+ Payment method + + {statusLabel} + +
+ + {hasPaymentMethod + ? 'A payment method is connected. Open the Stripe portal to update billing details, cards, and receipts.' + : 'Add a payment method to use paid services such as background checks and penetration testing.'} + +
+
+
+ + Managed securely in Stripe + + {!canManageBilling && ( + + Ask an organization admin to update billing details. + + )} +
+
+
+
+
+
+ ); +} + +function UsageMetric({ label, value }: { label: string; value: number }) { + return ( +
+ + + {label} + + + {value.toLocaleString()} + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx index 2cb0c60194..b413e6151d 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx @@ -1,29 +1,32 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@trycompai/ui/card'; +import { serverApi } from '@/lib/api-server'; import type { Metadata } from 'next'; +import { BillingSettingsClient } from './BillingSettingsClient'; +import type { BackgroundCheckBillingStatus } from './types'; + +const emptyBillingStatus: BackgroundCheckBillingStatus = { + hasPaymentMethod: false, + setupAt: null, + usage: { + backgroundChecks: 0, + penetrationTests: 0, + }, +}; + +export default async function BillingPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + const response = await serverApi.get( + '/v1/background-check-billing/status', + ); -export default async function BillingPage() { return ( -
- - - Penetration Testing - - Every organization gets a free trial run. Paid plans are coming soon. - - - -

- See your remaining trial runs on the Penetration Tests page. -

-
-
-
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts b/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts new file mode 100644 index 0000000000..335e87c6f7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts @@ -0,0 +1,8 @@ +export interface BackgroundCheckBillingStatus { + hasPaymentMethod: boolean; + setupAt: string | null; + usage?: { + backgroundChecks: number; + penetrationTests: number; + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.test.tsx b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.test.tsx new file mode 100644 index 0000000000..6359083953 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SettingsSidebar } from './SettingsSidebar'; + +vi.mock('next/navigation', () => ({ + usePathname: () => '/org-1/settings', +})); + +vi.mock('@trycompai/design-system', () => ({ + AppShellNav: ({ children }: { children: React.ReactNode }) => , + AppShellNavItem: ({ children }: { children: React.ReactNode }) => {children}, +})); + +describe('SettingsSidebar', () => { + it('places Billing directly after General when visible', () => { + render(); + + const links = screen.getAllByRole('link').map((link) => link.textContent); + expect(links.slice(0, 3)).toEqual(['General', 'Billing', 'Context']); + }); + + it('hides Billing when the billing tab is disabled', () => { + render(); + + expect(screen.queryByRole('link', { name: 'Billing' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.tsx b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.tsx index 85b2346480..694bc0457c 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsSidebar.tsx @@ -22,6 +22,12 @@ export function SettingsSidebar({ orgId, showBrowserTab, showBillingTab }: Setti const items: SettingsNavItem[] = [ { id: 'general', label: 'General', path: `/${orgId}/settings` }, + { + id: 'billing', + label: 'Billing', + path: `/${orgId}/settings/billing`, + hidden: !showBillingTab, + }, { id: 'context', label: 'Context', path: `/${orgId}/settings/context-hub` }, { id: 'api', label: 'API Keys', path: `/${orgId}/settings/api-keys` }, { id: 'portal', label: 'Portal', path: `/${orgId}/settings/portal` }, @@ -34,12 +40,6 @@ export function SettingsSidebar({ orgId, showBrowserTab, showBillingTab }: Setti path: `/${orgId}/settings/browser-connection`, hidden: !showBrowserTab, }, - { - id: 'billing', - label: 'Billing', - path: `/${orgId}/settings/billing`, - hidden: !showBillingTab, - }, { id: 'user', label: 'User Settings', path: `/${orgId}/settings/user` }, ]; diff --git a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.test.tsx index 0c986f702e..72dbdfaf40 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.test.tsx @@ -106,4 +106,17 @@ describe('SettingsTabs permission gating', () => { expect(screen.getByTestId('child-content')).toBeInTheDocument(); expect(screen.queryByTestId('page-layout')).not.toBeInTheDocument(); }); + + it('lets billing render its own page layout for header actions', () => { + setMockPermissions(ADMIN_PERMISSIONS); + mockPathname = '/org-1/settings/billing'; + render( + +
billing content
+
, + ); + + expect(screen.getByTestId('billing-content')).toBeInTheDocument(); + expect(screen.queryByTestId('page-layout')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.tsx b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.tsx index 37eb5a3195..5769dbfda1 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/components/SettingsTabs.tsx @@ -17,7 +17,8 @@ export function SettingsTabs({ orgId, children }: SettingsTabsProps) { // Pages that handle their own PageLayout (with breadcrumbs) const hasOwnLayout = - pathname.match(new RegExp(`^/${orgId}/settings/roles/(?:new|[^/]+)$`)) !== null; + pathname.match(new RegExp(`^/${orgId}/settings/roles/(?:new|[^/]+)$`)) !== null || + pathname.startsWith(`/${orgId}/settings/billing`); if (hasOwnLayout) { return <>{children}; diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 87f9d7aa0c..da3c15884d 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -6145,6 +6145,46 @@ ] } }, + "/v1/policies/{id}/evidence-tasks": { + "get": { + "operationId": "PoliciesController_getPolicyEvidenceTasks_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Policy ID", + "schema": { + "example": "pol_abc123def456", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get tasks that serve as evidence for a policy, grouped by control", + "tags": [ + "Policies" + ] + } + }, "/v1/policies/{id}/regenerate": { "post": { "operationId": "PoliciesController_regeneratePolicy_v1", @@ -8962,6 +9002,37 @@ ] } }, + "/v1/tasks/{taskId}/policies": { + "get": { + "operationId": "TasksController_getTaskPolicies_v1", + "parameters": [ + { + "name": "taskId", + "required": true, + "in": "path", + "description": "Unique task identifier", + "schema": { + "example": "tsk_abc123def456", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get policies that reference a task via shared controls", + "tags": [ + "Tasks" + ] + } + }, "/v1/tasks/{taskId}/activity": { "get": { "description": "Retrieve audit log activity for a specific task with pagination", @@ -20680,10 +20751,10 @@ ] } }, - "/v1/security-penetration-tests/github/repos": { + "/v1/security-penetration-tests/{id}": { "get": { - "description": "Returns GitHub repositories accessible with the connected GitHub integration.", - "operationId": "SecurityPenetrationTestsController_listGithubRepos_v1", + "description": "Returns a penetration test run with progress metadata.", + "operationId": "SecurityPenetrationTestsController_getById_v1", "parameters": [ { "name": "X-Organization-Id", @@ -20693,11 +20764,22 @@ "schema": { "type": "string" } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "Repository list returned" + "description": "Penetration test returned" + }, + "404": { + "description": "Penetration test not found" } }, "security": [ @@ -20705,16 +20787,16 @@ "apikey": [] } ], - "summary": "List accessible GitHub repositories", + "summary": "Get penetration test status", "tags": [ "Security Penetration Tests" ] } }, - "/v1/security-penetration-tests/{id}": { + "/v1/security-penetration-tests/{id}/progress": { "get": { - "description": "Returns a penetration test run with progress metadata.", - "operationId": "SecurityPenetrationTestsController_getById_v1", + "description": "Returns detailed progress for an in-flight report run.", + "operationId": "SecurityPenetrationTestsController_getProgress_v1", "parameters": [ { "name": "X-Organization-Id", @@ -20736,10 +20818,46 @@ ], "responses": { "200": { - "description": "Penetration test returned" + "description": "Progress returned" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get penetration test progress", + "tags": [ + "Security Penetration Tests" + ] + } + }, + "/v1/security-penetration-tests/{id}/issues": { + "get": { + "description": "Returns the structured findings discovered during the run. Grows over time during a live scan as agents discover more issues.", + "operationId": "SecurityPenetrationTestsController_getIssues_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } }, - "404": { - "description": "Penetration test not found" + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Issues returned" } }, "security": [ @@ -20747,16 +20865,16 @@ "apikey": [] } ], - "summary": "Get penetration test status", + "summary": "Get penetration test issues", "tags": [ "Security Penetration Tests" ] } }, - "/v1/security-penetration-tests/{id}/progress": { + "/v1/security-penetration-tests/{id}/events": { "get": { - "description": "Returns detailed progress for an in-flight report run.", - "operationId": "SecurityPenetrationTestsController_getProgress_v1", + "description": "Returns the real-time agent activity log emitted during a run (tool calls, observations, etc.). Noisy — meant for activity feeds and debugging.", + "operationId": "SecurityPenetrationTestsController_getEvents_v1", "parameters": [ { "name": "X-Organization-Id", @@ -20778,7 +20896,7 @@ ], "responses": { "200": { - "description": "Progress returned" + "description": "Events returned" } }, "security": [ @@ -20786,7 +20904,7 @@ "apikey": [] } ], - "summary": "Get penetration test progress", + "summary": "Get penetration test agent events", "tags": [ "Security Penetration Tests" ] @@ -20833,7 +20951,7 @@ }, "/v1/security-penetration-tests/{id}/pdf": { "get": { - "description": "Returns the PDF version of a completed report.", + "description": "Returns the PDF version of a completed report. Streams the binary PDF via Maced SDK.", "operationId": "SecurityPenetrationTestsController_getPdf_v1", "parameters": [ { @@ -20872,7 +20990,7 @@ }, "/v1/security-penetration-tests/webhook": { "post": { - "description": "Receives callback payloads from the penetration test provider when a run is updated. Per-run webhook token validation is enforced when handshake state exists.", + "description": "Receives signed JSON events from Maced. Signature is verified against MACED_WEBHOOK_SIGNING_SECRET using the SDK's verifyMacedWebhook helper.", "operationId": "SecurityPenetrationTestsController_handleWebhook_v1", "parameters": [ { @@ -20885,26 +21003,10 @@ } }, { - "name": "webhookToken", - "required": false, - "in": "query", - "description": "Per-job webhook token used for handshake validation when callbacks are sent to Comp.", - "schema": {} - }, - { - "name": "X-Webhook-Token", - "in": "header", - "description": "Optional webhook token header. Query param webhookToken is also accepted.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "X-Webhook-Id", + "name": "X-Maced-Signature", "in": "header", - "description": "Optional provider event identifier used for idempotency detection.", - "required": false, + "description": "HMAC signature header set by Maced (format: t=...,v1=...)", + "required": true, "schema": { "type": "string" } @@ -20916,6 +21018,9 @@ }, "400": { "description": "Invalid webhook payload" + }, + "403": { + "description": "Invalid webhook signature" } }, "security": [ @@ -20929,118 +21034,34 @@ ] } }, - "/v1/pentest-billing/status": { + "/v1/pentest-credits/status": { "get": { - "operationId": "PentestBillingController_getStatus_v1", - "parameters": [], - "responses": { - "200": { - "description": "Subscription status returned" - } - }, - "summary": "Get pentest subscription status", - "tags": [ - "Pentest Billing" - ] - } - }, - "/v1/pentest-billing/subscribe": { - "post": { - "operationId": "PentestBillingController_subscribe_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubscribeDto" - } - } - } - }, - "responses": { - "200": { - "description": "Checkout URL returned" - } - }, - "summary": "Create a Stripe checkout session for pentest subscription", - "tags": [ - "Pentest Billing" - ] - } - }, - "/v1/pentest-billing/handle-success": { - "post": { - "operationId": "PentestBillingController_handleSuccess_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HandleSuccessDto" - } - } - } - }, - "responses": { - "200": { - "description": "Subscription activated" - } - }, - "summary": "Handle Stripe checkout success callback", - "tags": [ - "Pentest Billing" - ] - } - }, - "/v1/pentest-billing/portal": { - "post": { - "operationId": "PentestBillingController_portal_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PortalDto" - } + "description": "Current spendable balance plus lifetime granted/consumed totals.", + "operationId": "PentestCreditsController_getStatus_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Portal URL returned" + "description": "Credits status" } }, - "summary": "Create a Stripe billing portal session", - "tags": [ - "Pentest Billing" - ] - } - }, - "/v1/pentest-billing/charge": { - "post": { - "operationId": "PentestBillingController_charge_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChargeDto" - } - } - } - }, - "responses": { - "200": { - "description": "Charge result returned" + "security": [ + { + "apikey": [] } - }, - "summary": "Check and charge overage for a pentest run", + ], + "summary": "Get pentest credit status", "tags": [ - "Pentest Billing" + "Pentest Credits" ] } }, @@ -26084,23 +26105,11 @@ "description": "Repository URL containing the target application code", "example": "https://github.com/org/repo" }, - "githubToken": { - "type": "string", - "description": "GitHub token used for cloning private repositories" - }, - "configYaml": { - "type": "string", - "description": "Optional YAML configuration for the pentest run" - }, "pipelineTesting": { "type": "boolean", "description": "Whether to enable pipeline testing mode", "default": false }, - "workspace": { - "type": "string", - "description": "Workspace identifier used by the pentest engine" - }, "webhookUrl": { "type": "string", "description": "Optional webhook URL to notify when report generation completes" @@ -26115,22 +26124,6 @@ "targetUrl" ] }, - "SubscribeDto": { - "type": "object", - "properties": {} - }, - "HandleSuccessDto": { - "type": "object", - "properties": {} - }, - "PortalDto": { - "type": "object", - "properties": {} - }, - "ChargeDto": { - "type": "object", - "properties": {} - }, "RequestBackgroundCheckDto": { "type": "object", "properties": {}