Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f316c50
feat(billing): implement background check billing customer and invoic…
carhartlewis Apr 30, 2026
9acb95e
fix(billing): update layout of BillingInvoicesTable component
carhartlewis Apr 30, 2026
11efc56
feat(billing): add tests for background check billing customer and UR…
carhartlewis Apr 30, 2026
435c235
feat(billing): integrate billing module and enhance background check …
carhartlewis Apr 30, 2026
0258f83
Merge branch 'main' of github.com:trycompai/comp into lewis/comp-stri…
carhartlewis Apr 30, 2026
7b93705
Merge branch 'main' into lewis/comp-stripe-overhaul
carhartlewis Apr 30, 2026
7697765
fix(billing): harden stripe flows
carhartlewis Apr 30, 2026
f094e65
Merge remote-tracking branch 'origin/lewis/comp-stripe-overhaul' into…
carhartlewis Apr 30, 2026
f7e5e9f
feat(billing): enhance billing services and subscription management
carhartlewis Apr 30, 2026
d855b7b
chore(billing): update TypeScript configuration and import paths
carhartlewis Apr 30, 2026
50bcc7a
chore(billing): update import paths for SKU definitions
carhartlewis May 1, 2026
519bebb
feat(billing): add billing add-ons functionality and trial eligibility
carhartlewis May 1, 2026
c8cfb04
fix(billing): handle subscription edge cases
carhartlewis May 1, 2026
f655f37
fix(billing): preserve legacy background check drafts
carhartlewis May 1, 2026
be9027b
feat(billing): implement admin billing actions and controller
carhartlewis May 1, 2026
0bf465a
Merge branch 'main' into lewis/comp-stripe-overhaul
carhartlewis May 1, 2026
a3e5ebc
fix(billing): harden subscription and credit edge cases
carhartlewis May 1, 2026
9f8f5dc
Merge branch 'main' into lewis/comp-stripe-overhaul
tofikwest May 1, 2026
686eb0e
chore(billing): update package.json and build process
carhartlewis May 1, 2026
daf0eed
fix(billing): harden billing idempotency and redirects
carhartlewis May 1, 2026
78ce9c6
fix(billing): handle allowance edge cases
carhartlewis May 1, 2026
f984415
Merge branch 'main' into lewis/comp-stripe-overhaul
tofikwest May 1, 2026
1740e05
fix(billing): harden subscription sync retries
carhartlewis May 1, 2026
36022f1
chore: merge release v3.41.0 back to main [skip ci]
github-actions[bot] May 1, 2026
1a367dc
Merge remote-tracking branch 'origin/main' into lewis/comp-stripe-ove…
carhartlewis May 1, 2026
921cddb
chore(db): roll up billing migrations
carhartlewis May 1, 2026
3ab8c73
Merge branch 'main' into lewis/comp-stripe-overhaul
carhartlewis May 1, 2026
f3c1948
Merge pull request #2728 from trycompai/lewis/comp-stripe-overhaul
carhartlewis May 1, 2026
dbced05
chore(integrations-catalog): refresh 2026-05-01 round 4
tofikwest May 1, 2026
cc48e0f
Merge pull request #2737 from trycompai/feat/integrations-catalog-r4
tofikwest May 1, 2026
4db12d5
feat(billing): implement billing audit logging and improve event hand…
carhartlewis May 1, 2026
40180c8
Merge pull request #2738 from trycompai/lewis/comp-stripe-overhaul-fix
carhartlewis May 1, 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
59 changes: 59 additions & 0 deletions .agents/skills/billing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
name: billing
description: Use when changing Comp AI billing, Stripe products/prices, subscription checkout, org payment methods, entitlements, usage ledgers, invoices, or billing webhooks.
metadata:
short-description: Comp AI billing architecture
---

# Billing

Comp AI billing is SKU-first and subscription-ready. Stripe is the payment provider; Comp AI owns catalog definitions, entitlement state, usage gating, and audit history.

## Core Rules

- Use `@trycompai/billing` for SKU keys, amounts, Stripe product IDs, Stripe price IDs, cadence, and included usage.
- Do not add product-specific nullable fields to `OrganizationBilling`.
- Keep org-level billing generic: `stripeCustomerId`, `stripePaymentMethodId`, and `paymentMethodUpdatedAt`.
- Store per-product state in generic per-SKU tables keyed by `skuKey`.
- Gate paid actions from local entitlement/usage state, not directly from Stripe object reads.
- Treat Stripe webhooks as eventually consistent, retryable, and possibly out of order.
- Make webhook and entitlement handling idempotent with Stripe event IDs, invoice IDs, subscription item IDs, and period start/end.
- Archive accidental live/test Stripe objects instead of reusing them blindly.

## Current SKU Shape

- Active background-check subscriptions: `background_checks_monthly_3` ($79/mo, 3 checks), `background_checks_monthly_10` ($199/mo, 10 checks), and `background_checks_monthly_20` ($399/mo, 20 checks).
- Active penetration-test subscriptions: `pentest_monthly_1` ($299/mo, 1 scan), `pentest_monthly_3` ($499/mo, 3 scans), and `pentest_monthly_5_current` ($899/mo, 5 scans).
- Deprecated / legacy SKUs remain in the catalog for historical Stripe records: `background_check_one_time`, `background_checks_monthly_25`, `pentest_monthly_4`, `pentest_monthly_5`, and `pentest_monthly_10`.

Live catalog entries should only be added after deliberate live Stripe object creation.

## Implementation Pattern

1. Add or update SKU definitions and Stripe IDs in `packages/billing/src/index.ts` and `packages/billing/src/sku-definitions.ts`.
2. Store Stripe IDs in the catalog by environment rather than adding new env vars.
3. Create subscriptions through Stripe Checkout `mode: subscription`.
4. Use the shared Stripe customer default payment method unless the product explicitly needs overrides.
5. For allowance products, sync local subscription state from Stripe subscription items and period timestamps.
6. Record usage in the billing usage ledger with a stable idempotency key.
7. Record support-relevant mutations in billing audit events.

## Webhooks

Handle these events before launching subscription access:

- `checkout.session.completed`
- `invoice.paid`
- `invoice.payment_failed`
- `invoice.payment_action_required`
- `customer.subscription.updated`
- `customer.subscription.deleted`

Provision or renew allowance only for the matching subscription item and SKU. Do not use generic invoice-level periods for multi-item subscriptions.

## Validation

- Run `bun run db:generate` after Prisma schema changes.
- Run `bun run check:prisma-schemas` to catch stale copied schema fragments.
- Run catalog tests after SKU changes.
- Add webhook tests for duplicate events, out-of-order delivery, payment failure, action required, cancellation, and renewal.
6 changes: 5 additions & 1 deletion apps/api/Dockerfile.multistage
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ COPY packages/integration-platform/package.json ./packages/integration-platform/
COPY packages/tsconfig/package.json ./packages/tsconfig/
COPY packages/email/package.json ./packages/email/
COPY packages/company/package.json ./packages/company/
COPY packages/billing/package.json ./packages/billing/

# Copy API package.json
COPY apps/api/package.json ./apps/api/
Expand Down Expand Up @@ -55,6 +56,7 @@ COPY packages/integration-platform ./packages/integration-platform
COPY packages/tsconfig ./packages/tsconfig
COPY packages/email ./packages/email
COPY packages/company ./packages/company
COPY packages/billing ./packages/billing

# Copy API source
COPY apps/api ./apps/api
Expand All @@ -66,7 +68,8 @@ RUN cd packages/db && bun run build
RUN cd packages/auth && bun run build \
&& cd ../integration-platform && bun run build \
&& cd ../email && bun run build \
&& cd ../company && bun run build
&& cd ../company && bun run build \
&& cd ../billing && bun run build

# Copy model files to api schema dir, then build NestJS app
# Note: @prisma/client is already generated by packages/db build (generate-prisma-client-js.js)
Expand Down Expand Up @@ -111,6 +114,7 @@ COPY --from=builder --chown=nestjs:nestjs /app/packages/integration-platform ./p
COPY --from=builder --chown=nestjs:nestjs /app/packages/tsconfig ./packages/tsconfig
COPY --from=builder --chown=nestjs:nestjs /app/packages/email ./packages/email
COPY --from=builder --chown=nestjs:nestjs /app/packages/company ./packages/company
COPY --from=builder --chown=nestjs:nestjs /app/packages/billing ./packages/billing

# Copy production node_modules (includes Prisma client already generated for linux/amd64)
COPY --from=builder --chown=nestjs:nestjs /app/node_modules ./node_modules
Expand Down
7 changes: 6 additions & 1 deletion apps/api/buildspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ phases:
- cd packages/db && bun run build && cd ../..
- cd packages/integration-platform && bun run build && cd ../..
- cd packages/company && bun run build && cd ../..
- cd packages/billing && bun run build && cd ../..

- echo "Building NestJS application..."
- echo "APP_NAME is set to $APP_NAME"
Expand Down Expand Up @@ -81,11 +82,13 @@ phases:
- rm -rf ../docker-build/node_modules/@trycompai/integration-platform
- rm -rf ../docker-build/node_modules/@trycompai/auth
- rm -rf ../docker-build/node_modules/@trycompai/company
- rm -rf ../docker-build/node_modules/@trycompai/billing
- mkdir -p ../docker-build/node_modules/@trycompai/utils
- mkdir -p ../docker-build/node_modules/@trycompai/db
- mkdir -p ../docker-build/node_modules/@trycompai/integration-platform
- mkdir -p ../docker-build/node_modules/@trycompai/auth
- mkdir -p ../docker-build/node_modules/@trycompai/company
- mkdir -p ../docker-build/node_modules/@trycompai/billing
- cp -r ../../packages/utils/src ../docker-build/node_modules/@trycompai/utils/
- cp ../../packages/utils/package.json ../docker-build/node_modules/@trycompai/utils/
- cp -r ../../packages/db/dist ../docker-build/node_modules/@trycompai/db/
Expand All @@ -96,10 +99,12 @@ phases:
- cp ../../packages/auth/package.json ../docker-build/node_modules/@trycompai/auth/
- cp -r ../../packages/company/dist ../docker-build/node_modules/@trycompai/company/
- cp ../../packages/company/package.json ../docker-build/node_modules/@trycompai/company/
- cp -r ../../packages/billing/dist ../docker-build/node_modules/@trycompai/billing/
- cp ../../packages/billing/package.json ../docker-build/node_modules/@trycompai/billing/

- cp Dockerfile ../docker-build/
# Remove workspace dependencies from package.json (they're copied manually above)
- cat package.json | jq 'del(.dependencies["@trycompai/integration-platform"]) | del(.dependencies["@trycompai/auth"]) | del(.dependencies["@trycompai/company"])' > ../docker-build/package.json
- cat package.json | jq 'del(.dependencies["@trycompai/integration-platform"]) | del(.dependencies["@trycompai/auth"]) | del(.dependencies["@trycompai/company"]) | del(.dependencies["@trycompai/billing"])' > ../docker-build/package.json
- cp ../../bun.lock ../docker-build/ || true

- echo "Building Docker image..."
Expand Down
5 changes: 4 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@trigger.dev/build": "4.4.3",
"@trigger.dev/sdk": "4.4.3",
"@trycompai/auth": "workspace:*",
"@trycompai/billing": "workspace:*",
"@trycompai/company": "workspace:*",
"@trycompai/db": "workspace:*",
"@trycompai/email": "workspace:*",
Expand Down Expand Up @@ -169,7 +170,9 @@
"moduleNameMapper": {
"^@db$": "<rootDir>/../prisma/index",
"^@/(.*)$": "<rootDir>/$1",
"^\\./sku-definitions\\.js$": "<rootDir>/../../../packages/billing/src/sku-definitions.ts",
"^@trycompai/auth$": "<rootDir>/../../../packages/auth/src/index.ts",
"^@trycompai/billing$": "<rootDir>/../../../packages/billing/src/index.ts",
"^@trycompai/company$": "<rootDir>/../../../packages/company/src/index.ts",
"^@trycompai/db$": "@prisma/client",
"^@trycompai/email$": "<rootDir>/../../../packages/email/index.ts",
Expand All @@ -183,7 +186,7 @@
"build": "nest build",
"build:docker": "bunx prisma generate --schema=prisma/schema && nest build",
"db:generate": "bun run db:getschema && bunx prisma generate --schema=prisma/schema",
"db:getschema": "find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;",
"db:getschema": "find prisma/schema -name '*.prisma' ! -name 'schema.prisma' -delete && find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;",
"db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api",
"deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy",
"dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"",
Expand Down
81 changes: 81 additions & 0 deletions apps/api/src/admin-organizations/admin-audit-log-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { of } from 'rxjs';
import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor';

const mockCreate = jest.fn().mockResolvedValue({});
const mockContextFind = jest.fn();

jest.mock('@db', () => ({
AuditLogEntityType: {
organization: 'organization',
finding: 'finding',
policy: 'policy',
task: 'task',
vendor: 'vendor',
},
Prisma: {},
db: {
auditLog: {
get create() {
return mockCreate;
},
},
context: {
get findFirst() {
return mockContextFind;
},
},
},
}));

jest.mock('../audit/audit-log.constants', () => ({
MUTATION_METHODS: new Set(['POST', 'PATCH', 'PUT', 'DELETE']),
SENSITIVE_KEYS: new Set<string>(),
}));

describe('AdminAuditLogInterceptor context parsing', () => {
beforeEach(() => {
jest.clearAllMocks();
mockContextFind.mockResolvedValue({
question: 'Which subprocessors are used for production data?',
});
});

it('keeps context entity ids instead of treating them as org-level actions', (done) => {
const request = {
method: 'PATCH',
url: '/v1/admin/organizations/org_1/context/ctx_1',
params: { orgId: 'org_1' },
body: { answer: 'Updated answer' },
userId: 'usr_admin',
};
const context = {
getHandler: () => context,
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as Parameters<AdminAuditLogInterceptor['intercept']>[0];
const interceptor = new AdminAuditLogInterceptor({
get: jest.fn().mockReturnValue(false),
} as never);

interceptor
.intercept(context, { handle: () => of({ ok: true }) })
.subscribe({
complete: () => {
setTimeout(() => {
expect(mockContextFind).toHaveBeenCalledWith({
where: { id: 'ctx_1', organizationId: 'org_1' },
select: { question: true },
});
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
entityType: 'organization',
entityId: 'ctx_1',
description:
"Updated context 'Which subprocessors are used for production data?'",
}),
});
done();
}, 50);
},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jest.mock('@db', () => ({
return mockPolicyFind;
},
},
taskItem: {
task: {
get findFirst() {
return mockTaskFind;
},
Expand Down Expand Up @@ -72,6 +72,7 @@ function buildContext(overrides: {
};

return {
getHandler: () => buildContext,
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as Parameters<AdminAuditLogInterceptor['intercept']>[0];
}
Expand Down Expand Up @@ -310,4 +311,29 @@ describe('AdminAuditLogInterceptor', () => {
},
});
});

it('should audit billing mutations against the organization id', (done) => {
const ctx = buildContext({
method: 'PUT',
url: '/v1/admin/organizations/org_1/billing/preferences',
params: { orgId: 'org_1' },
body: { billingEmail: 'accounts@example.com' },
});

interceptor.intercept(ctx, nextHandler).subscribe({
complete: () => {
setTimeout(() => {
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
organizationId: 'org_1',
entityType: 'organization',
entityId: 'org_1',
description: 'Updated billing',
}),
});
done();
}, 50);
},
});
});
});
12 changes: 12 additions & 0 deletions apps/api/src/admin-organizations/admin-audit-log.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const SEGMENT_TO_RESOURCE: Record<
entity: AuditLogEntityType.pentest,
singular: 'pentest credits',
},
billing: {
entity: AuditLogEntityType.organization,
singular: 'billing',
},
};

const SPECIAL_ACTION_DESCRIPTIONS: Record<string, string> = {
Expand Down Expand Up @@ -153,6 +157,14 @@ export class AdminAuditLogInterceptor implements NestInterceptor {
}

const mapped = SEGMENT_TO_RESOURCE[resourceSegment];
if (resourceSegment === 'billing' && mapped) {
return {
resource: mapped.singular,
entityType: mapped.entity,
entityId: orgId,
actionSegment: possibleEntityId ?? null,
};
}

return {
resource: mapped?.singular ?? resourceSegment,
Expand Down
Loading
Loading