From 92eb0d50a656ee2ff2e950c3351993aac0510797 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 14 Jul 2025 13:24:23 -0400 Subject: [PATCH 1/6] chore: remove stripe altogether from app --- .github/workflows/e2e-tests.yml | 6 - .github/workflows/quick-tests.yml | 2 - .github/workflows/test-quick.yml | 2 - apps/app/.env.example | 5 - apps/app/e2e/tests/split-onboarding.spec.ts | 285 ++++++++- apps/app/e2e/utils/auth-helpers.ts | 26 + apps/app/package.json | 1 - .../lib/create-stripe-customer.ts | 28 - .../src/actions/organization/lib/stripe.ts | 8 - .../src/actions/stripe/fetch-price-details.ts | 145 ----- apps/app/src/app/(app)/[orgId]/layout.tsx | 27 +- .../billing/cancel-subscription-dialog.tsx | 84 --- .../[orgId]/settings/billing/loading.tsx | 3 - .../(app)/[orgId]/settings/billing/page.tsx | 584 ------------------ .../src/app/(app)/[orgId]/settings/layout.tsx | 5 - .../actions/create-organization-minimal.ts | 15 - .../setup/actions/create-organization.ts | 15 - .../[orgId]/components/BookingDialog.tsx | 36 -- .../[orgId]/components/booking-step.tsx | 19 +- .../upgrade/[orgId]/hooks/use-checkout.ts | 120 ---- .../src/app/(app)/upgrade/[orgId]/page.tsx | 71 +-- .../(app)/upgrade/[orgId]/pricing-cards.tsx | 70 --- .../(app)/upgrade/[orgId]/types/pricing.ts | 41 -- .../upgrade/[orgId]/utils/pricing-helpers.ts | 67 -- .../app/api/auth/test-grant-access/route.ts | 60 ++ apps/app/src/app/api/auth/test-login/route.ts | 6 +- apps/app/src/app/api/stripe/README.md | 68 -- .../cancel-subscription.ts | 87 --- .../create-portal-session.ts | 70 --- .../generate-checkout-session.ts | 152 ----- .../src/app/api/stripe/getSubscriptionData.ts | 63 -- apps/app/src/app/api/stripe/repair/route.ts | 116 ---- .../resume-subscription.ts | 79 --- .../src/app/api/stripe/stripeDataToKv.type.ts | 30 - apps/app/src/app/api/stripe/success/route.ts | 70 --- .../app/api/stripe/sync-subscription/route.ts | 48 -- .../src/app/api/stripe/syncStripeDataToKv.ts | 209 ------- .../api/stripe/webhook/SLACK_NOTIFICATIONS.md | 64 -- apps/app/src/app/api/stripe/webhook/route.ts | 87 --- .../api/stripe/webhook/slack-notifications.ts | 246 -------- apps/app/src/components/header.tsx | 8 +- apps/app/src/context/subscription-context.tsx | 50 -- apps/app/src/env.mjs | 16 - apps/app/src/middleware.test.ts | 128 ++-- apps/app/src/middleware.ts | 51 +- .../migration.sql | 31 + packages/db/prisma/schema/organization.prisma | 34 +- turbo.json | 2 - 48 files changed, 509 insertions(+), 2931 deletions(-) delete mode 100644 apps/app/src/actions/organization/lib/create-stripe-customer.ts delete mode 100644 apps/app/src/actions/organization/lib/stripe.ts delete mode 100644 apps/app/src/actions/stripe/fetch-price-details.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/settings/billing/cancel-subscription-dialog.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/settings/billing/loading.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx delete mode 100644 apps/app/src/app/(app)/upgrade/[orgId]/components/BookingDialog.tsx delete mode 100644 apps/app/src/app/(app)/upgrade/[orgId]/hooks/use-checkout.ts delete mode 100644 apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx delete mode 100644 apps/app/src/app/(app)/upgrade/[orgId]/types/pricing.ts delete mode 100644 apps/app/src/app/(app)/upgrade/[orgId]/utils/pricing-helpers.ts create mode 100644 apps/app/src/app/api/auth/test-grant-access/route.ts delete mode 100644 apps/app/src/app/api/stripe/README.md delete mode 100644 apps/app/src/app/api/stripe/cancel-subscription/cancel-subscription.ts delete mode 100644 apps/app/src/app/api/stripe/create-portal-session/create-portal-session.ts delete mode 100644 apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts delete mode 100644 apps/app/src/app/api/stripe/getSubscriptionData.ts delete mode 100644 apps/app/src/app/api/stripe/repair/route.ts delete mode 100644 apps/app/src/app/api/stripe/resume-subscription/resume-subscription.ts delete mode 100644 apps/app/src/app/api/stripe/stripeDataToKv.type.ts delete mode 100644 apps/app/src/app/api/stripe/success/route.ts delete mode 100644 apps/app/src/app/api/stripe/sync-subscription/route.ts delete mode 100644 apps/app/src/app/api/stripe/syncStripeDataToKv.ts delete mode 100644 apps/app/src/app/api/stripe/webhook/SLACK_NOTIFICATIONS.md delete mode 100644 apps/app/src/app/api/stripe/webhook/route.ts delete mode 100644 apps/app/src/app/api/stripe/webhook/slack-notifications.ts delete mode 100644 apps/app/src/context/subscription-context.tsx create mode 100644 packages/db/prisma/migrations/20250714153009_remove_stripe_and_add_has_access/migration.sql diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 19bbe790f..91a0edb70 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -198,8 +198,6 @@ jobs: UPSTASH_REDIS_REST_TOKEN: dummy-token RESEND_API_KEY: dummy-resend-key TRIGGER_SECRET_KEY: dummy-trigger-key - STRIPE_SECRET_KEY: sk_test_dummy_key_for_ci_builds - STRIPE_WEBHOOK_SECRET: whsec_test_dummy_webhook_secret - name: Start server run: | @@ -282,8 +280,6 @@ jobs: UPSTASH_REDIS_REST_TOKEN: dummy-token RESEND_API_KEY: dummy-resend-key TRIGGER_SECRET_KEY: dummy-trigger-key - STRIPE_SECRET_KEY: sk_test_dummy_key_for_ci_builds - STRIPE_WEBHOOK_SECRET: whsec_test_dummy_webhook_secret - name: Run E2E tests run: | @@ -305,8 +301,6 @@ jobs: UPSTASH_REDIS_REST_TOKEN: dummy-token RESEND_API_KEY: dummy-resend-key TRIGGER_SECRET_KEY: dummy-trigger-key - STRIPE_SECRET_KEY: sk_test_dummy_key_for_ci_builds - STRIPE_WEBHOOK_SECRET: whsec_test_dummy_webhook_secret - name: Generate test summary if: always() diff --git a/.github/workflows/quick-tests.yml b/.github/workflows/quick-tests.yml index 1a329e39b..f22ab00bf 100644 --- a/.github/workflows/quick-tests.yml +++ b/.github/workflows/quick-tests.yml @@ -74,8 +74,6 @@ jobs: UPSTASH_REDIS_REST_TOKEN: dummy-token RESEND_API_KEY: dummy-resend-key TRIGGER_SECRET_KEY: dummy-trigger-key - STRIPE_SECRET_KEY: sk_test_dummy_key_for_ci_builds - STRIPE_WEBHOOK_SECRET: whsec_test_dummy_webhook_secret - name: Post test results to PR if: failure() && github.event_name == 'pull_request' diff --git a/.github/workflows/test-quick.yml b/.github/workflows/test-quick.yml index 174c24564..e96c8e947 100644 --- a/.github/workflows/test-quick.yml +++ b/.github/workflows/test-quick.yml @@ -94,5 +94,3 @@ jobs: UPSTASH_REDIS_REST_TOKEN: dummy-token RESEND_API_KEY: dummy-resend-key TRIGGER_SECRET_KEY: dummy-trigger-key - STRIPE_SECRET_KEY: sk_test_dummy_key_for_ci_builds - STRIPE_WEBHOOK_SECRET: whsec_test_dummy_webhook_secret diff --git a/apps/app/.env.example b/apps/app/.env.example index e376d3dec..3a17b5d3d 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -22,11 +22,6 @@ RESEND_API_KEY="" # Resend Dashboard -> API Keys UPSTASH_REDIS_REST_URL="" # Upstash Console -> Redis -> Create Database UPSTASH_REDIS_REST_TOKEN="" # Found in the same database details page -# Payment Processing -# Stripe (https://dashboard.stripe.com/apikeys) -STRIPE_SECRET_KEY="" # Stripe Dashboard -> Developers -> API keys -STRIPE_WEBHOOK_SECRET="" # Stripe Dashboard -> Developers -> Webhooks - # File Storage # Upload Thing (https://uploadthing.com/dashboard) UPLOADTHING_TOKEN="" # Upload Thing Dashboard -> API Keys diff --git a/apps/app/e2e/tests/split-onboarding.spec.ts b/apps/app/e2e/tests/split-onboarding.spec.ts index ba32b925a..f364e65ea 100644 --- a/apps/app/e2e/tests/split-onboarding.spec.ts +++ b/apps/app/e2e/tests/split-onboarding.spec.ts @@ -1,16 +1,26 @@ import { expect, test } from '@playwright/test'; -import { authenticateTestUser, clearAuth } from '../utils/auth-helpers'; +import { authenticateTestUser, clearAuth, grantAccess } from '../utils/auth-helpers'; import { generateTestData } from '../utils/helpers'; test.describe('Split Onboarding Flow', () => { + // New flow based on org.hasAccess column: + // + // 1. hasAccess = false: 3 setup steps → book a call → (after approval) 9 onboarding steps → product access + // 2. hasAccess = true, incomplete onboarding: 3 setup steps → 9 onboarding steps → product access + // 3. hasAccess = true, completed onboarding: redirect to app (skip setup/onboarding) + // + // Users with incomplete onboarding (even if hasAccess=true) are redirected from product pages to onboarding + test.beforeEach(async ({ page }) => { // Clear any existing auth state await clearAuth(page); }); - test('new user completes split onboarding: 3 steps → payment → 9 steps → product access', async ({ + test('new user without access: 3 steps → book call (blocked from onboarding/product)', async ({ page, }) => { + // Tests the flow for a new user who doesn't have access (hasAccess = false) + // They complete setup, see the book a call page, and are blocked from accessing onboarding/product const testData = generateTestData(); const website = `example${Date.now()}.com`; @@ -19,6 +29,7 @@ test.describe('Split Onboarding Flow', () => { email: testData.email, name: testData.userName, skipOrg: true, // Don't create org, user will go through setup + hasAccess: false, // This user doesn't have access initially }); // Navigate to setup @@ -55,22 +66,201 @@ test.describe('Split Onboarding Flow', () => { // Should redirect to upgrade page await expect(page).toHaveURL(/\/upgrade\/org_/); - // Mock successful payment by navigating to Stripe success URL + // Extract orgId from URL const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); expect(orgIdMatch).toBeTruthy(); const orgId = orgIdMatch![0]; - // Simulate Stripe success redirect - await page.goto(`/api/stripe/success?organizationId=${orgId}&planType=starter`); + // If they haven't booked a call (hasAccess is false), they see a book a call page. + await expect(page.getByText(`Let's get ${testData.organizationName} approved`)).toBeVisible(); + await expect( + page.getByText( + 'A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.', + ), + ).toBeVisible(); + + // For this test, we'll stop here since the user doesn't have access + // In a real scenario, they would book a call and get approved + // but for testing purposes, we'll just verify they see the book a call page + + // Try to access onboarding directly - should be redirected back to upgrade + await page.goto(`/onboarding/${orgId}`); + await expect(page).toHaveURL(`/upgrade/${orgId}`); + + // Try to access product pages - should be redirected back to upgrade + await page.goto(`/${orgId}/frameworks`); + await expect(page).toHaveURL(`/upgrade/${orgId}`); + + await page.goto(`/${orgId}/policies`); + await expect(page).toHaveURL(`/upgrade/${orgId}`); + }); + + test('user with access but incomplete onboarding: 3 steps → directly to onboarding', async ({ + page, + }) => { + // Tests user who has access (hasAccess = true) but hasn't completed onboarding + // They skip the book a call step and go directly to onboarding + const testData = generateTestData(); + const website = `example${Date.now()}.com`; + + // Authenticate user first + await authenticateTestUser(page, { + email: testData.email, + name: testData.userName, + skipOrg: true, + hasAccess: true, // This user has access + }); + + // Navigate to setup + await page.goto('/setup'); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Complete the 3 initial steps + // Step 1: Select framework + await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ + timeout: 10000, + }); + const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); + if (checkedFrameworks === 0) { + await page.locator('label:has-text("SOC 2")').click(); + } + await page.getByRole('button', { name: 'Next' }).click(); + + // Step 2: Organization name + await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); + await page.locator('input[name="organizationName"]').fill(testData.organizationName); + await page.getByRole('button', { name: 'Next' }).click(); + + // Step 3: Website + await page.waitForSelector('input[name="website"]', { timeout: 10000 }); + await page.locator('input[name="website"]').fill(website); + await page.getByRole('button', { name: 'Next' }).click(); + + // Extract orgId from URL + const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); + expect(orgIdMatch).toBeTruthy(); + const orgId = orgIdMatch![0]; - // Should redirect to onboarding + // Since the user has access (hasAccess = true), they should be redirected directly to onboarding + // This bypasses the book a call step + await page.goto(`/onboarding/${orgId}`); await expect(page).toHaveURL(`/onboarding/${orgId}`); - // Should see step 4 (describe) + // Should see step 4 (describe) - no book a call step await expect(page.getByText('Step 4 of 12')).toBeVisible(); await expect(page.getByText('Tell us a bit about your business')).toBeVisible(); + }); + + test('user with access but incomplete onboarding: redirected from product to onboarding', async ({ + page, + context, + }) => { + // Tests user who has access (hasAccess = true) but hasn't completed onboarding + // When they try to access product pages, they should be redirected to onboarding + const testData = generateTestData(); + const website = `example${Date.now()}.com`; + + // Authenticate user first + await authenticateTestUser(page, { + email: testData.email, + name: testData.userName, + skipOrg: true, + hasAccess: true, // This user has access + }); + + // First create org through minimal flow + await page.goto('/setup'); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Select framework + const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); + if (checkedFrameworks === 0) { + await page.locator('label:has-text("SOC 2")').click(); + } + await page.getByRole('button', { name: 'Next' }).click(); + + // Fill organization name + await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); + await page.locator('input[name="organizationName"]').fill(testData.organizationName); + await page.getByRole('button', { name: 'Next' }).click(); + + // Fill website + await page.waitForSelector('input[name="website"]', { timeout: 10000 }); + await page.locator('input[name="website"]').fill(website); + await page.getByRole('button', { name: 'Next' }).click(); + + const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); + expect(orgIdMatch).toBeTruthy(); + const orgId = orgIdMatch![0]; + + // Since the user has access (hasAccess = true), they can access onboarding + await page.goto(`/onboarding/${orgId}`); + await expect(page).toHaveURL(`/onboarding/${orgId}`); + + // Try to access product without completing onboarding + await page.goto(`/${orgId}/frameworks`); - // Complete remaining steps quickly + // Should be redirected back to onboarding + await expect(page).toHaveURL(`/onboarding/${orgId}`); + + // Try different product routes + await page.goto(`/${orgId}/policies`); + await expect(page).toHaveURL(`/onboarding/${orgId}`); + + await page.goto(`/${orgId}/vendors`); + await expect(page).toHaveURL(`/onboarding/${orgId}`); + }); + + test('user with access and completed onboarding: redirected from setup/onboarding to app', async ({ + page, + }) => { + // Tests user who has access (hasAccess = true) and completed onboarding + // They should be redirected from setup/upgrade/onboarding pages directly to the app + const testData = generateTestData(); + const website = `example${Date.now()}.com`; + + // Authenticate user first + await authenticateTestUser(page, { + email: testData.email, + name: testData.userName, + skipOrg: true, + hasAccess: true, // This user has access + }); + + // First create org through setup flow + await page.goto('/setup'); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Complete the 3 initial steps + // Step 1: Select framework + await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ + timeout: 10000, + }); + const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); + if (checkedFrameworks === 0) { + await page.locator('label:has-text("SOC 2")').click(); + } + await page.getByRole('button', { name: 'Next' }).click(); + + // Step 2: Organization name + await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); + await page.locator('input[name="organizationName"]').fill(testData.organizationName); + await page.getByRole('button', { name: 'Next' }).click(); + + // Step 3: Website + await page.waitForSelector('input[name="website"]', { timeout: 10000 }); + await page.locator('input[name="website"]').fill(website); + await page.getByRole('button', { name: 'Next' }).click(); + + // Extract orgId from URL + const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); + expect(orgIdMatch).toBeTruthy(); + const orgId = orgIdMatch![0]; + + // Since the user has access (hasAccess = true), they can access onboarding + await page.goto(`/onboarding/${orgId}`); + + // Complete all onboarding steps quickly to simulate a completed state const remainingSteps = [ { field: 'textarea', value: 'We are a test company' }, { field: 'select', text: 'Technology' }, @@ -105,66 +295,106 @@ test.describe('Split Onboarding Flow', () => { // Should redirect to product await expect(page).toHaveURL(`/${orgId}/frameworks`); - // Verify can access product pages + // Now test that user with completed onboarding goes directly to app + // When they try to access /setup, /upgrade, or /onboarding, they should be redirected + await page.goto('/setup'); + await expect(page).toHaveURL(`/${orgId}/frameworks`); + + await page.goto(`/upgrade/${orgId}`); + await expect(page).toHaveURL(`/${orgId}/frameworks`); + + await page.goto(`/onboarding/${orgId}`); + await expect(page).toHaveURL(`/${orgId}/frameworks`); + + // Should have access to all product pages await page.goto(`/${orgId}/policies`); await expect(page).toHaveURL(`/${orgId}/policies`); + + await page.goto(`/${orgId}/vendors`); + await expect(page).toHaveURL(`/${orgId}/vendors`); }); - test('paid user without completed onboarding is redirected to /onboarding', async ({ - page, - context, - }) => { - // Create a user with subscription but incomplete onboarding + test('user blocked by hasAccess → grant access → refresh shows onboarding', async ({ page }) => { + // Tests the flow where user is initially blocked, then access is granted, then they can access onboarding const testData = generateTestData(); const website = `example${Date.now()}.com`; - // Authenticate user first + // Authenticate user first (without access) await authenticateTestUser(page, { email: testData.email, name: testData.userName, skipOrg: true, + hasAccess: false, // This user doesn't have access initially }); - // First create org through minimal flow + // Navigate to setup await page.goto('/setup'); await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); - // Select framework + // Complete the 3 initial steps + // Step 1: Select framework + await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ + timeout: 10000, + }); const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); if (checkedFrameworks === 0) { await page.locator('label:has-text("SOC 2")').click(); } await page.getByRole('button', { name: 'Next' }).click(); - // Fill organization name + // Step 2: Organization name await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); await page.locator('input[name="organizationName"]').fill(testData.organizationName); await page.getByRole('button', { name: 'Next' }).click(); - // Fill website + // Step 3: Website await page.waitForSelector('input[name="website"]', { timeout: 10000 }); await page.locator('input[name="website"]').fill(website); await page.getByRole('button', { name: 'Next' }).click(); + // Should redirect to upgrade page + await expect(page).toHaveURL(/\/upgrade\/org_/); + + // Extract orgId from URL const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); expect(orgIdMatch).toBeTruthy(); const orgId = orgIdMatch![0]; - // Simulate payment completion - await page.goto(`/api/stripe/success?organizationId=${orgId}&planType=starter`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); + // Verify they see the book a call page (blocked by hasAccess = false) + await expect(page.getByText(`Let's get ${testData.organizationName} approved`)).toBeVisible(); + await expect( + page.getByText( + 'A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.', + ), + ).toBeVisible(); + + // Verify they can't access onboarding or product pages + await page.goto(`/onboarding/${orgId}`); + await expect(page).toHaveURL(`/upgrade/${orgId}`); - // Try to access product without completing onboarding await page.goto(`/${orgId}/frameworks`); + await expect(page).toHaveURL(`/upgrade/${orgId}`); - // Should be redirected back to onboarding + // Now simulate access being granted (like after a successful call) + await grantAccess(page, orgId, true); + + // Refresh the page - they should now be able to access onboarding + await page.reload(); + + // They should now be redirected to onboarding await expect(page).toHaveURL(`/onboarding/${orgId}`); - // Try different product routes - await page.goto(`/${orgId}/policies`); + // Verify they can see the onboarding content + await expect(page.getByText('Step 4 of 12')).toBeVisible(); + await expect(page.getByText('Tell us a bit about your business')).toBeVisible(); + + // Verify they can now access onboarding directly + await page.goto(`/onboarding/${orgId}`); await expect(page).toHaveURL(`/onboarding/${orgId}`); - await page.goto(`/${orgId}/vendors`); + // But if they try to access product pages, they should be redirected to onboarding + // (since they have access but haven't completed onboarding) + await page.goto(`/${orgId}/frameworks`); await expect(page).toHaveURL(`/onboarding/${orgId}`); }); @@ -179,6 +409,7 @@ test.describe('Split Onboarding Flow', () => { email: firstOrg.email, name: firstOrg.userName, skipOrg: true, + hasAccess: true, // This user has access }); await page.goto('/setup'); diff --git a/apps/app/e2e/utils/auth-helpers.ts b/apps/app/e2e/utils/auth-helpers.ts index 7c5693db4..f9e0a94c0 100644 --- a/apps/app/e2e/utils/auth-helpers.ts +++ b/apps/app/e2e/utils/auth-helpers.ts @@ -4,6 +4,7 @@ interface AuthOptions { email?: string; name?: string; skipOrg?: boolean; + hasAccess?: boolean; } /** @@ -68,3 +69,28 @@ export async function authenticateTestUser(page: Page, options: AuthOptions = {} export async function clearAuth(page: Page) { await page.context().clearCookies(); } + +/** + * Grant access to an organization for testing purposes + * This calls the test-grant-access endpoint to update the hasAccess field + */ +export async function grantAccess( + page: Page, + orgId: string, + hasAccess: boolean = true, +): Promise { + console.log(`Granting access to organization: ${orgId}, hasAccess: ${hasAccess}`); + + const response = await page.context().request.post('/api/auth/test-grant-access', { + data: { orgId, hasAccess }, + timeout: 10000, + }); + + if (!response.ok()) { + const errorBody = await response.text(); + throw new Error(`Failed to grant access: ${response.status()} - ${errorBody}`); + } + + const result = await response.json(); + console.log('Access granted successfully:', result); +} diff --git a/apps/app/package.json b/apps/app/package.json index fff770043..5918f26f5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -75,7 +75,6 @@ "remark-parse": "^11.0.0", "resend": "^4.4.1", "sonner": "^2.0.5", - "stripe": "^18.1.0", "three": "^0.177.0", "ts-pattern": "^5.7.0", "use-debounce": "^10.0.4", diff --git a/apps/app/src/actions/organization/lib/create-stripe-customer.ts b/apps/app/src/actions/organization/lib/create-stripe-customer.ts deleted file mode 100644 index 280bf8545..000000000 --- a/apps/app/src/actions/organization/lib/create-stripe-customer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { stripe } from './stripe'; - -async function createStripeCustomer(input: { - name: string; - email: string; - organizationId: string; -}): Promise { - try { - if (!stripe) { - return 'test_customer_id'; - } - - const customer = await stripe.customers.create({ - name: input.name, - email: input.email, - metadata: { - organizationId: input.organizationId, - }, - }); - - return customer.id; - } catch (error) { - console.error('Error creating Stripe customer', error); - throw error; - } -} - -export { createStripeCustomer }; diff --git a/apps/app/src/actions/organization/lib/stripe.ts b/apps/app/src/actions/organization/lib/stripe.ts deleted file mode 100644 index 8d719c3d4..000000000 --- a/apps/app/src/actions/organization/lib/stripe.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { env } from '@/env.mjs'; -import Stripe from 'stripe'; - -export const stripeWebhookSecret = env.STRIPE_WEBHOOK_SECRET; - -export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: '2025-05-28.basil', -}); diff --git a/apps/app/src/actions/stripe/fetch-price-details.ts b/apps/app/src/actions/stripe/fetch-price-details.ts deleted file mode 100644 index fab35286d..000000000 --- a/apps/app/src/actions/stripe/fetch-price-details.ts +++ /dev/null @@ -1,145 +0,0 @@ -'use server'; - -import { stripe } from '@/actions/organization/lib/stripe'; -import { env } from '@/env.mjs'; -import { client } from '@comp/kv'; -import Stripe from 'stripe'; - -type PriceDetails = { - id: string; - unitAmount: number | null; - currency: string; - interval: Stripe.Price.Recurring.Interval | null; - productName: string | null; -}; - -export type CachedPrices = { - managedMonthlyPrice: PriceDetails | null; - managedYearlyPrice: PriceDetails | null; - starterMonthlyPrice: PriceDetails | null; - starterYearlyPrice: PriceDetails | null; - fetchedAt: number; -}; - -const CACHE_DURATION = 30 * 60; // 30 minutes in seconds - -export async function fetchStripePriceDetails(): Promise { - // Fetch from Stripe - const managedMonthlyPriceId = env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_MONTHLY_PRICE_ID; - const managedYearlyPriceId = env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_YEARLY_PRICE_ID; - const starterMonthlyPriceId = env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_MONTHLY_PRICE_ID; - const starterYearlyPriceId = env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_YEARLY_PRICE_ID; - - // Create a unique cache key that includes the price IDs - const cacheKey = `stripe:all-prices:${managedMonthlyPriceId || 'none'}:${managedYearlyPriceId || 'none'}:${starterMonthlyPriceId || 'none'}:${starterYearlyPriceId || 'none'}`; - - try { - // Check cache first - const cached = await client.get(cacheKey); - if (cached && cached.fetchedAt && Date.now() - cached.fetchedAt < CACHE_DURATION * 1000) { - return cached; - } - } catch (error) { - console.error('[STRIPE] Error reading from cache:', error); - } - - let managedMonthlyPrice: PriceDetails | null = null; - let managedYearlyPrice: PriceDetails | null = null; - let starterMonthlyPrice: PriceDetails | null = null; - let starterYearlyPrice: PriceDetails | null = null; - - try { - // Fetch managed monthly price if ID exists - if (managedMonthlyPriceId) { - const price = await stripe.prices.retrieve(managedMonthlyPriceId, { - expand: ['product'], - }); - - managedMonthlyPrice = { - id: price.id, - unitAmount: price.unit_amount, - currency: price.currency, - interval: price.recurring?.interval ?? null, - productName: - price.product && typeof price.product === 'object' && !price.product.deleted - ? price.product.name - : null, - }; - } - - // Fetch managed yearly price if ID exists - if (managedYearlyPriceId) { - const price = await stripe.prices.retrieve(managedYearlyPriceId, { - expand: ['product'], - }); - - managedYearlyPrice = { - id: price.id, - unitAmount: price.unit_amount, - currency: price.currency, - interval: price.recurring?.interval ?? null, - productName: - price.product && typeof price.product === 'object' && !price.product.deleted - ? price.product.name - : null, - }; - } - - // Fetch starter monthly price if ID exists - if (starterMonthlyPriceId) { - const price = await stripe.prices.retrieve(starterMonthlyPriceId, { - expand: ['product'], - }); - - starterMonthlyPrice = { - id: price.id, - unitAmount: price.unit_amount, - currency: price.currency, - interval: price.recurring?.interval ?? null, - productName: - price.product && typeof price.product === 'object' && !price.product.deleted - ? price.product.name - : null, - }; - } - - // Fetch starter yearly price if ID exists - if (starterYearlyPriceId) { - const price = await stripe.prices.retrieve(starterYearlyPriceId, { - expand: ['product'], - }); - - starterYearlyPrice = { - id: price.id, - unitAmount: price.unit_amount, - currency: price.currency, - interval: price.recurring?.interval ?? null, - productName: - price.product && typeof price.product === 'object' && !price.product.deleted - ? price.product.name - : null, - }; - } - } catch (error) { - console.error('[STRIPE] Error fetching prices:', error); - } - - const priceData: CachedPrices = { - managedMonthlyPrice, - managedYearlyPrice, - starterMonthlyPrice, - starterYearlyPrice, - fetchedAt: Date.now(), - }; - - // Cache the results - try { - await client.set(cacheKey, priceData, { - ex: CACHE_DURATION, - }); - } catch (error) { - console.error('[STRIPE] Error caching price data:', error); - } - - return priceData; -} diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index c1379b6e9..ce709bf12 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,11 +1,9 @@ -import { getSubscriptionData } from '@/app/api/stripe/getSubscriptionData'; import { AnimatedLayout } from '@/components/animated-layout'; import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; import { Header } from '@/components/header'; import { AssistantSheet } from '@/components/sheets/assistant-sheet'; import { Sidebar } from '@/components/sidebar'; import { SidebarProvider } from '@/context/sidebar-context'; -import { SubscriptionProvider } from '@/context/subscription-context'; import { auth } from '@/utils/auth'; import { db } from '@comp/db'; import dynamic from 'next/dynamic'; @@ -13,7 +11,6 @@ import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { Suspense } from 'react'; import { OnboardingTracker } from './components/OnboardingTracker'; -import { UpgradeBanner } from './components/UpgradeBanner'; const HotKeys = dynamic(() => import('@/components/hot-keys').then((mod) => mod.HotKeys), { ssr: true, @@ -65,18 +62,6 @@ export default async function Layout({ return redirect('/auth/unauthorized'); } - // Fetch subscription data for the organization - const subscriptionData = await getSubscriptionData(requestedOrgId); - - // Log subscription status for monitoring - if (subscriptionData.status === 'none') { - console.log(`[SUBSCRIPTION] No subscription for org ${requestedOrgId}`); - } else if (subscriptionData.status === 'self-serve') { - console.log(`[SUBSCRIPTION] Org ${requestedOrgId} is on self-serve (free) plan`); - } else { - console.log(`[SUBSCRIPTION] Org ${requestedOrgId} status: ${subscriptionData.status}`); - } - const onboarding = await db.onboarding.findFirst({ where: { organizationId: requestedOrgId, @@ -95,20 +80,12 @@ export default async function Layout({ {onboarding?.triggerJobId && ( )} -
+
- -
- -
- {children} -
+ {children}
diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/cancel-subscription-dialog.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/cancel-subscription-dialog.tsx deleted file mode 100644 index 772b260f1..000000000 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/cancel-subscription-dialog.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client'; - -import { Button } from '@comp/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@comp/ui/dialog'; -import { Loader2 } from 'lucide-react'; - -interface CancelSubscriptionDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - isLoading?: boolean; - currentPeriodEnd?: number; - isTrialing?: boolean; -} - -export function CancelSubscriptionDialog({ - open, - onOpenChange, - onConfirm, - isLoading = false, - currentPeriodEnd, - isTrialing = false, -}: CancelSubscriptionDialogProps) { - const formattedDate = currentPeriodEnd - ? new Date(currentPeriodEnd * 1000).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - : null; - - return ( - - - - Cancel {isTrialing ? 'Trial' : 'Subscription'} - -

Are you sure you want to cancel your {isTrialing ? 'trial' : 'subscription'}?

- {isTrialing ? ( -

- If you cancel now, you'll lose access immediately. You can always start a new - subscription later, but you won't get another free trial. -

- ) : ( - formattedDate && ( -

- Your subscription will remain active until{' '} - {formattedDate}. You can resume your - subscription at any time before this date. -

- ) - )} -

- You'll lose access to all premium features{' '} - {isTrialing ? 'immediately' : 'after your current billing period ends'}. -

-
-
- - - - -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/loading.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/loading.tsx deleted file mode 100644 index 9c774022b..000000000 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import Loader from '@/components/ui/loader'; - -export default Loader; diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx deleted file mode 100644 index 6a7f2d53b..000000000 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx +++ /dev/null @@ -1,584 +0,0 @@ -'use client'; - -import { cancelSubscriptionAction } from '@/app/api/stripe/cancel-subscription/cancel-subscription'; -import { createPortalSessionAction } from '@/app/api/stripe/create-portal-session/create-portal-session'; -import { resumeSubscriptionAction } from '@/app/api/stripe/resume-subscription/resume-subscription'; -import { useSubscription } from '@/context/subscription-context'; -import { env } from '@/env.mjs'; -import { Alert, AlertDescription } from '@comp/ui/alert'; -import { Badge } from '@comp/ui/badge'; -import { Button } from '@comp/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; -import { Separator } from '@comp/ui/separator'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; -import { - AlertCircle, - Calendar, - Check, - Clock, - CreditCard, - Loader2, - Lock, - RefreshCw, - Sparkles, -} from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; -import { useParams, useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import { CancelSubscriptionDialog } from './cancel-subscription-dialog'; - -type PlanType = 'free' | 'starter' | 'managed'; - -export default function BillingPage() { - const { subscription, hasActiveSubscription, isTrialing, isSelfServe } = useSubscription(); - const router = useRouter(); - const params = useParams(); - const organizationId = params.orgId as string; - const [showCancelDialog, setShowCancelDialog] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - - // Determine plan type based on price ID - const getPlanType = (): PlanType => { - if (isSelfServe) return 'free'; - if ('priceId' in subscription && subscription.priceId) { - const starterPriceIds = [ - env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_MONTHLY_PRICE_ID, - env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_YEARLY_PRICE_ID, - ].filter(Boolean); - - if (starterPriceIds.includes(subscription.priceId)) { - return 'starter'; - } - } - return 'managed'; - }; - - const planType = getPlanType(); - - // Get plan configuration - const planConfig = { - free: { - displayName: 'Free Plan', - description: 'DIY compliance for small teams', - features: [ - 'Complete access to manage your compliance program', - 'Generate policies and documentation', - 'Basic integrations', - 'Community support', - ], - }, - starter: { - displayName: 'Starter Plan', - description: 'Everything you need to get compliant', - features: [ - 'All frameworks (SOC 2, ISO 27001, etc.)', - 'Trust & Security Portal', - 'AI Vendor & Risk Management', - 'Unlimited team members', - 'API access', - 'Community Support', - ], - trialDays: 14, - minimumTermMonths: 12, - }, - managed: { - displayName: 'Done For You', - description: 'White-glove compliance service', - features: [ - 'Everything in Starter', - 'Dedicated compliance team', - '3rd party audit included', - 'Compliant in 14 days', - '24x7x365 Support & SLA', - 'Private Slack channel', - ], - minimumTermMonths: 12, - }, - }; - - const currentPlanConfig = planConfig[planType]; - - // Calculate if minimum term has been met for both starter and managed plans - const hasMetMinimumTerm = () => { - // Free trials can be cancelled anytime - if (isTrialing) { - return true; - } - - // Only enforce minimum term for starter and managed plans - if ( - (planType !== 'starter' && planType !== 'managed') || - !('currentPeriodStart' in subscription) || - subscription.currentPeriodStart == null - ) { - return true; - } - - const startDate = new Date(subscription.currentPeriodStart * 1000); - const now = new Date(); - const monthsElapsed = - (now.getFullYear() - startDate.getFullYear()) * 12 + (now.getMonth() - startDate.getMonth()); - - const minimumTermMonths = - planType === 'starter' - ? planConfig.starter.minimumTermMonths - : planConfig.managed.minimumTermMonths; - - return monthsElapsed >= (minimumTermMonths || 12); - }; - - const canCancelSubscription = hasMetMinimumTerm(); - - // Calculate when cancellation will be available - const getCancellationAvailableDate = () => { - // Free trials don't have minimum term - if (isTrialing) { - return null; - } - - if ( - (planType !== 'starter' && planType !== 'managed') || - !('currentPeriodStart' in subscription) || - subscription.currentPeriodStart == null - ) { - return null; - } - - const startDate = new Date(subscription.currentPeriodStart * 1000); - const cancellationDate = new Date(startDate); - const minimumTermMonths = - planType === 'starter' - ? planConfig.starter.minimumTermMonths - : planConfig.managed.minimumTermMonths; - - cancellationDate.setMonth(cancellationDate.getMonth() + (minimumTermMonths || 12)); - - return cancellationDate; - }; - - const formatDate = (timestamp: number | Date) => { - const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : timestamp; - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - - // Action for canceling subscription - const { execute: cancelSubscription, isExecuting: isCanceling } = useAction( - cancelSubscriptionAction, - { - onSuccess: ({ data }) => { - if (data?.success) { - toast.success(data.message); - setShowCancelDialog(false); - } - }, - onError: ({ error }) => { - toast.error(error.serverError || 'Failed to cancel subscription'); - }, - }, - ); - - // Action for resuming subscription - const { execute: resumeSubscription, isExecuting: isResuming } = useAction( - resumeSubscriptionAction, - { - onSuccess: ({ data }) => { - if (data?.success) { - toast.success(data.message); - } - }, - onError: ({ error }) => { - toast.error(error.serverError || 'Failed to resume subscription'); - }, - }, - ); - - // Action for opening customer portal - const { execute: openPortal, isExecuting: isOpeningPortal } = useAction( - createPortalSessionAction, - { - onSuccess: ({ data }) => { - if (data?.portalUrl) { - router.push(data.portalUrl); - } - }, - onError: ({ error }) => { - toast.error(error.serverError || 'Failed to open billing portal'); - }, - }, - ); - - const getStatusBadge = () => { - if (subscription.status === 'none') { - return No subscription; - } - - if (subscription.status === 'self-serve') { - return Free Plan; - } - - const statusConfig = { - active: { variant: 'default' as const, label: 'Active' }, - trialing: { variant: 'outline' as const, label: 'Trial' }, - past_due: { variant: 'destructive' as const, label: 'Past Due' }, - canceled: { variant: 'secondary' as const, label: 'Canceled' }, - incomplete: { variant: 'secondary' as const, label: 'Incomplete' }, - incomplete_expired: { variant: 'secondary' as const, label: 'Expired' }, - unpaid: { variant: 'destructive' as const, label: 'Unpaid' }, - paused: { variant: 'secondary' as const, label: 'Paused' }, - }; - - const config = statusConfig[subscription.status] || { - variant: 'secondary' as const, - label: subscription.status, - }; - - return {config.label}; - }; - - // Function to refresh subscription data - const refreshSubscriptionData = async () => { - setIsRefreshing(true); - try { - const response = await fetch('/api/stripe/sync-subscription', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ organizationId }), - }); - - if (response.ok) { - toast.success('Subscription data refreshed'); - // Reload the page to get updated data - router.refresh(); - } else { - toast.error('Failed to refresh subscription data'); - } - } catch (error) { - toast.error('Error refreshing subscription data'); - } finally { - setIsRefreshing(false); - } - }; - - // Render different states based on plan and subscription status - - // 1. Free Plan View - if (planType === 'free') { - return ( -
- - -
- {currentPlanConfig.displayName} - {getStatusBadge()} -
- {currentPlanConfig.description} -
- -
-

Your plan includes:

-
    - {currentPlanConfig.features.map((feature, idx) => ( -
  • - - {feature} -
  • - ))} -
-
- - - -
-

Want more features?

- - - - Upgrade to Starter or Done For You plans for advanced features and support. - - - -
-
-
-
- ); - } - - // 2. No Active Subscription View - if (!hasActiveSubscription && subscription.status !== 'canceled') { - return ( -
- - - No Active Subscription - Choose a plan to get started - - - - - - Choose a plan to start managing your compliance program. - - - - - -
- ); - } - - // 3. Active Subscription View (Starter or Managed) - const renderTrialWarning = () => { - if ( - !isTrialing || - !('currentPeriodEnd' in subscription) || - subscription.currentPeriodEnd == null - ) - return null; - - const daysLeft = Math.ceil( - (subscription.currentPeriodEnd * 1000 - Date.now()) / (1000 * 60 * 60 * 24), - ); - const trialEndDate = formatDate(subscription.currentPeriodEnd); - - return ( - - - -
-

- Your {currentPlanConfig.displayName} trial expires in {daysLeft} days ({trialEndDate} - ). -

- {planType === 'starter' && ( -

- Add a payment method now to continue after your trial. You won't be charged until - the trial ends. Note: This plan requires a 12-month minimum commitment. -

- )} - {planType === 'managed' && ( -

- Your compliance team is ready to help! Note: This plan requires a 12-month minimum - commitment. -

- )} -
-
-
- ); - }; - - const renderCancellationWarning = () => { - if (!('cancelAtPeriodEnd' in subscription) || !subscription.cancelAtPeriodEnd) return null; - - return ( - - - - Your subscription will be canceled at the end of the current billing period. - - - ); - }; - - return ( - -
- {renderTrialWarning()} - {renderCancellationWarning()} - - - -
-
- {currentPlanConfig.displayName} - {getStatusBadge()} -
- -
- {currentPlanConfig.description} -
- -
- {'price' in subscription && subscription.price && ( -
-
- - Billing -
- - {subscription.price.unit_amount - ? new Intl.NumberFormat('en-US', { - style: 'currency', - currency: subscription.price.currency, - }).format(subscription.price.unit_amount / 100) - : 'Free'} - {subscription.price.interval ? `/${subscription.price.interval}` : ''} - -
- )} - - {isTrialing && - 'currentPeriodEnd' in subscription && - subscription.currentPeriodEnd && ( -
-
- - Trial Ends -
- - {formatDate(subscription.currentPeriodEnd)} - -
- )} - - {!isTrialing && - 'currentPeriodStart' in subscription && - subscription.currentPeriodStart && ( -
-
- - Started -
- - {formatDate(subscription.currentPeriodStart)} - -
- )} - - {'currentPeriodEnd' in subscription && - subscription.currentPeriodEnd && - !isTrialing && ( -
-
- - - {'cancelAtPeriodEnd' in subscription && subscription.cancelAtPeriodEnd - ? 'Expires' - : 'Renews'} - -
- - {formatDate(subscription.currentPeriodEnd)} - -
- )} - - {(planType === 'starter' || planType === 'managed') && - !canCancelSubscription && - getCancellationAvailableDate() && ( -
-
- - Minimum Term -
- - 12 months (ends {formatDate(getCancellationAvailableDate()!)}) - -
- )} -
- - - -
- - - {'cancelAtPeriodEnd' in subscription && subscription.cancelAtPeriodEnd ? ( - - ) : ( - subscription.status !== 'canceled' && - (canCancelSubscription ? ( - - ) : ( - - - - - - - -

This plan requires a 12-month minimum commitment.

- {getCancellationAvailableDate() && ( -

You can cancel after {formatDate(getCancellationAvailableDate()!)}.

- )} -
-
- )) - )} -
-
-
- - - cancelSubscription({ - organizationId, - immediate: isTrialing, // Cancel immediately for trials - }) - } - isLoading={isCanceling} - isTrialing={isTrialing} - currentPeriodEnd={ - 'currentPeriodEnd' in subscription && subscription.currentPeriodEnd !== null - ? subscription.currentPeriodEnd - : undefined - } - /> -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/settings/layout.tsx b/apps/app/src/app/(app)/[orgId]/settings/layout.tsx index a7ea6c6d2..a3a081364 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/layout.tsx @@ -9,7 +9,6 @@ export default async function Layout({ children }: { children: React.ReactNode } headers: await headers(), }); - const user = session?.user; const orgId = session?.session.activeOrganizationId; if (!session) { @@ -37,10 +36,6 @@ export default async function Layout({ children }: { children: React.ReactNode } path: `/${orgId}/settings/api-keys`, label: 'API', }, - { - path: `/${orgId}/settings/billing`, - label: 'Billing', - }, ]} />
diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 36d6353be..990743bbf 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -1,6 +1,5 @@ 'use server'; -import { createStripeCustomer } from '@/actions/organization/lib/create-stripe-customer'; import { getFrameworkNames } from '@/actions/organization/lib/get-framework-names'; import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; @@ -129,20 +128,6 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }, }); - // Create Stripe customer for new org - const stripeCustomerId = await createStripeCustomer({ - name: parsedInput.organizationName, - email: session.user.email, - organizationId: orgId, - }); - - if (stripeCustomerId) { - await db.organization.update({ - where: { id: orgId }, - data: { stripeCustomerId }, - }); - } - // Initialize frameworks - this sets up the structure immediately if (parsedInput.frameworkIds && parsedInput.frameworkIds.length > 0) { await initializeOrganization({ diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts index 320b413e6..a143c891f 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts @@ -1,6 +1,5 @@ 'use server'; -import { createStripeCustomer } from '@/actions/organization/lib/create-stripe-customer'; import { getFrameworkNames } from '@/actions/organization/lib/get-framework-names'; import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; @@ -145,20 +144,6 @@ export const createOrganization = authActionClientWithoutOrg }, }); - // Create Stripe customer for new org - const stripeCustomerId = await createStripeCustomer({ - name: parsedInput.organizationName, - email: session.user.email, - organizationId: orgId, - }); - - if (stripeCustomerId) { - await db.organization.update({ - where: { id: orgId }, - data: { stripeCustomerId }, - }); - } - // Initialize frameworks using the existing function if (parsedInput.frameworkIds && parsedInput.frameworkIds.length > 0) { await initializeOrganization({ diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/BookingDialog.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/BookingDialog.tsx deleted file mode 100644 index 326892c3f..000000000 --- a/apps/app/src/app/(app)/upgrade/[orgId]/components/BookingDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import { STRIPE_SUB_CACHE } from '@/app/api/stripe/stripeDataToKv.type'; -import CalendarEmbed from '@/components/calendar-embed'; -import { Button } from '@comp/ui/button'; -import { Dialog, DialogContent, DialogOverlay, DialogTrigger } from '@comp/ui/dialog'; -import { CalendarDays } from 'lucide-react'; - -export function BookingDialog({ subscription }: { subscription?: STRIPE_SUB_CACHE }) { - return ( - - - - - - - - - - - ); -} diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx index 65534257b..176963467 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx @@ -1,6 +1,5 @@ 'use client'; -import { SubscriptionType } from '@comp/db/types'; import { Button } from '@comp/ui/button'; import { Card } from '@comp/ui/card'; import { ArrowRight } from 'lucide-react'; @@ -12,26 +11,22 @@ export function BookingStep({ company, orgId, complianceFrameworks, - planType, + hasAccess, }: { email: string; name: string; company: string; orgId: string; complianceFrameworks: string[]; - planType: SubscriptionType; + hasAccess: boolean; }) { - const title = - planType === 'FREE' || planType === 'STARTER' - ? 'Talk to us to upgrade' - : `Let's get ${company} approved`; + const title = !hasAccess ? `Let's get ${company} approved` : 'Talk to us to upgrade'; - const description = - planType === 'FREE' || planType === 'STARTER' - ? 'A quick 20-minute call with our team to understand your compliance needs and upgrade your plan.' - : `A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.`; + const description = !hasAccess + ? `A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.` + : `A quick 20-minute call with our team to understand your compliance needs and upgrade your plan.`; - const cta = planType === 'FREE' || planType === 'STARTER' ? 'Book a Call' : 'Book Your Demo'; + const cta = !hasAccess ? 'Book Your Demo' : 'Book a Call'; return (
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/hooks/use-checkout.ts b/apps/app/src/app/(app)/upgrade/[orgId]/hooks/use-checkout.ts deleted file mode 100644 index 85285a759..000000000 --- a/apps/app/src/app/(app)/upgrade/[orgId]/hooks/use-checkout.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { generateCheckoutSessionAction } from '@/app/api/stripe/generate-checkout-session/generate-checkout-session'; -import { trackEvent, trackPurchaseEvent } from '@/utils/tracking'; -import { SubscriptionType } from '@comp/db/types'; -import { useAction } from 'next-safe-action/hooks'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import { PLAN_TYPES } from '../constants/pricing'; -import { PaymentType } from '../types/pricing'; - -interface UseCheckoutProps { - organizationId: string; - hasStarterSubscription: boolean; - prices: { - starterMonthlyPrice: number; - starterYearlyPriceTotal: number; - managedMonthlyPrice: number; - managedYearlyPriceTotal: number; - }; - priceDetails: any; -} - -export function useCheckout({ - organizationId, - hasStarterSubscription, - prices, - priceDetails, -}: UseCheckoutProps) { - const router = useRouter(); - const [executingButton, setExecutingButton] = useState(null); - - const baseUrl = - typeof window !== 'undefined' - ? `${window.location.protocol}//${window.location.host}` - : process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - - const { execute, isExecuting } = useAction(generateCheckoutSessionAction, { - onSuccess: ({ data }) => { - if (data?.checkoutUrl) { - router.push(data.checkoutUrl); - } - setExecutingButton(null); - }, - onError: ({ error }) => { - toast.error(error.serverError || 'Failed to create checkout session'); - setExecutingButton(null); - }, - }); - - const handleSubscribe = (plan: SubscriptionType, paymentType: PaymentType) => { - // Don't allow subscribing to starter if already on starter - if (plan === 'STARTER' && hasStarterSubscription) { - return; - } - - // Set which button is executing - setExecutingButton(`${plan}-${paymentType}`); - - const isYearly = paymentType === 'upfront'; - let priceId: string | undefined; - let planType: string; - - if (plan === 'STARTER') { - priceId = isYearly - ? priceDetails.starterYearlyPrice?.id - : priceDetails.starterMonthlyPrice?.id; - planType = PLAN_TYPES.starter; - } else { - priceId = isYearly - ? priceDetails.managedYearlyPrice?.id - : priceDetails.managedMonthlyPrice?.id; - planType = PLAN_TYPES.managed; - } - - if (!priceId) { - toast.error('Price information not available'); - setExecutingButton(null); - return; - } - - // Track checkout started event - const value = - plan === 'STARTER' - ? isYearly - ? prices.starterYearlyPriceTotal - : prices.starterMonthlyPrice - : isYearly - ? prices.managedYearlyPriceTotal - : prices.managedMonthlyPrice; - - trackPurchaseEvent('checkout_started', value); - trackEvent('checkout_started', { - event_category: 'ecommerce', - event_label: `${plan}_${isYearly ? 'yearly' : 'monthly'}`, - plan_type: plan, - billing_period: isYearly ? 'yearly' : 'monthly', - value, - currency: 'USD', - }); - - execute({ - organizationId, - mode: 'subscription', - priceId, - successUrl: `${baseUrl}/api/stripe/success?organizationId=${organizationId}&planType=${planType}`, - cancelUrl: `${baseUrl}/upgrade/${organizationId}`, - allowPromotionCodes: true, - metadata: { - organizationId, - plan, - billingPeriod: isYearly ? 'yearly' : 'monthly', - }, - }); - }; - - return { - handleSubscribe, - executingButton, - }; -} diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index 3de3b8308..f62e36c7d 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -1,11 +1,8 @@ -import { fetchStripePriceDetails } from '@/actions/stripe/fetch-price-details'; -import { getSubscriptionData } from '@/app/api/stripe/getSubscriptionData'; import { auth } from '@/utils/auth'; import { db } from '@comp/db'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { BookingStep } from './components/booking-step'; -import { PricingCards } from './pricing-cards'; import { UpgradePageTracking } from './UpgradePageTracking'; interface PageProps { @@ -41,24 +38,12 @@ export default async function UpgradePage({ params }: PageProps) { redirect('/'); } - // Check if they already have an active subscription - const subscription = await getSubscriptionData(orgId); + const hasAccess = member.organization.hasAccess; - // Only redirect if they have the managed plan - if ( - member.organization.subscriptionType === 'MANAGED' && - subscription && - (subscription.status === 'active' || subscription.status === 'trialing') - ) { - // Already have managed plan, redirect to dashboard + if (hasAccess) { redirect(`/${orgId}`); } - // Fetch price details from Stripe - const priceDetails = await fetchStripePriceDetails(); - - const hadCall = member.organization.hadCall; - const frameworkInstances = await db.frameworkInstance.findMany({ where: { organizationId: orgId, @@ -72,52 +57,18 @@ export default async function UpgradePage({ params }: PageProps) { framework.framework.name.toLowerCase().replaceAll(' ', ''), ); - if (!hadCall) { - return ( - <> - -
- -
- - ); - } - return ( <> - -
-
-
-
-
-

Pay your invoice

-

- Pay your invoice to get started with your compliance journey. -

-
- -
-
-
+
+
); diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx deleted file mode 100644 index fe7cd8136..000000000 --- a/apps/app/src/app/(app)/upgrade/[orgId]/pricing-cards.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import { Alert, AlertDescription } from '@comp/ui/alert'; -import { AlertCircle } from 'lucide-react'; -import { PricingCard } from './components/pricing-card'; -import { PRICING_FEATURES } from './constants/pricing'; -import { useCheckout } from './hooks/use-checkout'; -import { PricingCardsProps } from './types/pricing'; -import { checkSubscriptionStatus, getPriceDetails } from './utils/pricing-helpers'; - -export function PricingCards({ - organizationId, - priceDetails, - currentSubscription, - subscriptionType, -}: PricingCardsProps) { - // Check subscription status - const { hasStarterSubscription, isLoadingSubscription, hasPaymentIssue } = - checkSubscriptionStatus(subscriptionType, currentSubscription); - - // Calculate prices - const prices = getPriceDetails(priceDetails); - - // Use checkout hook - const { handleSubscribe, executingButton } = useCheckout({ - organizationId, - hasStarterSubscription, - prices, - priceDetails, - }); - - return ( -
- {/* Payment Issue Alert */} - {hasPaymentIssue && ( - - - - Your current subscription has a payment issue. Please update your payment method in{' '} - - billing settings - {' '} - before upgrading to a new plan. - - - )} - - {/* Pricing Card */} -
-
- handleSubscribe('MANAGED', 'upfront')} - onCheckoutMonthly={() => handleSubscribe('MANAGED', 'monthly')} - title="Done For You" - description="For companies up to 25 people." - annualPrice={prices.managedYearlyPriceTotal} - monthlyPrice={prices.managedMonthlyPrice} - subtitle="White-glove compliance service" - features={PRICING_FEATURES.managed} - isExecutingUpfront={executingButton === 'managed-upfront'} - isExecutingMonthly={executingButton === 'managed-monthly'} - isCurrentPlan={false} - isLoadingSubscription={isLoadingSubscription} - /> -
-
-
- ); -} diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/types/pricing.ts b/apps/app/src/app/(app)/upgrade/[orgId]/types/pricing.ts deleted file mode 100644 index 0f8d4ac0b..000000000 --- a/apps/app/src/app/(app)/upgrade/[orgId]/types/pricing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { STRIPE_SUB_CACHE } from '@/app/api/stripe/stripeDataToKv.type'; -import { SubscriptionType } from '@comp/db/types'; - -export interface PricingCardsProps { - organizationId: string; - priceDetails: { - managedMonthlyPrice: PriceDetail | null; - managedYearlyPrice: PriceDetail | null; - starterMonthlyPrice: PriceDetail | null; - starterYearlyPrice: PriceDetail | null; - }; - currentSubscription?: STRIPE_SUB_CACHE; - subscriptionType?: 'NONE' | 'FREE' | 'STARTER' | 'MANAGED'; -} - -export interface PriceDetail { - id: string; - unitAmount: number | null; - currency: string; - interval: string | null; - productName: string | null; -} - -export interface PricingCardProps { - planType: SubscriptionType; - onCheckoutUpfront: () => void; - onCheckoutMonthly: () => void; - title: string; - description: string; - annualPrice: number; - monthlyPrice: number; - subtitle?: string; - features: readonly string[]; - badge?: string; - isExecutingUpfront?: boolean; - isExecutingMonthly?: boolean; - isCurrentPlan?: boolean; - isLoadingSubscription?: boolean; -} - -export type PaymentType = 'upfront' | 'monthly'; diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/utils/pricing-helpers.ts b/apps/app/src/app/(app)/upgrade/[orgId]/utils/pricing-helpers.ts deleted file mode 100644 index cfcde86d3..000000000 --- a/apps/app/src/app/(app)/upgrade/[orgId]/utils/pricing-helpers.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { STRIPE_SUB_CACHE } from '@/app/api/stripe/stripeDataToKv.type'; -import { SubscriptionType } from '@comp/db/types'; -import { PRICING_DEFAULTS } from '../constants/pricing'; - -export function calculatePriceFromStripe( - stripePrice: { unitAmount: number | null } | null, - defaultPrice: number, -): number { - return stripePrice?.unitAmount ? Math.round(stripePrice.unitAmount / 100) : defaultPrice; -} - -export function calculateMonthlyFromYearly(yearlyTotal: number): number { - return Math.round(yearlyTotal / 12); -} - -export function checkSubscriptionStatus( - subscriptionType?: SubscriptionType, - subscription?: STRIPE_SUB_CACHE, -) { - const hasStarterSubscription = (() => { - if (subscriptionType !== 'STARTER') return false; - if (!subscription) return false; - - // Check if the subscription is completely dead - if (subscription.status === 'incomplete_expired' || subscription.status === 'unpaid') { - return false; - } - - return true; - })(); - - const isSubscriptionCanceling = - subscription && 'cancelAtPeriodEnd' in subscription && subscription.cancelAtPeriodEnd; - - const isLoadingSubscription = subscription === undefined; - - const hasPaymentIssue = - subscription && 'status' in subscription && subscription.status === 'past_due'; - - return { - hasStarterSubscription, - isSubscriptionCanceling, - isLoadingSubscription, - hasPaymentIssue, - }; -} - -export function getPriceDetails(priceDetails: any) { - return { - starterMonthlyPrice: calculatePriceFromStripe( - priceDetails.starterMonthlyPrice, - PRICING_DEFAULTS.starter.monthly, - ), - starterYearlyPriceTotal: calculatePriceFromStripe( - priceDetails.starterYearlyPrice, - PRICING_DEFAULTS.starter.yearlyTotal, - ), - managedMonthlyPrice: calculatePriceFromStripe( - priceDetails.managedMonthlyPrice, - PRICING_DEFAULTS.managed.monthly, - ), - managedYearlyPriceTotal: calculatePriceFromStripe( - priceDetails.managedYearlyPrice, - PRICING_DEFAULTS.managed.yearlyTotal, - ), - }; -} diff --git a/apps/app/src/app/api/auth/test-grant-access/route.ts b/apps/app/src/app/api/auth/test-grant-access/route.ts new file mode 100644 index 000000000..3c5f8373f --- /dev/null +++ b/apps/app/src/app/api/auth/test-grant-access/route.ts @@ -0,0 +1,60 @@ +import { db } from '@comp/db'; +import { NextRequest, NextResponse } from 'next/server'; + +// Force dynamic rendering for this route +export const dynamic = 'force-dynamic'; + +// This endpoint is ONLY for E2E tests - never enable in production! +export async function POST(request: NextRequest) { + console.log('[TEST-GRANT-ACCESS] ========================='); + console.log('[TEST-GRANT-ACCESS] Endpoint hit at:', new Date().toISOString()); + console.log('[TEST-GRANT-ACCESS] E2E_TEST_MODE:', process.env.E2E_TEST_MODE); + console.log('[TEST-GRANT-ACCESS] NODE_ENV:', process.env.NODE_ENV); + console.log('[TEST-GRANT-ACCESS] ========================='); + + // Only allow in E2E test mode + if (process.env.E2E_TEST_MODE !== 'true') { + console.log('[TEST-GRANT-ACCESS] E2E_TEST_MODE is not true:', process.env.E2E_TEST_MODE); + return NextResponse.json({ error: 'Not allowed' }, { status: 403 }); + } + + console.log('[TEST-GRANT-ACCESS] E2E mode verified'); + + try { + const body = await request.json(); + console.log('[TEST-GRANT-ACCESS] Request body:', body); + + const { orgId, hasAccess } = body; + + if (!orgId) { + return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 }); + } + + console.log( + '[TEST-GRANT-ACCESS] Updating organization access:', + orgId, + 'hasAccess:', + hasAccess, + ); + + // Update the organization's hasAccess field + const updatedOrg = await db.organization.update({ + where: { id: orgId }, + data: { hasAccess: hasAccess !== undefined ? hasAccess : true }, + }); + + console.log('[TEST-GRANT-ACCESS] Successfully updated organization:', updatedOrg.id); + + return NextResponse.json({ + success: true, + organizationId: updatedOrg.id, + hasAccess: updatedOrg.hasAccess, + }); + } catch (error) { + console.error('[TEST-GRANT-ACCESS] Error:', error); + return NextResponse.json( + { error: 'Failed to update organization access', details: error }, + { status: 500 }, + ); + } +} diff --git a/apps/app/src/app/api/auth/test-login/route.ts b/apps/app/src/app/api/auth/test-login/route.ts index 4dfa4b2c9..d2d05a768 100644 --- a/apps/app/src/app/api/auth/test-login/route.ts +++ b/apps/app/src/app/api/auth/test-login/route.ts @@ -43,7 +43,7 @@ async function handleLogin(request: NextRequest) { const body = await request.json(); console.log('[TEST-LOGIN] Request body:', body); - const { email, name } = body; + const { email, name, hasAccess } = body; const testPassword = 'Test123456!'; // Use a stronger test password console.log('[TEST-LOGIN] Checking for existing user:', email); @@ -92,7 +92,7 @@ async function handleLogin(request: NextRequest) { await db.organization.create({ data: { name: `Test Org ${Date.now()}`, - subscriptionType: 'NONE', // New users haven't chosen a subscription yet + hasAccess: hasAccess || false, // Allow setting hasAccess for tests members: { create: { userId: user.id, @@ -140,7 +140,7 @@ async function handleLogin(request: NextRequest) { data: { id: `org_${Date.now()}`, name: `Test Org ${Date.now()}`, - subscriptionType: 'NONE', // New users haven't chosen a subscription yet + hasAccess: hasAccess || false, // Allow setting hasAccess for tests members: { create: { id: `mem_${Date.now()}`, diff --git a/apps/app/src/app/api/stripe/README.md b/apps/app/src/app/api/stripe/README.md deleted file mode 100644 index 93a818de8..000000000 --- a/apps/app/src/app/api/stripe/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Stripe Subscription Data with KV Cache - -This module provides a resilient way to fetch Stripe subscription data with automatic KV cache fallback. - -## Problem - -When using KV (Key-Value) stores with eviction enabled, cached subscription data can be removed when the store reaches its memory limit. Without proper handling, this would result in missing subscription data. - -## Solution - -The `getSubscriptionData` function implements a cache-aside pattern that: - -1. **Attempts to read from KV cache first** - Fast and reduces Stripe API calls -2. **Falls back to Stripe API on cache miss** - Ensures data is always available -3. **Repopulates the cache automatically** - Future requests will be fast again -4. **Handles errors gracefully** - Returns safe defaults if Stripe is unavailable - -## Usage - -```typescript -import { getSubscriptionData } from './getSubscriptionData'; - -// Get subscription data for an organization -const subscriptionData = await getSubscriptionData(organizationId); - -if (subscriptionData.status === 'none') { - // No subscription exists -} else if (subscriptionData.status === 'active') { - // Active subscription - console.log('Price ID:', subscriptionData.priceId); - console.log('Renews:', new Date(subscriptionData.currentPeriodEnd! * 1000)); -} -``` - -## API - -### `getSubscriptionData(organizationId: string): Promise` - -Fetches subscription data for an organization. Returns either: - -- `{ status: 'none' }` - No subscription exists -- Full subscription object with status, pricing, and payment details - -### `invalidateSubscriptionCache(organizationId: string): Promise` - -Removes cached subscription data, forcing the next read to fetch fresh data from Stripe. - -## How It Works - -```mermaid -graph TD - A[getSubscriptionData called] --> B{Get Stripe Customer ID} - B -->|Not found| C[Return status: none] - B -->|Found| D{Check KV Cache} - D -->|Cache Hit| E[Return cached data] - D -->|Cache Miss| F[Call Stripe API] - F --> G[Update KV Cache] - G --> H[Return fresh data] - F -->|Error| I[Log error & return status: none] -``` - -## Benefits - -1. **Resilient to KV eviction** - Always returns data even if cache is cleared -2. **Reduces Stripe API calls** - Uses cache when available -3. **Self-healing** - Automatically repopulates cache after eviction -4. **Error handling** - Gracefully handles Stripe API failures -5. **Type-safe** - Full TypeScript support with STRIPE_SUB_CACHE type diff --git a/apps/app/src/app/api/stripe/cancel-subscription/cancel-subscription.ts b/apps/app/src/app/api/stripe/cancel-subscription/cancel-subscription.ts deleted file mode 100644 index cdd7eb94b..000000000 --- a/apps/app/src/app/api/stripe/cancel-subscription/cancel-subscription.ts +++ /dev/null @@ -1,87 +0,0 @@ -'use server'; - -import { stripe } from '@/actions/organization/lib/stripe'; -import { authWithOrgAccessClient } from '@/actions/safe-action'; -import { getSubscriptionData } from '@/app/api/stripe/getSubscriptionData'; -import { syncStripeDataToKV } from '@/app/api/stripe/syncStripeDataToKv'; -import { db } from '@comp/db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -const cancelSubscriptionSchema = z.object({ - organizationId: z.string(), - immediate: z.boolean().optional().default(false), // If true, cancel immediately. If false, cancel at period end -}); - -export const cancelSubscriptionAction = authWithOrgAccessClient - .inputSchema(cancelSubscriptionSchema) - .metadata({ - name: 'cancel-subscription', - track: { - event: 'cancel-subscription', - description: 'Cancel Stripe subscription', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { organizationId, immediate } = parsedInput; - - try { - // Get current subscription data - const subscriptionData = await getSubscriptionData(organizationId); - - if (subscriptionData.status === 'none') { - throw new Error('No active subscription found'); - } - - if (!('subscriptionId' in subscriptionData) || !subscriptionData.subscriptionId) { - throw new Error('Invalid subscription data'); - } - - // Cancel the subscription - const updatedSubscription = await stripe.subscriptions.update( - subscriptionData.subscriptionId, - { - cancel_at_period_end: !immediate, - // If immediate cancellation, use cancel method instead - }, - ); - - // If immediate cancellation requested, cancel now - if (immediate) { - await stripe.subscriptions.cancel(subscriptionData.subscriptionId); - } - - // Get the organization's Stripe customer ID - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { stripeCustomerId: true }, - }); - - if (organization?.stripeCustomerId) { - // Sync the updated subscription data to the database - await syncStripeDataToKV(organization.stripeCustomerId); - } - - // Revalidate the current path - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - - revalidatePath(path); - // Also specifically revalidate the billing page - revalidatePath(`/${organizationId}/settings/billing`); - - return { - success: true, - cancelAtPeriodEnd: !immediate, - message: immediate - ? 'Subscription canceled immediately' - : 'Subscription will be canceled at the end of the current period', - }; - } catch (error) { - console.error('[STRIPE] Error canceling subscription:', error); - throw new Error(error instanceof Error ? error.message : 'Failed to cancel subscription'); - } - }); diff --git a/apps/app/src/app/api/stripe/create-portal-session/create-portal-session.ts b/apps/app/src/app/api/stripe/create-portal-session/create-portal-session.ts deleted file mode 100644 index c7a5ec550..000000000 --- a/apps/app/src/app/api/stripe/create-portal-session/create-portal-session.ts +++ /dev/null @@ -1,70 +0,0 @@ -'use server'; - -import { stripe } from '@/actions/organization/lib/stripe'; -import { authWithOrgAccessClient } from '@/actions/safe-action'; -import { db } from '@comp/db'; -import { client } from '@comp/kv'; -import { z } from 'zod'; - -const createPortalSessionSchema = z.object({ - organizationId: z.string(), - returnUrl: z.string().url().optional(), -}); - -export const createPortalSessionAction = authWithOrgAccessClient - .inputSchema(createPortalSessionSchema) - .metadata({ - name: 'create-portal-session', - track: { - event: 'create-portal-session', - description: 'Create Stripe customer portal session', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { organizationId, returnUrl } = parsedInput; - - try { - // Get the Stripe customer ID - let stripeCustomerId = await client.get(`stripe:organization:${organizationId}`); - - // If not in KV, check database - if (!stripeCustomerId) { - const organization = await db.organization.findUnique({ - where: { - id: organizationId, - }, - select: { - stripeCustomerId: true, - }, - }); - - if (!organization?.stripeCustomerId) { - throw new Error('No Stripe customer found for this organization'); - } - - stripeCustomerId = organization.stripeCustomerId; - - // Cache it for next time - await client.set(`stripe:organization:${organizationId}`, stripeCustomerId); - } - - // Ensure we have a valid base URL - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - const defaultReturnUrl = `${appUrl}/${organizationId}/settings/billing`; - - // Create the portal session - const portalSession = await stripe.billingPortal.sessions.create({ - customer: stripeCustomerId as string, - return_url: returnUrl || defaultReturnUrl, - }); - - return { - success: true, - portalUrl: portalSession.url, - }; - } catch (error) { - console.error('[STRIPE] Error creating portal session:', error); - throw new Error(error instanceof Error ? error.message : 'Failed to create portal session'); - } - }); diff --git a/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts b/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts deleted file mode 100644 index c2793f963..000000000 --- a/apps/app/src/app/api/stripe/generate-checkout-session/generate-checkout-session.ts +++ /dev/null @@ -1,152 +0,0 @@ -'use server'; - -import { stripe } from '@/actions/organization/lib/stripe'; -import { authWithOrgAccessClient } from '@/actions/safe-action'; -import { db } from '@comp/db'; -import { client } from '@comp/kv'; -import type { Stripe } from 'stripe'; -import { z } from 'zod'; - -/** - * Zod schema for Stripe checkout session generation. - * - * Important: According to Stripe's API: - * - success_url is REQUIRED for standard checkout sessions - * - cancel_url is OPTIONAL (Stripe will use success_url if not provided) - * - line_items with recurring price is REQUIRED for subscription mode - * - * We provide sensible defaults to ensure the action always works. - */ -const generateCheckoutSessionSchema = z - .object({ - organizationId: z.string(), - mode: z.enum(['payment', 'subscription']), - priceId: z.string(), - successUrl: z.string().url(), - cancelUrl: z.string().url(), - allowPromotionCodes: z.boolean().optional(), - metadata: z.record(z.string()).optional(), - }) - .refine( - // Ensure priceId is provided for subscription mode - (data) => { - if (data.mode === 'subscription' && !data.priceId) { - return false; - } - return true; - }, - { - message: "priceId is required when mode is 'subscription'", - path: ['priceId'], - }, - ); - -export const generateCheckoutSessionAction = authWithOrgAccessClient - .inputSchema(generateCheckoutSessionSchema) - .metadata({ - name: 'generate-checkout-session', - track: { - event: 'generate-checkout-session', - description: 'Generate Stripe checkout session', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { user, member, organizationId } = ctx; - const { - successUrl, - cancelUrl, - mode, - priceId, - allowPromotionCodes = false, - metadata, - } = parsedInput; - - let stripeCustomerId; - - // First, check the KV store for the stripeCustomerId - stripeCustomerId = await client.get(`stripe:organization:${organizationId}`); - - // If not present in KV, check the database for the organization and stripeCustomerId - if (!stripeCustomerId) { - const organization = await db.organization.findUnique({ - where: { - id: organizationId, - }, - }); - - if (!organization) { - throw new Error('Organization not found'); - } - - if (organization.stripeCustomerId) { - stripeCustomerId = organization.stripeCustomerId; - // Sync the stripeCustomerId to KV store since it was missing there - await client.set(`stripe:organization:${organizationId}`, stripeCustomerId); - } - } - - // Create a new Stripe customer if this organization doesn't have one - if (!stripeCustomerId) { - const newCustomer = await stripe.customers.create({ - email: user.email, - metadata: { - organizationId, - userId: user.id, - }, - }); - - // Store the relation between organizationId and stripeCustomerId in your KV & database - await client.set(`stripe:organization:${organizationId}`, newCustomer.id); - await db.organization.update({ - where: { - id: organizationId, - }, - data: { - stripeCustomerId: newCustomer.id, - }, - }); - stripeCustomerId = newCustomer.id; - } - - const sessionData: Stripe.Checkout.SessionCreateParams = { - payment_method_types: ['card'], - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode, - ...(allowPromotionCodes && { allow_promotion_codes: true }), - metadata: { - organizationId, - userId: user.id, - memberId: member.id, - dubCustomerId: user.id, - ...metadata, - }, - success_url: successUrl, - cancel_url: cancelUrl, - customer_update: { - address: 'auto', - }, - ...(stripeCustomerId && { customer: stripeCustomerId as string }), - ...(mode === 'subscription' && { - subscription_data: { - metadata: { - organizationId, - userId: user.id, - }, - }, - }), - }; - - const checkout = await stripe.checkout.sessions.create(sessionData); - - return { - success: true, - checkoutUrl: checkout.url, - sessionId: checkout.id, - }; - }); diff --git a/apps/app/src/app/api/stripe/getSubscriptionData.ts b/apps/app/src/app/api/stripe/getSubscriptionData.ts deleted file mode 100644 index 70175405d..000000000 --- a/apps/app/src/app/api/stripe/getSubscriptionData.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { db } from '@comp/db'; -import { STRIPE_SUB_CACHE } from './stripeDataToKv.type'; - -/** - * Gets subscription data for an organization from the database. - * This is now a simple database lookup with no KV caching. - */ -export async function getSubscriptionData(organizationId: string): Promise { - try { - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { - subscriptionType: true, - stripeSubscriptionData: true, - }, - }); - - if (!organization) { - console.log(`[SUBSCRIPTION] Organization ${organizationId} not found`); - return { status: 'none' }; - } - - // Return based on subscription type - switch (organization.subscriptionType) { - case 'FREE': - console.log(`[SUBSCRIPTION] Org ${organizationId} is on free plan`); - return { status: 'self-serve' }; - - case 'STARTER': - case 'MANAGED': - if (organization.stripeSubscriptionData) { - const data = organization.stripeSubscriptionData as STRIPE_SUB_CACHE; - console.log( - `[SUBSCRIPTION] Org ${organizationId} has ${organization.subscriptionType} subscription with status: ${data.status}`, - ); - return data; - } - console.log( - `[SUBSCRIPTION] Org ${organizationId} has ${organization.subscriptionType} type but no data`, - ); - return { status: 'none' }; - - case 'NONE': - default: - console.log(`[SUBSCRIPTION] Org ${organizationId} has no subscription`); - return { status: 'none' }; - } - } catch (error) { - console.error('[SUBSCRIPTION] Error fetching subscription data:', error); - return { status: 'none' }; - } -} - -/** - * Invalidates the cached subscription data for an organization. - * With the DB approach, this is now a no-op but kept for compatibility. - */ -export async function invalidateSubscriptionCache(organizationId: string): Promise { - // No-op - data is always fresh from DB - console.log( - `[SUBSCRIPTION] Cache invalidation requested for ${organizationId} (no-op with DB approach)`, - ); -} diff --git a/apps/app/src/app/api/stripe/repair/route.ts b/apps/app/src/app/api/stripe/repair/route.ts deleted file mode 100644 index bb6f8c569..000000000 --- a/apps/app/src/app/api/stripe/repair/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { stripe } from '@/actions/organization/lib/stripe'; -import { db } from '@comp/db'; -import { SubscriptionType } from '@comp/db/types'; -import { NextResponse } from 'next/server'; -import { syncStripeDataToKV } from '../syncStripeDataToKv'; - -// Type for request body -interface RepairStripeDataRequest { - org_id: string; - stripe_sub_id: string; -} - -// Error response helper -function errorResponse(message: string, status: number) { - return new Response(JSON.stringify({ error: message }), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -// Success response helper -function successResponse(message: string, data?: any) { - return new Response(JSON.stringify({ message, data }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); -} - -/** - * POST /api/stripe/repair - * - * Repairs Stripe data for an organization by: - * 1. Retrieving the Stripe subscription - * 2. Updating the organization with the correct customer ID - * 3. Syncing the data to KV store - */ -export async function POST(req: Request) { - // Validate authentication - const retoolCompApiSecret = process.env.RETOOL_COMP_API_SECRET; - if (!retoolCompApiSecret) { - return errorResponse('Server configuration error: retool comp api secret not configured', 500); - } - - const authHeader = req.headers.get('authorization'); - const token = authHeader?.split(' ')[1]; - if (!token || token !== retoolCompApiSecret) { - return NextResponse.json( - { - success: false, - error: 'Unauthorized', - }, - { status: 401 }, - ); - } - - // Parse and validate request body - let body: RepairStripeDataRequest; - try { - body = await req.json(); - } catch (error) { - return errorResponse('Invalid JSON in request body', 400); - } - - const { org_id: organizationId, stripe_sub_id: stripeSubscriptionId } = body; - - if (!organizationId) { - return errorResponse('Missing required field: org_id', 400); - } - - if (!stripeSubscriptionId) { - return errorResponse('Missing required field: stripe_sub_id', 400); - } - - // Fetch organization from database - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - }); - - if (!organization) { - return errorResponse(`Organization not found: ${organizationId}`, 404); - } - - // Retrieve Stripe subscription - let subscription; - try { - subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId); - } catch (error) { - return errorResponse(`Stripe subscription not found: ${stripeSubscriptionId}`, 404); - } - - // Retrieve Stripe customer - let customer; - try { - customer = await stripe.customers.retrieve(subscription.customer as string); - } catch (error) { - return errorResponse(`Stripe customer not found: ${subscription.customer}`, 404); - } - - // Update organization with Stripe data - const updatedOrganization = await db.organization.update({ - where: { id: organizationId }, - data: { - stripeCustomerId: customer.id, - subscriptionType: SubscriptionType.MANAGED, - }, - }); - - // Sync updated data to KV store - await syncStripeDataToKV(customer.id); - - return successResponse('Stripe data successfully repaired', { - organizationId: updatedOrganization.id, - stripeCustomerId: customer.id, - subscriptionType: SubscriptionType.MANAGED, - }); -} diff --git a/apps/app/src/app/api/stripe/resume-subscription/resume-subscription.ts b/apps/app/src/app/api/stripe/resume-subscription/resume-subscription.ts deleted file mode 100644 index bb16ff642..000000000 --- a/apps/app/src/app/api/stripe/resume-subscription/resume-subscription.ts +++ /dev/null @@ -1,79 +0,0 @@ -'use server'; - -import { stripe } from '@/actions/organization/lib/stripe'; -import { authWithOrgAccessClient } from '@/actions/safe-action'; -import { getSubscriptionData } from '@/app/api/stripe/getSubscriptionData'; -import { syncStripeDataToKV } from '@/app/api/stripe/syncStripeDataToKv'; -import { db } from '@comp/db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -const resumeSubscriptionSchema = z.object({ - organizationId: z.string(), -}); - -export const resumeSubscriptionAction = authWithOrgAccessClient - .inputSchema(resumeSubscriptionSchema) - .metadata({ - name: 'resume-subscription', - track: { - event: 'resume-subscription', - description: 'Resume a cancelled Stripe subscription', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { organizationId } = parsedInput; - - try { - // Get current subscription data - const subscriptionData = await getSubscriptionData(organizationId); - - if (subscriptionData.status === 'none') { - throw new Error('No subscription found'); - } - - if (!('subscriptionId' in subscriptionData) || !subscriptionData.subscriptionId) { - throw new Error('Invalid subscription data'); - } - - if (!subscriptionData.cancelAtPeriodEnd) { - throw new Error('Subscription is not scheduled for cancellation'); - } - - // Resume the subscription by updating cancel_at_period_end to false - await stripe.subscriptions.update(subscriptionData.subscriptionId, { - cancel_at_period_end: false, - }); - - // Get the organization's Stripe customer ID - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { stripeCustomerId: true }, - }); - - if (organization?.stripeCustomerId) { - // Sync the updated subscription data to the database - await syncStripeDataToKV(organization.stripeCustomerId); - } - - // Revalidate the current path - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - - revalidatePath(path); - // Also specifically revalidate the billing page - revalidatePath(`/${organizationId}/settings/billing`); - - return { - success: true, - message: - 'Your subscription has been reactivated and will continue after the current period', - }; - } catch (error) { - console.error('[STRIPE] Error resuming subscription:', error); - throw new Error(error instanceof Error ? error.message : 'Failed to resume subscription'); - } - }); diff --git a/apps/app/src/app/api/stripe/stripeDataToKv.type.ts b/apps/app/src/app/api/stripe/stripeDataToKv.type.ts deleted file mode 100644 index 2b5cd4f59..000000000 --- a/apps/app/src/app/api/stripe/stripeDataToKv.type.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Stripe from 'stripe'; - -export type STRIPE_SUB_CACHE = - | { - subscriptionId: string | null; - status: Stripe.Subscription.Status; - priceId: string | null; - currentPeriodStart: number | null; - currentPeriodEnd: number | null; - cancelAtPeriodEnd: boolean; - price: { - nickname: string | null; - unit_amount: number | null; - currency: string; - interval: Stripe.Price.Recurring.Interval | null; - } | null; - product: { - name: string; - } | null; - paymentMethod: { - brand: string | null; // e.g., "visa", "mastercard" - last4: string | null; // e.g., "4242" - } | null; - } - | { - status: 'none'; - } - | { - status: 'self-serve'; - }; diff --git a/apps/app/src/app/api/stripe/success/route.ts b/apps/app/src/app/api/stripe/success/route.ts deleted file mode 100644 index 909f35dce..000000000 --- a/apps/app/src/app/api/stripe/success/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { getServersideSession } from '@/lib/get-session'; -import { trackPurchaseCompletionServer } from '@/utils/server-tracking'; -import { db } from '@comp/db'; -import { client } from '@comp/kv'; -import { redirect } from 'next/navigation'; -import { syncStripeDataToKV } from '../syncStripeDataToKv'; - -export async function GET(req: Request) { - const { user } = await getServersideSession(req); - - // Extract organizationId and planType from query parameters - const url = new URL(req.url); - const organizationId = url.searchParams.get('organizationId'); - const planType = url.searchParams.get('planType') || 'done-for-you'; // Default to done-for-you for backwards compatibility - - if (!organizationId) { - return redirect('/'); - } - - // Check if the user has access to the organization by querying the members table - const member = await db.member.findFirst({ - where: { - userId: user.id, - organizationId: organizationId, - }, - }); - - if (!member) { - return redirect('/'); - } - - // Get organization to check onboarding status - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { onboardingCompleted: true }, - }); - - const stripeCustomerId = await client.get(`stripe:organization:${organizationId}`); - if (!stripeCustomerId) { - return redirect(`/${organizationId}`); - } - - await syncStripeDataToKV(stripeCustomerId as string); - - // Get subscription value for tracking (you may need to fetch this from Stripe or your DB) - // For now, using default values based on plan type - const value = planType === 'starter' ? 99 : 997; - - // Track the successful purchase with user ID - await trackPurchaseCompletionServer(organizationId, planType, value, user.id); - - // Check if onboarding is complete - if (organization && !organization.onboardingCompleted) { - // Redirect to onboarding with parameters - const redirectUrl = new URL(`/onboarding/${organizationId}`, url.origin); - redirectUrl.searchParams.set('checkoutComplete', planType); - redirectUrl.searchParams.set('organizationId', organizationId); - redirectUrl.searchParams.set('value', value.toString()); - - return redirect(redirectUrl.toString()); - } - - // Redirect to frameworks (existing behavior) if onboarding is complete - const redirectUrl = new URL(`/${organizationId}/frameworks`, url.origin); - redirectUrl.searchParams.set('checkoutComplete', planType); - redirectUrl.searchParams.set('organizationId', organizationId); - redirectUrl.searchParams.set('value', value.toString()); - - return redirect(redirectUrl.toString()); -} diff --git a/apps/app/src/app/api/stripe/sync-subscription/route.ts b/apps/app/src/app/api/stripe/sync-subscription/route.ts deleted file mode 100644 index 0ae0ee53d..000000000 --- a/apps/app/src/app/api/stripe/sync-subscription/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getServersideSession } from '@/lib/get-session'; -import { db } from '@comp/db'; -import { NextResponse } from 'next/server'; -import { syncStripeDataToKV } from '../syncStripeDataToKv'; - -export async function POST(req: Request) { - try { - const { user } = await getServersideSession(req); - const { organizationId } = await req.json(); - - if (!organizationId) { - return NextResponse.json({ error: 'organizationId is required' }, { status: 400 }); - } - - // Check if the user has access to the organization - const member = await db.member.findFirst({ - where: { - userId: user.id, - organizationId: organizationId, - }, - }); - - if (!member) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); - } - - // Get the organization with Stripe customer ID - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { stripeCustomerId: true }, - }); - - if (!organization?.stripeCustomerId) { - return NextResponse.json({ error: 'No Stripe customer found' }, { status: 404 }); - } - - // Sync the subscription data - const result = await syncStripeDataToKV(organization.stripeCustomerId); - - return NextResponse.json({ - success: true, - subscription: result, - }); - } catch (error) { - console.error('[STRIPE SYNC] Error:', error); - return NextResponse.json({ error: 'Failed to sync subscription data' }, { status: 500 }); - } -} diff --git a/apps/app/src/app/api/stripe/syncStripeDataToKv.ts b/apps/app/src/app/api/stripe/syncStripeDataToKv.ts deleted file mode 100644 index cd778a412..000000000 --- a/apps/app/src/app/api/stripe/syncStripeDataToKv.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { stripe } from '@/actions/organization/lib/stripe'; -import { env } from '@/env.mjs'; -import { db } from '@comp/db'; -import { Prisma } from '@comp/db/types'; -import { STRIPE_SUB_CACHE } from './stripeDataToKv.type'; - -/** - * Syncs Stripe subscription data to the database. - * This is called by webhooks when subscription status changes. - */ -export async function syncStripeDataToKV(customerId: string): Promise { - try { - // Find organization by Stripe customer ID - const organization = await db.organization.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!organization) { - console.error(`[STRIPE] No organization found for customer ${customerId}`); - return { status: 'none' }; - } - - // Fetch latest subscription data from Stripe - const subscriptions = await stripe.subscriptions.list({ - customer: customerId, - limit: 1, - status: 'all', - expand: ['data.default_payment_method'], - }); - - if (subscriptions.data.length === 0) { - // No subscription - update organization - await db.organization.update({ - where: { id: organization.id }, - data: { - subscriptionType: 'NONE', - stripeSubscriptionData: Prisma.JsonNull, - }, - }); - - const subData: STRIPE_SUB_CACHE = { status: 'none' }; - return subData; - } - - // If a user can have multiple subscriptions, that's your problem - const subscription = subscriptions.data[0]; - - // Handle cases where subscription items might be missing or malformed - const firstItem = subscription.items.data[0]; - if (!firstItem) { - console.error('[STRIPE] Subscription has no items:', subscription.id); - const subData: STRIPE_SUB_CACHE = { status: 'none' }; - await db.organization.update({ - where: { id: organization.id }, - data: { - subscriptionType: 'NONE', - stripeSubscriptionData: Prisma.JsonNull, - }, - }); - return subData; - } - - const priceId = firstItem.price?.id; - let priceDetails = null; - let productDetails = null; - - if (priceId) { - try { - const price = await stripe.prices.retrieve(priceId, { expand: ['product'] }); - if (price.product && typeof price.product === 'object' && !price.product.deleted) { - priceDetails = { - nickname: price.nickname, - unit_amount: price.unit_amount, - currency: price.currency, - interval: price.recurring?.interval ?? null, - }; - productDetails = { - name: price.product.name, - }; - } - } catch (priceError) { - console.error(`[STRIPE] Failed to retrieve price ${priceId} or its product:`, priceError); - } - } - - // Build subscription data - let paymentMethodData = null; - - // First try to get payment method from subscription - if ( - subscription.default_payment_method && - typeof subscription.default_payment_method !== 'string' - ) { - paymentMethodData = { - brand: subscription.default_payment_method.card?.brand ?? null, - last4: subscription.default_payment_method.card?.last4 ?? null, - }; - } - - // If no payment method on subscription and it's a trial, check customer's payment methods - if (!paymentMethodData && subscription.status === 'trialing') { - try { - const customer = await stripe.customers.retrieve(customerId, { - expand: ['default_source', 'invoice_settings.default_payment_method'], - }); - - if (typeof customer !== 'string' && !customer.deleted) { - // Check for default payment method on customer - if (customer.invoice_settings?.default_payment_method) { - const pmId = - typeof customer.invoice_settings.default_payment_method === 'string' - ? customer.invoice_settings.default_payment_method - : customer.invoice_settings.default_payment_method.id; - - const paymentMethod = await stripe.paymentMethods.retrieve(pmId); - paymentMethodData = { - brand: paymentMethod.card?.brand ?? null, - last4: paymentMethod.card?.last4 ?? null, - }; - } else { - // If no default payment method, get the first card from the customer - const paymentMethods = await stripe.paymentMethods.list({ - customer: customerId, - type: 'card', - limit: 1, - }); - - if (paymentMethods.data.length > 0) { - const pm = paymentMethods.data[0]; - paymentMethodData = { - brand: pm.card?.brand ?? null, - last4: pm.card?.last4 ?? null, - }; - console.log( - `[STRIPE] Found payment method for trial: ${pm.card?.brand} •••• ${pm.card?.last4}`, - ); - } - } - } - } catch (error) { - console.error('[STRIPE] Error fetching customer payment method:', error); - } - } - - // If still no payment method found for any subscription status, try listing payment methods - if (!paymentMethodData) { - try { - const paymentMethods = await stripe.paymentMethods.list({ - customer: customerId, - type: 'card', - limit: 1, - }); - - if (paymentMethods.data.length > 0) { - const pm = paymentMethods.data[0]; - paymentMethodData = { - brand: pm.card?.brand ?? null, - last4: pm.card?.last4 ?? null, - }; - console.log(`[STRIPE] Found payment method: ${pm.card?.brand} •••• ${pm.card?.last4}`); - } - } catch (error) { - console.error('[STRIPE] Error listing payment methods:', error); - } - } - - const subData: STRIPE_SUB_CACHE = { - subscriptionId: subscription.id, - status: subscription.status, - priceId: firstItem.price?.id ?? null, - currentPeriodEnd: firstItem.current_period_end ?? null, - currentPeriodStart: firstItem.current_period_start ?? null, - cancelAtPeriodEnd: subscription.cancel_at_period_end, - price: priceDetails, - product: productDetails, - paymentMethod: paymentMethodData, - }; - - // Determine subscription type based on price ID - const starterPriceIds = [ - env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_MONTHLY_PRICE_ID, - env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_YEARLY_PRICE_ID, - ].filter(Boolean); - - const subscriptionType = starterPriceIds.includes(firstItem.price?.id ?? '') - ? 'STARTER' - : 'MANAGED'; - - // Update organization with subscription data - await db.organization.update({ - where: { id: organization.id }, - data: { - subscriptionType, - stripeSubscriptionData: subData as any, // Cast for Prisma Json type - }, - }); - - console.log( - `[STRIPE] Updated org ${organization.id} with ${subscriptionType} subscription status: ${subscription.status}`, - ); - return subData; - } catch (error) { - console.error('[STRIPE] Error syncing subscription data to DB:', error); - - // Return a safe default state on error - const errorData: STRIPE_SUB_CACHE = { status: 'none' }; - return errorData; - } -} diff --git a/apps/app/src/app/api/stripe/webhook/SLACK_NOTIFICATIONS.md b/apps/app/src/app/api/stripe/webhook/SLACK_NOTIFICATIONS.md deleted file mode 100644 index 7678b31be..000000000 --- a/apps/app/src/app/api/stripe/webhook/SLACK_NOTIFICATIONS.md +++ /dev/null @@ -1,64 +0,0 @@ -# Stripe Webhook Slack Notifications - -This webhook sends sales notifications to Slack when important Stripe events occur. - -## Events Tracked - -Each notification is compact and displays on a single line with color coding: - -1. **New Subscription** (💰 Green #36C537) - - Format: `💰 New Subscription | OrgName: email@example.com • $99.00: Monthly` - - Triggered when a customer starts a paid subscription directly - -2. **New Trial Started** (🎉 Blue #0084FF) - - Format: `🎉 New Trial Started | OrgName: email@example.com • $990.00: Yearly` - - Triggered when a customer starts a trial subscription - -3. **Trial Converted to Paid** (🚀 Purple #9F40E6) - - Format: `🚀 Trial Converted to Paid | OrgName: email@example.com • $99.00: Monthly` - - Triggered when a trial subscription converts to an active paid subscription - -4. **Trial/Subscription Cancelled** (❌ Red #DC3545) - - Format: `❌ Subscription Cancelled | OrgName: email@example.com • $99.00 Monthly: Ends 12/31/2024` - - Triggered when a customer cancels their trial or subscription - -5. **Subscription Ended** (🚫 Dark Red #8B0000) - - Format: `🚫 Subscription Ended | OrgName: email@example.com • Reason: Cancelled` - - Triggered when a subscription actually ends - -## Setup - -1. Add the Slack webhook URL to your environment variables: - - ``` - SLACK_SALES_WEBHOOK=https://hooks.slack.com/services/YOUR/WEBHOOK/URL - ``` - -2. The webhook will automatically send notifications to your Slack channel when these events occur. - -## Notification Format - -Each notification includes: - -- Color-coded attachment for quick visual identification -- Organization name -- Customer email (organization owner) - **clickable link to Stripe dashboard** -- Relevant details (amount, plan, dates, etc.) - -The customer email is displayed as a clickable link that takes you directly to the customer's page in Stripe Dashboard (e.g., `https://dashboard.stripe.com/customers/cus_XXXXX`) - -## Testing - -To test the webhook locally, you can use Stripe CLI: - -```bash -stripe listen --forward-to localhost:3000/api/stripe/webhook -``` - -Then trigger test events: - -```bash -stripe trigger checkout.session.completed -stripe trigger customer.subscription.updated -stripe trigger customer.subscription.deleted -``` diff --git a/apps/app/src/app/api/stripe/webhook/route.ts b/apps/app/src/app/api/stripe/webhook/route.ts deleted file mode 100644 index 9d00238ae..000000000 --- a/apps/app/src/app/api/stripe/webhook/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { stripe } from '@/actions/organization/lib/stripe'; -import { headers } from 'next/headers'; -import { after, NextResponse } from 'next/server'; -import Stripe from 'stripe'; -import { syncStripeDataToKV } from '../syncStripeDataToKv'; -import { handleStripeEventNotification } from './slack-notifications'; - -const allowedEvents: Stripe.Event.Type[] = [ - 'checkout.session.completed', - 'customer.subscription.created', - 'customer.subscription.updated', - 'customer.subscription.deleted', - 'customer.subscription.paused', - 'customer.subscription.resumed', - 'customer.subscription.pending_update_applied', - 'customer.subscription.pending_update_expired', - 'customer.subscription.trial_will_end', - 'invoice.paid', - 'invoice.payment_failed', - 'invoice.payment_action_required', - 'invoice.upcoming', - 'invoice.marked_uncollectible', - 'invoice.payment_succeeded', - 'payment_intent.succeeded', - 'payment_intent.payment_failed', - 'payment_intent.canceled', -]; - -async function processEvent(event: Stripe.Event) { - // Skip processing if the event isn't one I'm tracking - if (!allowedEvents.includes(event.type)) return; - - // All the events I track have a customerId - const { customer: customerId } = event?.data?.object as { - customer: string; - }; - - // This helps make it typesafe and also lets me know if my assumption is wrong - if (typeof customerId !== 'string') { - throw new Error(`[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}`); - } - - // Sync data to KV first - const subscriptionData = await syncStripeDataToKV(customerId); - - // Send Slack notifications for relevant events - const notificationEvents = [ - 'checkout.session.completed', - 'customer.subscription.updated', - 'customer.subscription.deleted', - ]; - - if (notificationEvents.includes(event.type)) { - await handleStripeEventNotification(event, subscriptionData, customerId); - } - - return subscriptionData; -} - -export async function POST(req: Request) { - const body = await req.text(); - const signature = (await headers()).get('Stripe-Signature'); - - if (!signature) return NextResponse.json({}, { status: 400 }); - - try { - const event = stripe.webhooks.constructEvent( - body, - signature, - process.env.STRIPE_WEBHOOK_SECRET!, - ); - - // Use after() to process the event without blocking the response - after(async () => { - try { - await processEvent(event); - } catch (error) { - console.error('[STRIPE HOOK] Error processing event in background', error); - } - }); - - return NextResponse.json({ received: true }); - } catch (error) { - console.error('[STRIPE HOOK] Error constructing event', error); - return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 400 }); - } -} diff --git a/apps/app/src/app/api/stripe/webhook/slack-notifications.ts b/apps/app/src/app/api/stripe/webhook/slack-notifications.ts deleted file mode 100644 index 3fcaddeab..000000000 --- a/apps/app/src/app/api/stripe/webhook/slack-notifications.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { stripe } from '@/actions/organization/lib/stripe'; -import { db } from '@comp/db'; -import Stripe from 'stripe'; -import { STRIPE_SUB_CACHE } from '../stripeDataToKv.type'; - -interface SlackBlock { - type: string; - text?: any; - fields?: any[]; -} - -interface NotificationConfig { - title: string; - color: string; - fields: Array<{ label: string; value: string }>; - extraText?: string; -} - -/** - * Send notification to Slack sales channel - */ -export async function sendSlackNotification(blocks: SlackBlock[], color?: string) { - const webhookUrl = process.env.SLACK_SALES_WEBHOOK; - - if (!webhookUrl) { - console.log('[SLACK] Sales notifications webhook not configured'); - return; - } - - try { - const payload = color - ? { - attachments: [ - { - color, - blocks, - }, - ], - } - : { blocks }; - - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - console.error('[SLACK] Failed to send notification:', response.statusText); - } - } catch (error) { - console.error('[SLACK] Error sending notification:', error); - } -} - -/** - * Format currency amount for display - */ -function formatCurrency(amount: number | null | undefined, currency: string = 'usd'): string { - if (!amount) return '$0.00'; - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency.toUpperCase(), - }).format(amount / 100); -} - -/** - * Get organization and customer details for Slack notifications - */ -async function getCustomerDetails(customerId: string) { - try { - const organization = await db.organization.findFirst({ - where: { stripeCustomerId: customerId }, - include: { - members: { - include: { user: true }, - where: { role: 'OWNER' }, - take: 1, - }, - }, - }); - - const customer = await stripe.customers.retrieve(customerId); - - if (typeof customer === 'string' || customer.deleted) { - return null; - } - - return { - organization, - customer, - ownerEmail: organization?.members[0]?.user?.email || customer.email || 'Unknown', - organizationName: organization?.name || customer.name || 'Unknown Organization', - }; - } catch (error) { - console.error('[STRIPE] Error getting customer details:', error); - return null; - } -} - -/** - * Build Slack notification blocks - compact version - */ -function buildNotificationBlocks(config: NotificationConfig): SlackBlock[] { - // Combine all info into a single compact section - const blocks: SlackBlock[] = [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `${config.title}\n${config.fields.map((f) => `*${f.label}:* ${f.value}`).join(' • ')}${config.extraText ? `\n${config.extraText}` : ''}`, - }, - }, - ]; - - return blocks; -} - -/** - * Handle Stripe events and send Slack notifications - */ -export async function handleStripeEventNotification( - event: Stripe.Event, - subscriptionData: STRIPE_SUB_CACHE, - customerId: string, -) { - const customerDetails = await getCustomerDetails(customerId); - if (!customerDetails) return; - - // Create clickable email with Stripe dashboard link - const stripeCustomerUrl = `https://dashboard.stripe.com/customers/${customerId}`; - const clickableEmail = `<${stripeCustomerUrl}|${customerDetails.ownerEmail}>`; - - switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as Stripe.Checkout.Session; - - if (session.mode === 'subscription') { - const subscription = await stripe.subscriptions.retrieve(session.subscription as string); - const isTrialing = subscription.status === 'trialing'; - - // Get amount and interval - // For trials, get the price from the subscription, not the session (which would be $0) - const price = subscription.items.data[0]?.price; - const amount = isTrialing - ? formatCurrency(price?.unit_amount, price?.currency || 'usd') - : formatCurrency(session.amount_total, session.currency || 'usd'); - - const interval = price?.recurring?.interval === 'year' ? 'Yearly' : 'Monthly'; - - const config: NotificationConfig = isTrialing - ? { - title: `🎉 New Trial Started`, - color: '#0084FF', - fields: [ - { label: customerDetails.organizationName, value: clickableEmail }, - { label: amount, value: interval }, - { label: 'Subscription ID', value: subscription.id }, - ], - } - : { - title: `💰 New Subscription`, - color: '#36C537', - fields: [ - { label: customerDetails.organizationName, value: clickableEmail }, - { label: amount, value: interval }, - { label: 'Subscription ID', value: subscription.id }, - ], - }; - - await sendSlackNotification(buildNotificationBlocks(config), config.color); - } - break; - } - - case 'customer.subscription.updated': { - const subscription = event.data.object as Stripe.Subscription; - const previousAttributes = event.data.previous_attributes as any; - - // Trial conversion - if (previousAttributes?.status === 'trialing' && subscription.status === 'active') { - const price = subscription.items.data[0]?.price; - const amount = formatCurrency(price?.unit_amount, price?.currency || 'usd'); - const billingInterval = price?.recurring?.interval === 'year' ? 'Yearly' : 'Monthly'; - - const config: NotificationConfig = { - title: '🚀 Trial Converted to Paid', - color: '#9F40E6', - fields: [ - { label: customerDetails.organizationName, value: clickableEmail }, - { label: amount, value: billingInterval }, - { label: 'Subscription ID', value: subscription.id }, - ], - }; - - await sendSlackNotification(buildNotificationBlocks(config), config.color); - } - - // Cancellation - if (!previousAttributes?.cancel_at_period_end && subscription.cancel_at_period_end) { - const cancelDate = new Date( - (subscription as any).current_period_end * 1000, - ).toLocaleDateString(); - - // Get amount and interval - const price = subscription.items.data[0]?.price; - const amount = formatCurrency(price?.unit_amount, price?.currency || 'usd'); - const interval = price?.recurring?.interval === 'year' ? 'Yearly' : 'Monthly'; - - const config: NotificationConfig = { - title: - subscription.status === 'trialing' ? '❌ Trial Cancelled' : '❌ Subscription Cancelled', - color: '#DC3545', - fields: [ - { label: customerDetails.organizationName, value: clickableEmail }, - { label: `${amount} ${interval}`, value: `Ends ${cancelDate}` }, - { label: 'Subscription ID', value: subscription.id }, - ], - }; - - await sendSlackNotification(buildNotificationBlocks(config), config.color); - } - break; - } - - case 'customer.subscription.deleted': { - const subscription = event.data.object as Stripe.Subscription; - const reason = subscription.cancellation_details?.reason || 'Cancelled'; - - const config: NotificationConfig = { - title: '🚫 Subscription Ended', - color: '#8B0000', - fields: [ - { label: customerDetails.organizationName, value: clickableEmail }, - { label: 'Reason', value: reason }, - { label: 'Subscription ID', value: subscription.id }, - ], - }; - - await sendSlackNotification(buildNotificationBlocks(config), config.color); - break; - } - } -} diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index 0d5bb559b..10df126f9 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -1,5 +1,3 @@ -import { BookingDialog } from '@/app/(app)/upgrade/[orgId]/components/BookingDialog'; -import { STRIPE_SUB_CACHE } from '@/app/api/stripe/stripeDataToKv.type'; import { UserMenu } from '@/components/user-menu'; import { getOrganizations } from '@/data/getOrganizations'; import { auth } from '@/utils/auth'; @@ -10,7 +8,7 @@ import { Suspense } from 'react'; import { AssistantButton } from './ai/chat-button'; import { MobileMenu } from './mobile-menu'; -export async function Header({ subscription }: { subscription: STRIPE_SUB_CACHE }) { +export async function Header() { const session = await auth.api.getSession({ headers: await headers(), }); @@ -30,10 +28,6 @@ export async function Header({ subscription }: { subscription: STRIPE_SUB_CACHE
-
- -
- }> diff --git a/apps/app/src/context/subscription-context.tsx b/apps/app/src/context/subscription-context.tsx deleted file mode 100644 index d8695cd8e..000000000 --- a/apps/app/src/context/subscription-context.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { STRIPE_SUB_CACHE } from '@/app/api/stripe/stripeDataToKv.type'; -import { createContext, useContext } from 'react'; - -interface SubscriptionContextValue { - subscription: STRIPE_SUB_CACHE; - hasActiveSubscription: boolean; - isTrialing: boolean; - isSelfServe: boolean; -} - -const SubscriptionContext = createContext(undefined); - -export function SubscriptionProvider({ - children, - subscription, -}: { - children: React.ReactNode; - subscription: STRIPE_SUB_CACHE; -}) { - const hasActiveSubscription = - subscription.status === 'active' || - subscription.status === 'trialing' || - subscription.status === 'self-serve'; - - const isTrialing = subscription.status === 'trialing'; - const isSelfServe = subscription.status === 'self-serve'; - - return ( - - {children} - - ); -} - -export function useSubscription() { - const context = useContext(SubscriptionContext); - if (context === undefined) { - throw new Error('useSubscription must be used within a SubscriptionProvider'); - } - return context; -} diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs index 1ff1ae00b..ac5e93e1b 100644 --- a/apps/app/src/env.mjs +++ b/apps/app/src/env.mjs @@ -13,8 +13,6 @@ export const env = createEnv({ RESEND_API_KEY: z.string(), UPSTASH_REDIS_REST_URL: z.string().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().optional(), - STRIPE_SECRET_KEY: z.string().min(1), - STRIPE_WEBHOOK_SECRET: z.string().min(1), DISCORD_WEBHOOK_URL: z.string().optional(), TRIGGER_SECRET_KEY: z.string().optional(), TRIGGER_API_KEY: z.string().optional(), @@ -49,10 +47,6 @@ export const env = createEnv({ NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), NEXT_PUBLIC_VERCEL_URL: z.string().optional(), - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_MONTHLY_PRICE_ID: z.string().optional(), - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_YEARLY_PRICE_ID: z.string().optional(), - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_MONTHLY_PRICE_ID: z.string().optional(), - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_YEARLY_PRICE_ID: z.string().optional(), NEXT_PUBLIC_IS_DUB_ENABLED: z.string().optional(), NEXT_PUBLIC_GTM_ID: z.string().optional(), NEXT_PUBLIC_LINKEDIN_PARTNER_ID: z.string().optional(), @@ -71,8 +65,6 @@ export const env = createEnv({ RESEND_API_KEY: process.env.RESEND_API_KEY, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, - STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, - STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, TRIGGER_SECRET_KEY: process.env.TRIGGER_SECRET_KEY, TRIGGER_API_KEY: process.env.TRIGGER_API_KEY, @@ -97,14 +89,6 @@ export const env = createEnv({ ZAPIER_HUBSPOT_WEBHOOK_URL: process.env.ZAPIER_HUBSPOT_WEBHOOK_URL, FLEET_URL: process.env.FLEET_URL, FLEET_TOKEN: process.env.FLEET_TOKEN, - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_MONTHLY_PRICE_ID: - process.env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_MONTHLY_PRICE_ID, - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_YEARLY_PRICE_ID: - process.env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_MANAGED_YEARLY_PRICE_ID, - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_MONTHLY_PRICE_ID: - process.env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_MONTHLY_PRICE_ID, - NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_YEARLY_PRICE_ID: - process.env.NEXT_PUBLIC_STRIPE_SUBSCRIPTION_STARTER_YEARLY_PRICE_ID, DUB_API_KEY: process.env.DUB_API_KEY, DUB_REFER_URL: process.env.DUB_REFER_URL, NEXT_PUBLIC_IS_DUB_ENABLED: process.env.NEXT_PUBLIC_IS_DUB_ENABLED, diff --git a/apps/app/src/middleware.test.ts b/apps/app/src/middleware.test.ts index 9e67b54b4..ccdde67cd 100644 --- a/apps/app/src/middleware.test.ts +++ b/apps/app/src/middleware.test.ts @@ -17,12 +17,6 @@ import { createMockRequest } from '@/test-utils/helpers/middleware'; import { createMockSession, mockAuth, setupAuthMocks } from '@/test-utils/mocks/auth'; import { mockDb } from '@/test-utils/mocks/db'; -// Mock getSubscriptionData -const mockGetSubscriptionData = vi.fn(); -vi.mock('@/app/api/stripe/getSubscriptionData', () => ({ - getSubscriptionData: mockGetSubscriptionData, -})); - vi.mock('next/headers', () => ({ headers: vi.fn( () => @@ -58,18 +52,18 @@ describe('Middleware', () => { it('should allow authenticated users to access their org', async () => { // Arrange - const { session, user } = setupAuthMocks(); + const { user } = setupAuthMocks(); - // Also mock that user is a member of the org - mockDb.member.findFirst.mockResolvedValue({ - id: 'member_123', - userId: user!.id, - organizationId: 'org_123', - role: 'owner', + // Mock that the organization has access + mockDb.organization.findFirst.mockResolvedValue({ + hasAccess: true, }); - // Mock valid subscription - mockGetSubscriptionData.mockResolvedValue({ status: 'active' }); + // Mock that onboarding is completed + mockDb.organization.findUnique.mockResolvedValue({ + id: 'org_123', + onboardingCompleted: true, + }); const request = createMockRequest('/org_123/dashboard'); @@ -160,15 +154,17 @@ describe('Middleware', () => { }); }); - describe('Subscription Validation', () => { + describe('Access Control (hasAccess)', () => { beforeEach(() => { - // Set up authenticated user for subscription tests + // Set up authenticated user for access control tests setupAuthMocks(); }); - it('should block access to org routes without valid subscription', async () => { + it('should block access to org routes without hasAccess', async () => { // Arrange - mockGetSubscriptionData.mockResolvedValue({ status: 'canceled' }); + mockDb.organization.findFirst.mockResolvedValue({ + hasAccess: false, + }); const request = createMockRequest('/org_123/dashboard'); @@ -180,36 +176,36 @@ describe('Middleware', () => { expect(response.headers.get('location')).toBe('http://localhost:3000/upgrade/org_123'); }); - it('should allow access with valid subscription statuses', async () => { + it('should allow access with hasAccess = true', async () => { // Arrange - const validStatuses = ['active', 'trialing', 'self-serve', 'past_due', 'paused']; + mockDb.organization.findFirst.mockResolvedValue({ + hasAccess: true, + }); - for (const status of validStatuses) { - mockGetSubscriptionData.mockResolvedValue({ status }); + // Mock onboarding completed so we don't get redirected to onboarding + mockDb.organization.findUnique.mockResolvedValue({ + id: 'org_123', + onboardingCompleted: true, + }); - const request = createMockRequest('/org_123/dashboard'); + const request = createMockRequest('/org_123/dashboard'); - // Act - const response = await middleware(request); + // Act + const response = await middleware(request); - // Assert - expect(response.status).toBe(200); - } + // Assert + expect(response.status).toBe(200); }); - it('should bypass subscription check for exempt routes', async () => { + it('should bypass access check for unprotected routes', async () => { // Arrange - const exemptRoutes = [ - '/org_123/settings/billing', - '/org_123/upgrade', - '/setup', - '/auth', - '/invite/abc123', - ]; + const unprotectedRoutes = ['/upgrade/org_123', '/setup', '/auth', '/invite/abc123']; - mockGetSubscriptionData.mockResolvedValue({ status: 'canceled' }); + mockDb.organization.findFirst.mockResolvedValue({ + hasAccess: false, + }); - for (const route of exemptRoutes) { + for (const route of unprotectedRoutes) { const request = createMockRequest(route); // Act @@ -224,6 +220,42 @@ describe('Middleware', () => { } } }); + + it('should handle organizations that do not exist', async () => { + // Arrange + mockDb.organization.findFirst.mockResolvedValue(null); + + const request = createMockRequest('/org_123/dashboard'); + + // Act + const response = await middleware(request); + + // Assert + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://localhost:3000/upgrade/org_123'); + }); + + it('should preserve query parameters when redirecting to upgrade', async () => { + // Arrange + mockDb.organization.findFirst.mockResolvedValue({ + hasAccess: false, + }); + + const request = createMockRequest('/org_123/dashboard', { + searchParams: { + redirect: 'policies', + tab: 'active', + }, + }); + + // Act + const response = await middleware(request); + + // Assert + expect(response.status).toBe(307); + const location = response.headers.get('location'); + expect(location).toBe('http://localhost:3000/upgrade/org_123?redirect=policies&tab=active'); + }); }); describe('Session Healing', () => { @@ -235,10 +267,9 @@ describe('Middleware', () => { mockDb.organization.findFirst.mockResolvedValue({ id: 'org_123', name: 'Test Org', + hasAccess: true, }); - mockGetSubscriptionData.mockResolvedValue({ status: 'active' }); - const request = createMockRequest('/org_123/dashboard'); // Act @@ -255,12 +286,15 @@ describe('Middleware', () => { describe('Onboarding Completion', () => { beforeEach(() => { - // Set up authenticated user with valid subscription for onboarding tests + // Set up authenticated user with access for onboarding tests setupAuthMocks(); - mockGetSubscriptionData.mockResolvedValue({ status: 'active' }); + // Mock that the organization has access (required for onboarding checks) + mockDb.organization.findFirst.mockResolvedValue({ + hasAccess: true, + }); }); - it('should redirect to /onboarding when subscription is active but onboarding not completed', async () => { + it('should redirect to /onboarding when user has access but onboarding not completed', async () => { // Arrange mockDb.organization.findUnique.mockResolvedValue({ id: 'org_123', @@ -334,16 +368,16 @@ describe('Middleware', () => { ); }); - it('should not check onboarding for subscription-exempt routes', async () => { + it('should not check onboarding for unprotected routes', async () => { // Arrange mockDb.organization.findUnique.mockResolvedValue({ id: 'org_123', onboardingCompleted: false, }); - const exemptRoutes = ['/upgrade/org_123', '/onboarding/org_123', '/auth', '/setup']; + const unprotectedRoutes = ['/upgrade/org_123', '/onboarding/org_123', '/auth', '/setup']; - for (const route of exemptRoutes) { + for (const route of unprotectedRoutes) { const request = createMockRequest(route); // Act diff --git a/apps/app/src/middleware.ts b/apps/app/src/middleware.ts index 23136cc34..dfe6989a5 100644 --- a/apps/app/src/middleware.ts +++ b/apps/app/src/middleware.ts @@ -1,4 +1,3 @@ -import { getSubscriptionData } from '@/app/api/stripe/getSubscriptionData'; import { auth } from '@/utils/auth'; import { db } from '@comp/db'; import { headers } from 'next/headers'; @@ -12,18 +11,11 @@ export const config = { ], }; -// Routes that don't require subscription -const SUBSCRIPTION_EXEMPT_ROUTES = [ - '/auth', - '/invite', - '/setup', - '/upgrade', - '/settings/billing', - '/onboarding', -]; - -function isSubscriptionExempt(pathname: string): boolean { - return SUBSCRIPTION_EXEMPT_ROUTES.some((route) => pathname.includes(route)); +// Unprotected routes +const UNPROTECTED_ROUTES = ['/auth', '/invite', '/setup', '/upgrade']; + +function isUnprotectedRoute(pathname: string): boolean { + return UNPROTECTED_ROUTES.some((route) => pathname.includes(route)); } export async function middleware(request: NextRequest) { @@ -107,25 +99,20 @@ export async function middleware(request: NextRequest) { } } - // Check subscription status for organization routes const orgMatch = nextUrl.pathname.match(/^\/org_[a-zA-Z0-9]+/); - if (orgMatch && !isSubscriptionExempt(nextUrl.pathname)) { - const orgId = orgMatch[0].substring(1); // Remove leading slash - console.log( - `[MIDDLEWARE] Checking subscription for path: ${nextUrl.pathname}, orgId: ${orgId}`, - ); - - // Check subscription status (handles both Stripe and self-serve) - const subscription = await getSubscriptionData(orgId); - - // Allow access for valid statuses - const validStatuses = ['active', 'trialing', 'self-serve', 'past_due', 'paused']; - - // Redirect to upgrade page only if they have no valid subscription - if (!subscription || !validStatuses.includes(subscription.status)) { - console.log( - `[MIDDLEWARE] Redirecting org ${orgId} to upgrade. Status: ${subscription?.status}`, - ); + if (orgMatch && !isUnprotectedRoute(nextUrl.pathname)) { + const orgId = orgMatch[0].substring(1); + + const foundOrg = await db.organization.findFirst({ + where: { id: orgId }, + select: { hasAccess: true }, + }); + + const hasAccess = foundOrg?.hasAccess; + + console.log(`[MIDDLEWARE] Checking access for org ${orgId}: ${hasAccess}`); + + if (!hasAccess) { const url = new URL(`/upgrade/${orgId}`, request.url); // Preserve existing search params nextUrl.searchParams.forEach((value, key) => { @@ -134,7 +121,7 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(url); } - // NEW: Check onboarding status for paid users + // Check onboarding status for paid users const org = await db.organization.findUnique({ where: { id: orgId }, select: { onboardingCompleted: true }, diff --git a/packages/db/prisma/migrations/20250714153009_remove_stripe_and_add_has_access/migration.sql b/packages/db/prisma/migrations/20250714153009_remove_stripe_and_add_has_access/migration.sql new file mode 100644 index 000000000..c5f747a1c --- /dev/null +++ b/packages/db/prisma/migrations/20250714153009_remove_stripe_and_add_has_access/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - You are about to drop the column `hadCall` on the `Organization` table. All the data in the column will be lost. + - You are about to drop the column `stripeCustomerId` on the `Organization` table. All the data in the column will be lost. + - You are about to drop the column `stripeSubscriptionData` on the `Organization` table. All the data in the column will be lost. + - You are about to drop the column `subscriptionType` on the `Organization` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Organization_stripeCustomerId_idx"; + +-- DropIndex +DROP INDEX "Organization_subscriptionType_idx"; + +-- Add hasAccess column +ALTER TABLE "Organization" ADD COLUMN "hasAccess" BOOLEAN NOT NULL DEFAULT false; + +-- Update all organizations to have access depending on subscription type +UPDATE "Organization" SET "hasAccess" = true WHERE "subscriptionType" = 'FREE'; +UPDATE "Organization" SET "hasAccess" = true WHERE "subscriptionType" = 'STARTER'; +UPDATE "Organization" SET "hasAccess" = true WHERE "subscriptionType" = 'MANAGED'; + +-- Drop columns +ALTER TABLE "Organization" DROP COLUMN "hadCall"; +ALTER TABLE "Organization" DROP COLUMN "stripeCustomerId"; +ALTER TABLE "Organization" DROP COLUMN "stripeSubscriptionData"; +ALTER TABLE "Organization" DROP COLUMN "subscriptionType"; + +-- Drop subscription type enum +DROP TYPE "SubscriptionType"; \ No newline at end of file diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index f009564ea..99b63d01a 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -1,19 +1,14 @@ model Organization { - id String @id @default(dbgenerated("generate_prefixed_cuid('org'::text)")) - name String - slug String @unique @default(dbgenerated("generate_prefixed_cuid('slug'::text)")) - logo String? - createdAt DateTime @default(now()) - metadata String? - stripeCustomerId String? - onboarding Onboarding? - website String? - - // Subscription tracking - onboardingCompleted Boolean @default(false) - subscriptionType SubscriptionType @default(NONE) - stripeSubscriptionData Json? - hadCall Boolean @default(false) + id String @id @default(dbgenerated("generate_prefixed_cuid('org'::text)")) + name String + slug String @unique @default(dbgenerated("generate_prefixed_cuid('slug'::text)")) + logo String? + createdAt DateTime @default(now()) + metadata String? + onboarding Onboarding? + website String? + onboardingCompleted Boolean @default(false) + hasAccess Boolean @default(false) // FleetDM fleetDmLabelId Int? @@ -36,13 +31,4 @@ model Organization { context Context[] @@index([slug]) - @@index([subscriptionType]) - @@index([stripeCustomerId]) -} - -enum SubscriptionType { - NONE - FREE - STARTER - MANAGED } diff --git a/turbo.json b/turbo.json index d919ccf5e..d2ba97916 100644 --- a/turbo.json +++ b/turbo.json @@ -16,8 +16,6 @@ "UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN", "NEXT_PUBLIC_OPENPANEL_CLIENT_ID", - "STRIPE_SECRET_KEY", - "STRIPE_WEBHOOK_SECRET", "RESEND_API_KEY", "RESEND_AUDIENCE_ID", "NEXT_PUBLIC_GOOGLE_TAG_ID", From 4b6c023d1dda1713431b79c644d92182d3240260 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 14 Jul 2025 13:28:38 -0400 Subject: [PATCH 2/6] chore: remove lingering failed files --- .../actions/organization/choose-self-serve.ts | 46 ----- .../[orgId]/components/UpgradeBanner.tsx | 91 --------- .../src/app/(app)/onboarding/[orgId]/page.tsx | 2 +- .../[orgId]/components/pricing-card.tsx | 187 ------------------ apps/app/src/app/api/auth/test-db/route.ts | 2 +- 5 files changed, 2 insertions(+), 326 deletions(-) delete mode 100644 apps/app/src/actions/organization/choose-self-serve.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/components/UpgradeBanner.tsx delete mode 100644 apps/app/src/app/(app)/upgrade/[orgId]/components/pricing-card.tsx diff --git a/apps/app/src/actions/organization/choose-self-serve.ts b/apps/app/src/actions/organization/choose-self-serve.ts deleted file mode 100644 index bacafbb80..000000000 --- a/apps/app/src/actions/organization/choose-self-serve.ts +++ /dev/null @@ -1,46 +0,0 @@ -'use server'; - -import { authWithOrgAccessClient } from '@/actions/safe-action'; -import { db } from '@comp/db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -const chooseSelfServeSchema = z.object({ - organizationId: z.string(), -}); - -export const chooseSelfServeAction = authWithOrgAccessClient - .inputSchema(chooseSelfServeSchema) - .metadata({ - name: 'choose-self-serve', - track: { - event: 'choose-self-serve', - description: 'User chose the self-serve (free) plan', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { organizationId } = parsedInput; - const { member } = ctx; - - // Update the organization to mark that they chose self-serve - await db.organization.update({ - where: { - id: organizationId, - }, - data: { - subscriptionType: 'FREE', - }, - }); - - // Revalidate the path - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - revalidatePath(path); - - return { - success: true, - }; - }); diff --git a/apps/app/src/app/(app)/[orgId]/components/UpgradeBanner.tsx b/apps/app/src/app/(app)/[orgId]/components/UpgradeBanner.tsx deleted file mode 100644 index fba5b59eb..000000000 --- a/apps/app/src/app/(app)/[orgId]/components/UpgradeBanner.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { Button } from '@comp/ui/button'; -import { ArrowRight, Sparkles } from 'lucide-react'; -import Link from 'next/link'; -import { useState } from 'react'; - -interface UpgradeBannerProps { - subscriptionType: 'NONE' | 'FREE' | 'STARTER' | 'MANAGED'; - organizationId: string; -} - -export function UpgradeBanner({ subscriptionType, organizationId }: UpgradeBannerProps) { - const [isDismissed, setIsDismissed] = useState(false); - - // Check if we should show the banner based on subscription type - const shouldShowBanner = subscriptionType === 'FREE' || subscriptionType === 'STARTER'; - - // Handle dismiss - const handleDismiss = () => { - setIsDismissed(true); - }; - - // Don't show if dismissed or not eligible - if (isDismissed || !shouldShowBanner) { - return null; - } - - // Use consistent message for all users who see the banner - const bannerMessage = - 'Compliance taking too much time? Let us handle it. 14 days to audit-ready.'; - - return ( -
- {/* Background with gradient overlay */} -
- - {/* Animated gradient background layer */} -
-
-
- - {/* Shimmer effect overlay */} -
-
-
- - {/* Border glow */} -
- - {/* Content */} -
-
-
- -

{bannerMessage}

-
-
- - - - -
-
-
-
- ); -} diff --git a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx index 01538cdcb..747faa504 100644 --- a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx @@ -51,7 +51,7 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) { } // Check if they have a subscription - if (organization.subscriptionType === 'NONE') { + if (!organization.hasAccess) { redirect(`/upgrade/${orgId}`); } diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/pricing-card.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/pricing-card.tsx deleted file mode 100644 index 9b8b243af..000000000 --- a/apps/app/src/app/(app)/upgrade/[orgId]/components/pricing-card.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { Badge } from '@comp/ui/badge'; -import { Button } from '@comp/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@comp/ui/card'; -import { ArrowRight, CheckIcon, Loader2 } from 'lucide-react'; -import { PricingCardProps } from '../types/pricing'; - -export const PricingCard = ({ - planType, - onCheckoutUpfront, - onCheckoutMonthly, - title, - description, - annualPrice, - monthlyPrice, - subtitle, - features, - badge, - isExecutingUpfront, - isExecutingMonthly, - isCurrentPlan, - isLoadingSubscription, -}: PricingCardProps) => { - const isPopular = planType === 'MANAGED'; - - return ( - - -
-
- {title} - {badge && !isPopular && ( - - {badge} - - )} -
- {description} -
-
-
- ${annualPrice.toLocaleString()} - /year -
-

- or 12 payments of ${monthlyPrice.toLocaleString()} -

- {subtitle && ( -

{subtitle}

- )} -
-
- -
- - -
    - {features.map((feature, idx) => { - const isEverythingIn = idx === 0 && feature.includes('Everything in'); - const isAuditNote = feature.includes('Pay for your audit'); - - return ( -
  • - {!isEverythingIn && !isAuditNote && ( - - )} - - {feature} - -
  • - ); - })} -
- - {/* Money Back Guarantee Section */} -
-
-
- - - -
-
-

- 14-Day Money Back Guarantee -

-

- Try risk-free. Full refund if not satisfied. -

-
-
-
-
- - - {isCurrentPlan ? ( - - ) : ( -
- - -
- )} -
- - ); -}; diff --git a/apps/app/src/app/api/auth/test-db/route.ts b/apps/app/src/app/api/auth/test-db/route.ts index 61c7cabf1..076a7a1d5 100644 --- a/apps/app/src/app/api/auth/test-db/route.ts +++ b/apps/app/src/app/api/auth/test-db/route.ts @@ -21,7 +21,7 @@ export async function GET() { data: { id: `org_test_${Date.now()}`, name: 'Test DB Org', - subscriptionType: 'FREE', + hasAccess: true, }, }); From 3fab22ffbc4766529c20c940c9fdea5acd6c3619 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 14 Jul 2025 13:29:29 -0400 Subject: [PATCH 3/6] chore: update dependencies and remove stripe references from lock files --- bun.lock | 9 +-------- yarn.lock | 42 ++++++++++++------------------------------ 2 files changed, 13 insertions(+), 38 deletions(-) diff --git a/bun.lock b/bun.lock index 37bb6b3c6..4cea18a2b 100644 --- a/bun.lock +++ b/bun.lock @@ -132,7 +132,6 @@ "remark-parse": "^11.0.0", "resend": "^4.4.1", "sonner": "^2.0.5", - "stripe": "^18.1.0", "three": "^0.177.0", "ts-pattern": "^5.7.0", "use-debounce": "^10.0.4", @@ -3725,7 +3724,7 @@ "pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="], - "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -4087,8 +4086,6 @@ "strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="], - "stripe": ["stripe@18.2.1", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-GwB1B7WSwEBzW4dilgyJruUYhbGMscrwuyHsPUmSRKrGHZ5poSh2oU9XKdii5BFVJzXHn35geRvGJ6R8bYcp8w=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], @@ -4655,8 +4652,6 @@ "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chalk-template/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], @@ -4729,8 +4724,6 @@ "express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], - "express/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], - "extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/yarn.lock b/yarn.lock index ce07d62f5..d47c07724 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1285,14 +1285,14 @@ "@types/conventional-commits-parser" "^5.0.0" chalk "^5.3.0" -"@comp/analytics@^workspace:packages/analytics": +"@comp/analytics@packages/analytics": version "workspace:packages/analytics" resolved "workspace:packages/analytics" dependencies: posthog-js "^1.236.6" posthog-node "^4.14.0" -"@comp/app@^workspace:apps/app", "@comp/app@workspace:*": +"@comp/app@apps/app", "@comp/app@workspace:*": version "workspace:apps/app" resolved "workspace:apps/app" devDependencies: @@ -1330,9 +1330,7 @@ "@browserbasehq/sdk" "^2.5.0" "@calcom/atoms" "^1.0.102-framer" "@calcom/embed-react" "^1.5.3" - dependencies: "@comp/db" "workspace:*" - dependencies: "@date-fns/tz" "^1.2.0" "@dnd-kit/core" "^6.3.1" "@dnd-kit/modifiers" "^9.0.0" @@ -1394,7 +1392,6 @@ remark-parse "^11.0.0" resend "^4.4.1" sonner "^2.0.5" - stripe "^18.1.0" three "^0.177.0" ts-pattern "^5.7.0" use-debounce "^10.0.4" @@ -1403,19 +1400,18 @@ zaraz-ts "^1.2.0" zustand "^5.0.3" -"@comp/db@^workspace:packages/db", "@comp/db@workspace:*": +"@comp/db@packages/db", "@comp/db@workspace:*": version "workspace:packages/db" resolved "workspace:packages/db" devDependencies: "@comp/tsconfig" "workspace:*" - devDependencies: prisma "^6.9.0" ts-node "^10.9.2" typescript "^5.8.3" dependencies: "@prisma/client" "6.9.0" -"@comp/email@^workspace:packages/email": +"@comp/email@packages/email": version "workspace:packages/email" resolved "workspace:packages/email" devDependencies: @@ -1435,7 +1431,7 @@ react-email "^4.0.15" responsive-react-email "^0.0.5" -"@comp/framework-editor@^workspace:apps/framework-editor": +"@comp/framework-editor@apps/framework-editor": version "workspace:apps/framework-editor" resolved "workspace:apps/framework-editor" devDependencies: @@ -1465,7 +1461,7 @@ tippy.js "^6.3.7" zod "3.25.67" -"@comp/integrations@^workspace:packages/integrations": +"@comp/integrations@packages/integrations": version "workspace:packages/integrations" resolved "workspace:packages/integrations" devDependencies: @@ -1488,14 +1484,14 @@ stoppable "^1.1.0" zod "3.25.67" -"@comp/kv@^workspace:packages/kv": +"@comp/kv@packages/kv": version "workspace:packages/kv" resolved "workspace:packages/kv" dependencies: "@upstash/redis" "^1.34.2" server-only "0.0.1" -"@comp/portal@^workspace:apps/portal": +"@comp/portal@apps/portal": version "workspace:apps/portal" resolved "workspace:apps/portal" devDependencies: @@ -1526,7 +1522,7 @@ next "15.4.0-canary.85" react-email "^4.0.15" -"@comp/trust@^workspace:apps/trust": +"@comp/trust@apps/trust": version "workspace:apps/trust" resolved "workspace:apps/trust" devDependencies: @@ -1548,11 +1544,11 @@ lucide-react "^0.518.0" next "15.4.0-canary.85" -"@comp/tsconfig@^workspace:packages/tsconfig", "@comp/tsconfig@workspace:*": +"@comp/tsconfig@packages/tsconfig", "@comp/tsconfig@workspace:*": version "workspace:packages/tsconfig" resolved "workspace:packages/tsconfig" -"@comp/ui@^workspace:packages/ui", "@comp/ui@workspace:*": +"@comp/ui@packages/ui", "@comp/ui@workspace:*": version "workspace:packages/ui" resolved "workspace:packages/ui" devDependencies: @@ -1635,7 +1631,7 @@ use-debounce "^10.0.4" vaul "^0.9.6" -"@comp/utils@^workspace:packages/utils", "@comp/utils@workspace:*": +"@comp/utils@packages/utils", "@comp/utils@workspace:*": version "workspace:packages/utils" resolved "workspace:packages/utils" devDependencies: @@ -14925,13 +14921,6 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" -qs@^6.11.0: - version "6.14.0" - resolved "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz" - integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== - dependencies: - side-channel "^1.1.0" - queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -16579,13 +16568,6 @@ strip-literal@^3.0.0: dependencies: js-tokens "^9.0.1" -stripe@^18.1.0: - version "18.2.1" - resolved "https://registry.npmjs.org/stripe/-/stripe-18.2.1.tgz" - integrity sha512-GwB1B7WSwEBzW4dilgyJruUYhbGMscrwuyHsPUmSRKrGHZ5poSh2oU9XKdii5BFVJzXHn35geRvGJ6R8bYcp8w== - dependencies: - qs "^6.11.0" - strnum@^1.0.5: version "1.1.2" resolved "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz" From c005d6583b9424e0e029924f49a6beb8671b3afd Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 15 Jul 2025 15:42:11 -0400 Subject: [PATCH 4/6] chore: all tests passing --- apps/app/e2e/simple-auth.spec.ts | 77 +- .../e2e/tests/middleware-onboarding.spec.ts | 53 +- apps/app/e2e/tests/onboarding.spec.ts | 6 +- apps/app/e2e/tests/split-onboarding.spec.ts | 679 ++++++++---------- .../components/PostPaymentOnboarding.tsx | 1 + .../components/OnboardingFormActions.tsx | 2 + .../setup/components/OnboardingStepInput.tsx | 62 +- apps/app/src/app/api/auth/test-login/route.ts | 345 ++++++--- apps/app/src/app/page.tsx | 16 +- apps/app/src/test-utils/helpers/middleware.ts | 12 +- packages/ui/src/components/select-pills.tsx | 5 + yarn.lock | 27 +- 12 files changed, 714 insertions(+), 571 deletions(-) diff --git a/apps/app/e2e/simple-auth.spec.ts b/apps/app/e2e/simple-auth.spec.ts index f05298f51..14d4de88b 100644 --- a/apps/app/e2e/simple-auth.spec.ts +++ b/apps/app/e2e/simple-auth.spec.ts @@ -1,70 +1,51 @@ import { expect, test } from '@playwright/test'; +import { authenticateTestUser } from './utils/auth-helpers'; test('simple auth flow', async ({ page, context, browserName }) => { - // Create a test user and authenticate - const testEmail = `test-${Date.now()}@example.com`; - - console.log(`[${browserName}] Starting test with email: ${testEmail}`); - - const response = await context.request.post('http://localhost:3000/api/auth/test-login', { - data: { - email: testEmail, - name: 'Test User', - }, - timeout: 30000, // 30 second timeout + const testEmail = `test-${Date.now()}-${Math.random().toString(36).substring(7)}-${browserName}@example.com`; + + // Authenticate user + await authenticateTestUser(page, { + email: testEmail, + name: 'Test User', + skipOrg: false, + hasAccess: true, }); - // Add debugging for all browsers - if (!response.ok()) { - console.error(`[${browserName}] Test login failed:`, { - status: response.status(), - statusText: response.statusText(), - }); - try { - const body = await response.text(); - console.error(`[${browserName}] Response body:`, body); - } catch (e) { - console.error(`[${browserName}] Could not read response body`); - } - } - - expect(response.ok()).toBeTruthy(); - const data = await response.json(); - expect(data.success).toBe(true); - expect(data.user).toBeDefined(); - expect(data.user.email).toBe(testEmail); - expect(data.user.emailVerified).toBe(true); - // Verify session cookie was set const cookies = await context.cookies(); const sessionCookie = cookies.find((c) => c.name === 'better-auth.session_token'); expect(sessionCookie).toBeDefined(); - expect(sessionCookie?.httpOnly).toBe(true); - // Navigate to auth page - should be redirected since we're authenticated - await page.goto('http://localhost:3000/auth', { waitUntil: 'domcontentloaded' }); + // Navigate to root first + await page.goto('/', { waitUntil: 'domcontentloaded' }); - // Wait for the redirect to happen - // Since we know we should be redirected, wait for URL change - let retries = 0; - const maxRetries = 10; + // Wait for final redirect to complete + await page.waitForTimeout(3000); // Just wait for redirects to settle - while (page.url().includes('/auth') && retries < maxRetries) { - await page.waitForTimeout(500); - retries++; - } + const afterRootUrl = page.url(); + console.log('URL after navigating to root:', afterRootUrl); + + // Now navigate to auth page - should be redirected since we're authenticated + await page.goto('/auth', { waitUntil: 'domcontentloaded' }); + + // Wait for redirect away from auth + await page.waitForURL((url) => !url.toString().includes('/auth'), { timeout: 5000 }); const currentUrl = page.url(); + console.log('Current URL after auth redirect:', currentUrl); - // Verify we're redirected to an authenticated route expect(currentUrl).not.toContain('/auth'); - // Common authenticated routes include /setup, /dashboard, /upgrade, or organization-specific routes + console.log('Current URL after auth redirect:', currentUrl); + + // User should be on one of these authenticated routes const isAuthenticatedRoute = - currentUrl.includes('/setup') || - currentUrl.includes('/dashboard') || + currentUrl.includes('/setup') || // Matches both /setup and /setup/ + currentUrl.match(/\/org_[a-zA-Z0-9]+\//) !== null || // Organization routes currentUrl.includes('/upgrade') || - currentUrl.includes('/org_'); + currentUrl.includes('/no-access') || + currentUrl.includes('/onboarding'); expect(isAuthenticatedRoute).toBeTruthy(); }); diff --git a/apps/app/e2e/tests/middleware-onboarding.spec.ts b/apps/app/e2e/tests/middleware-onboarding.spec.ts index ac2d6c240..bc150af18 100644 --- a/apps/app/e2e/tests/middleware-onboarding.spec.ts +++ b/apps/app/e2e/tests/middleware-onboarding.spec.ts @@ -18,18 +18,23 @@ test.describe('Middleware Onboarding Behavior', () => { }); // Try to access organization page - await page.goto('/'); - - // Should NOT be redirected to onboarding - await page.waitForTimeout(2000); // Give time for any redirects - expect(page.url()).not.toContain('/onboarding'); - - // Should be on an authenticated page (org page, frameworks, etc) - const isOnOrgPage = - page.url().includes('/org_') || - page.url().includes('/frameworks') || - page.url().includes('/setup'); - expect(isOnOrgPage).toBeTruthy(); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + // Wait for all redirects to complete + await page.waitForTimeout(3000); // Just wait for redirects to settle + + const currentUrl = page.url(); + console.log('Current URL after navigation:', currentUrl); + + expect(currentUrl).not.toContain('/onboarding'); + + // Should be on an authenticated page - could be org page or setup (if activeOrgId not set) + const isOnValidPage = + currentUrl.match(/\/org_[a-zA-Z0-9]+\//) !== null || // Organization routes + currentUrl.match(/\/setup\/[a-zA-Z0-9]+/) !== null || // Dynamic setup URLs + currentUrl.includes('/upgrade'); // Upgrade page + + expect(isOnValidPage).toBeTruthy(); }); test('user without org is redirected to setup', async ({ page }) => { @@ -43,19 +48,29 @@ test.describe('Middleware Onboarding Behavior', () => { }); // Navigate to root - await page.goto('/'); + await page.goto('/', { waitUntil: 'domcontentloaded' }); - // Should be redirected to setup - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); - expect(page.url()).toContain('/setup/'); + // Wait for redirects + await page.waitForTimeout(2000); + + const currentUrl = page.url(); + console.log('User without org redirected to:', currentUrl); + + // Should be redirected to setup (with dynamic ID) + expect(currentUrl).toMatch(/\/setup\/[a-zA-Z0-9]+/); }); test('unauthenticated user is redirected to auth', async ({ page }) => { // Try to access protected route without auth - await page.goto('/org_123/frameworks'); + await page.goto('/org_123/frameworks', { waitUntil: 'domcontentloaded' }); + + // Wait for the redirect to complete + await page.waitForTimeout(2000); + + const currentUrl = page.url(); + console.log('Unauthenticated user redirected to:', currentUrl); // Should be redirected to auth - await page.waitForURL(/\/auth/, { timeout: 5000 }); - expect(page.url()).toContain('/auth'); + expect(currentUrl).toContain('/auth'); }); }); diff --git a/apps/app/e2e/tests/onboarding.spec.ts b/apps/app/e2e/tests/onboarding.spec.ts index 7e6a95ae4..0b16149b2 100644 --- a/apps/app/e2e/tests/onboarding.spec.ts +++ b/apps/app/e2e/tests/onboarding.spec.ts @@ -5,7 +5,9 @@ import { fillFormField, generateTestData, waitForURL } from '../utils/helpers'; // Increase test timeout for complex flows test.describe.configure({ timeout: 60000 }); -test.describe('Onboarding Flow', () => { +// DEPRECATED: This test file is for the old full onboarding flow. +// The new split onboarding flow is tested in split-onboarding.spec.ts +test.describe.skip('Onboarding Flow (DEPRECATED)', () => { test.beforeEach(async ({ page }) => { // Clear any existing auth state await clearAuth(page); @@ -388,7 +390,7 @@ test.describe('Onboarding Flow', () => { }); }); -test.describe('Setup Page Components', () => { +test.describe.skip('Setup Page Components (DEPRECATED)', () => { test.beforeEach(async ({ page }) => { const testData = generateTestData(); diff --git a/apps/app/e2e/tests/split-onboarding.spec.ts b/apps/app/e2e/tests/split-onboarding.spec.ts index f364e65ea..eda5130c6 100644 --- a/apps/app/e2e/tests/split-onboarding.spec.ts +++ b/apps/app/e2e/tests/split-onboarding.spec.ts @@ -3,470 +3,401 @@ import { authenticateTestUser, clearAuth, grantAccess } from '../utils/auth-help import { generateTestData } from '../utils/helpers'; test.describe('Split Onboarding Flow', () => { - // New flow based on org.hasAccess column: - // - // 1. hasAccess = false: 3 setup steps → book a call → (after approval) 9 onboarding steps → product access - // 2. hasAccess = true, incomplete onboarding: 3 setup steps → 9 onboarding steps → product access - // 3. hasAccess = true, completed onboarding: redirect to app (skip setup/onboarding) - // - // Users with incomplete onboarding (even if hasAccess=true) are redirected from product pages to onboarding + test.setTimeout(60000); test.beforeEach(async ({ page }) => { - // Clear any existing auth state await clearAuth(page); }); - test('new user without access: 3 steps → book call (blocked from onboarding/product)', async ({ - page, - }) => { - // Tests the flow for a new user who doesn't have access (hasAccess = false) - // They complete setup, see the book a call page, and are blocked from accessing onboarding/product + test('new user without access: 3 steps → book call', async ({ page }) => { const testData = generateTestData(); const website = `example${Date.now()}.com`; - // Authenticate user first + // Authenticate user without access await authenticateTestUser(page, { email: testData.email, name: testData.userName, - skipOrg: true, // Don't create org, user will go through setup - hasAccess: false, // This user doesn't have access initially + skipOrg: true, + hasAccess: false, }); - // Navigate to setup + // Go to setup await page.goto('/setup'); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); - // Should redirect to /setup/[setupId] - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); - - // Wait for content to load - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + // Step 1: Framework selection - wait for it to be auto-selected + await expect(page.getByText('Step 1 of 3')).toBeVisible(); + await expect(page.getByText('Which compliance frameworks do you need?')).toBeVisible(); - // Step 1: Select framework - await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ - timeout: 10000, - }); + // Wait for frameworks to load and one to be auto-selected + await page.waitForSelector('input[type="checkbox"]:checked'); - // Check if framework is already selected, if not select one - const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); - if (checkedFrameworks === 0) { - await page.locator('label:has-text("SOC 2")').click(); - } - await page.getByRole('button', { name: 'Next' }).click(); + // Click Next + await page.getByTestId('setup-next-button').click(); // Step 2: Organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(testData.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByText('Step 2 of 3')).toBeVisible(); + await expect(page.getByText('What is your company name?')).toBeVisible(); + + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + await page.getByTestId('setup-next-button').click(); // Step 3: Website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(website); - await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByText('Step 3 of 3')).toBeVisible(); + await expect(page.getByText("What's your company website?")).toBeVisible(); - // Should redirect to upgrade page - await expect(page).toHaveURL(/\/upgrade\/org_/); + await page.getByPlaceholder('example.com').fill(website); - // Extract orgId from URL - const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - expect(orgIdMatch).toBeTruthy(); - const orgId = orgIdMatch![0]; + // Click Finish and wait for redirect + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); - // If they haven't booked a call (hasAccess is false), they see a book a call page. + // Should see book a call page await expect(page.getByText(`Let's get ${testData.organizationName} approved`)).toBeVisible(); - await expect( - page.getByText( - 'A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.', - ), - ).toBeVisible(); - - // For this test, we'll stop here since the user doesn't have access - // In a real scenario, they would book a call and get approved - // but for testing purposes, we'll just verify they see the book a call page - - // Try to access onboarding directly - should be redirected back to upgrade - await page.goto(`/onboarding/${orgId}`); - await expect(page).toHaveURL(`/upgrade/${orgId}`); - - // Try to access product pages - should be redirected back to upgrade - await page.goto(`/${orgId}/frameworks`); - await expect(page).toHaveURL(`/upgrade/${orgId}`); - - await page.goto(`/${orgId}/policies`); - await expect(page).toHaveURL(`/upgrade/${orgId}`); + await expect(page.getByText('A quick 20-minute call with our team')).toBeVisible(); }); - test('user with access but incomplete onboarding: 3 steps → directly to onboarding', async ({ - page, - }) => { - // Tests user who has access (hasAccess = true) but hasn't completed onboarding - // They skip the book a call step and go directly to onboarding + test('user creates org → book call → gets access → onboarding', async ({ page }) => { const testData = generateTestData(); const website = `example${Date.now()}.com`; - // Authenticate user first + // Authenticate user with access await authenticateTestUser(page, { email: testData.email, name: testData.userName, skipOrg: true, - hasAccess: true, // This user has access + hasAccess: true, }); - // Navigate to setup + // Go to setup await page.goto('/setup'); - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); - // Complete the 3 initial steps - // Step 1: Select framework - await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ - timeout: 10000, - }); - const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); - if (checkedFrameworks === 0) { - await page.locator('label:has-text("SOC 2")').click(); - } - await page.getByRole('button', { name: 'Next' }).click(); + // Step 1: Framework selection + await expect(page.getByText('Step 1 of 3')).toBeVisible(); + await page.waitForSelector('input[type="checkbox"]:checked'); + await page.getByTestId('setup-next-button').click(); // Step 2: Organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(testData.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByText('Step 2 of 3')).toBeVisible(); + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + await page.getByTestId('setup-next-button').click(); // Step 3: Website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(website); - await page.getByRole('button', { name: 'Next' }).click(); - - // Extract orgId from URL - const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - expect(orgIdMatch).toBeTruthy(); - const orgId = orgIdMatch![0]; - - // Since the user has access (hasAccess = true), they should be redirected directly to onboarding - // This bypasses the book a call step - await page.goto(`/onboarding/${orgId}`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); - - // Should see step 4 (describe) - no book a call step - await expect(page.getByText('Step 4 of 12')).toBeVisible(); - await expect(page.getByText('Tell us a bit about your business')).toBeVisible(); + await expect(page.getByText('Step 3 of 3')).toBeVisible(); + await page.getByPlaceholder('example.com').fill(website); + + // Click Finish and wait for redirect - NEW ORGS ALWAYS GO TO BOOK CALL FIRST + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); + + // Even users with access get redirected to book call for NEW organizations + await expect(page.getByText(`Let's get ${testData.organizationName} approved`)).toBeVisible(); + await expect(page.getByText('A quick 20-minute call with our team')).toBeVisible(); + + // Extract orgId to grant access and test the onboarding flow + const orgId = page.url().match(/org_[a-zA-Z0-9]+/)?.[0]; + expect(orgId).toBeTruthy(); + + // Now grant access (simulating Lewis manually approving the org) + await grantAccess(page, orgId!, true); + await page.reload(); + + // Should now redirect to onboarding + await expect(page).toHaveURL(`/onboarding/${orgId!}`); + await expect(page.getByText('Step 1 of 9')).toBeVisible(); + await expect(page.getByText('Describe your company in a few sentences')).toBeVisible(); }); - test('user with access but incomplete onboarding: redirected from product to onboarding', async ({ - page, - context, - }) => { - // Tests user who has access (hasAccess = true) but hasn't completed onboarding - // When they try to access product pages, they should be redirected to onboarding + test('user with approved org: redirected from product to onboarding', async ({ page }) => { const testData = generateTestData(); const website = `example${Date.now()}.com`; - // Authenticate user first + // Authenticate user with access await authenticateTestUser(page, { email: testData.email, name: testData.userName, skipOrg: true, - hasAccess: true, // This user has access + hasAccess: true, }); - // First create org through minimal flow + // Complete setup to create org await page.goto('/setup'); - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); - - // Select framework - const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); - if (checkedFrameworks === 0) { - await page.locator('label:has-text("SOC 2")').click(); - } - await page.getByRole('button', { name: 'Next' }).click(); - - // Fill organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(testData.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); - - // Fill website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(website); - await page.getByRole('button', { name: 'Next' }).click(); - - const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - expect(orgIdMatch).toBeTruthy(); - const orgId = orgIdMatch![0]; - - // Since the user has access (hasAccess = true), they can access onboarding - await page.goto(`/onboarding/${orgId}`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); - - // Try to access product without completing onboarding - await page.goto(`/${orgId}/frameworks`); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); + + // Go through 3 steps quickly + await page.waitForSelector('input[type="checkbox"]:checked'); + // Wait for the button to be enabled (form validation needs to catch up) + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 10000 }, + ); + await page.waitForTimeout(500); // Brief pause for stability + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + // Wait for the button to be enabled after filling the organization name + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 5000 }, + ); + await page.waitForTimeout(300); // Brief pause for stability + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('example.com').fill(website); + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); + + // Extract orgId from current URL + const orgId = page.url().match(/org_[a-zA-Z0-9]+/)?.[0]; + expect(orgId).toBeTruthy(); + + // Grant access (simulating Lewis manually approving the org) + await grantAccess(page, orgId!, true); + await page.reload(); - // Should be redirected back to onboarding - await expect(page).toHaveURL(`/onboarding/${orgId}`); + // Should now be on onboarding + await expect(page).toHaveURL(`/onboarding/${orgId!}`); - // Try different product routes - await page.goto(`/${orgId}/policies`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); + // Try to access product pages - should redirect back to onboarding since onboarding not completed + await page.goto(`/${orgId!}/frameworks`); + await expect(page).toHaveURL(/\/onboarding\/org_/); - await page.goto(`/${orgId}/vendors`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); + await page.goto(`/${orgId!}/policies`); + await expect(page).toHaveURL(/\/onboarding\/org_/); }); - test('user with access and completed onboarding: redirected from setup/onboarding to app', async ({ - page, - }) => { - // Tests user who has access (hasAccess = true) and completed onboarding - // They should be redirected from setup/upgrade/onboarding pages directly to the app + test('completed user redirected from setup to app', async ({ page }) => { const testData = generateTestData(); const website = `example${Date.now()}.com`; - // Authenticate user first + // Authenticate user with access await authenticateTestUser(page, { email: testData.email, name: testData.userName, skipOrg: true, - hasAccess: true, // This user has access + hasAccess: true, }); - // First create org through setup flow + // Complete setup await page.goto('/setup'); - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); + + await page.waitForSelector('input[type="checkbox"]:checked'); + // Wait for the button to be enabled (form validation needs to catch up) + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 10000 }, + ); + await page.waitForTimeout(500); // Brief pause for stability + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + // Wait for the button to be enabled after filling the organization name + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 5000 }, + ); + await page.waitForTimeout(300); // Brief pause for stability + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('example.com').fill(website); + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); + + const orgId = page.url().match(/org_[a-zA-Z0-9]+/)?.[0]; + expect(orgId).toBeTruthy(); + + // Grant access (simulating Lewis manually approving the org) + await grantAccess(page, orgId!, true); + await page.reload(); - // Complete the 3 initial steps - // Step 1: Select framework - await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ - timeout: 10000, + // Should now be on onboarding + await expect(page).toHaveURL(`/onboarding/${orgId!}`); + + // Complete onboarding quickly - all 9 steps + + // Step 1: Describe company + await page + .getByTestId('onboarding-input-describe') + .fill('We are a test company that provides compliance solutions.'); + await page.getByTestId('onboarding-next-button').click(); + + // Step 2: Industry (dropdown) + await page.getByTestId('onboarding-input-industry').click(); + await page.waitForSelector('[data-testid="onboarding-option-saas"]', { state: 'visible' }); + await page.getByTestId('onboarding-option-saas').click(); + await page.getByTestId('onboarding-next-button').click(); + + // Step 3: Team size (dropdown) + await page.getByTestId('onboarding-input-teamSize').click(); + await page.waitForSelector('[data-testid="onboarding-option-11-50"]', { state: 'visible' }); + await page.getByTestId('onboarding-option-11-50').click(); + await page.getByTestId('onboarding-next-button').click(); + + // Step 4: Devices (multi-select) + await page.getByTestId('onboarding-input-devices-search').click(); + await page.waitForSelector( + '[data-testid="onboarding-input-devices-search-option-company-provided-laptops"]', + { state: 'visible' }, + ); + await page + .getByTestId('onboarding-input-devices-search-option-company-provided-laptops') + .click(); + await page.getByTestId('onboarding-next-button').click(); + + // Step 5: Authentication (multi-select) + await page.getByTestId('onboarding-input-authentication-search').click(); + await page.waitForSelector( + '[data-testid="onboarding-input-authentication-search-option-google-workspace"]', + { state: 'visible' }, + ); + await page + .getByTestId('onboarding-input-authentication-search-option-google-workspace') + .click(); + await page.getByTestId('onboarding-next-button').click(); + + // Step 6: Software (multi-select) + await page.getByTestId('onboarding-input-software-search').click(); + await page.waitForSelector('[data-testid="onboarding-input-software-search-option-github"]', { + state: 'visible', }); - const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); - if (checkedFrameworks === 0) { - await page.locator('label:has-text("SOC 2")').click(); - } - await page.getByRole('button', { name: 'Next' }).click(); - - // Step 2: Organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(testData.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); + await page.getByTestId('onboarding-input-software-search-option-github').click(); + await page.getByTestId('onboarding-next-button').click(); - // Step 3: Website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(website); - await page.getByRole('button', { name: 'Next' }).click(); - - // Extract orgId from URL - const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - expect(orgIdMatch).toBeTruthy(); - const orgId = orgIdMatch![0]; - - // Since the user has access (hasAccess = true), they can access onboarding - await page.goto(`/onboarding/${orgId}`); - - // Complete all onboarding steps quickly to simulate a completed state - const remainingSteps = [ - { field: 'textarea', value: 'We are a test company' }, - { field: 'select', text: 'Technology' }, - { field: 'select', text: '11-50' }, - { field: 'multiselect', values: ['Company-provided laptops'] }, - { field: 'multiselect', values: ['Single sign-on (SSO)'] }, - { field: 'multiselect', values: ['GitHub'] }, - { field: 'multiselect', values: ['Remote'] }, - { field: 'multiselect', values: ['Cloud (AWS, GCP, Azure)'] }, - { field: 'multiselect', values: ['Customer data'] }, - ]; - - for (const step of remainingSteps) { - if (step.field === 'textarea' && step.value) { - await page.locator('textarea').fill(step.value); - } else if (step.field === 'select' && step.text) { - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: step.text }).click(); - } else if (step.field === 'multiselect' && step.values) { - for (const value of step.values) { - await page.getByPlaceholder(/Type to search/).click(); - await page.getByRole('option', { name: value }).click(); - } - } - await page.getByRole('button', { name: 'Next' }).click(); - } - - // Final step should say "Complete Setup" - await expect(page.getByRole('button', { name: 'Complete Setup' })).toBeVisible(); - await page.getByRole('button', { name: 'Complete Setup' }).click(); - - // Should redirect to product - await expect(page).toHaveURL(`/${orgId}/frameworks`); - - // Now test that user with completed onboarding goes directly to app - // When they try to access /setup, /upgrade, or /onboarding, they should be redirected + // Step 7: Work location (dropdown) + await page.getByTestId('onboarding-input-workLocation').click(); + await page.waitForSelector('[data-testid="onboarding-option-fully-remote"]', { + state: 'visible', + }); + await page.getByTestId('onboarding-option-fully-remote').click(); + await page.getByTestId('onboarding-next-button').click(); + + // Step 8: Infrastructure (multi-select) + await page.getByTestId('onboarding-input-infrastructure-search').click(); + await page.waitForSelector( + '[data-testid="onboarding-input-infrastructure-search-option-aws"]', + { state: 'visible' }, + ); + await page.getByTestId('onboarding-input-infrastructure-search-option-aws').click(); + await page.getByTestId('onboarding-next-button').click(); + + // Step 9: Data types (multi-select) - Final step + await page.getByTestId('onboarding-input-dataTypes-search').click(); + await page.waitForSelector( + '[data-testid="onboarding-input-dataTypes-search-option-customer-pii"]', + { state: 'visible' }, + ); + await page.getByTestId('onboarding-input-dataTypes-search-option-customer-pii').click(); + + // Final step - Complete Setup button + await page.getByTestId('onboarding-next-button').click(); + + // Wait for the redirect chain: /onboarding/org_xxx → /org_xxx/ → /org_xxx/frameworks + await page.waitForURL(`/${orgId!}/frameworks`, { timeout: 10000 }); + + // Now test redirects for completed user await page.goto('/setup'); - await expect(page).toHaveURL(`/${orgId}/frameworks`); - - await page.goto(`/upgrade/${orgId}`); - await expect(page).toHaveURL(`/${orgId}/frameworks`); + await expect(page).toHaveURL(`/${orgId!}/frameworks`); - await page.goto(`/onboarding/${orgId}`); - await expect(page).toHaveURL(`/${orgId}/frameworks`); - - // Should have access to all product pages - await page.goto(`/${orgId}/policies`); - await expect(page).toHaveURL(`/${orgId}/policies`); - - await page.goto(`/${orgId}/vendors`); - await expect(page).toHaveURL(`/${orgId}/vendors`); + await page.goto(`/upgrade/${orgId!}`); + await expect(page).toHaveURL(`/${orgId!}/frameworks`); }); - test('user blocked by hasAccess → grant access → refresh shows onboarding', async ({ page }) => { - // Tests the flow where user is initially blocked, then access is granted, then they can access onboarding + test('user without access gets blocked then granted access', async ({ page }) => { const testData = generateTestData(); const website = `example${Date.now()}.com`; - // Authenticate user first (without access) + // Authenticate user without access await authenticateTestUser(page, { email: testData.email, name: testData.userName, skipOrg: true, - hasAccess: false, // This user doesn't have access initially + hasAccess: false, }); - // Navigate to setup + // Complete setup to get to book call page await page.goto('/setup'); - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); - - // Complete the 3 initial steps - // Step 1: Select framework - await expect(page.locator('text=/compliance frameworks/i').first()).toBeVisible({ - timeout: 10000, - }); - const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); - if (checkedFrameworks === 0) { - await page.locator('label:has-text("SOC 2")').click(); - } - await page.getByRole('button', { name: 'Next' }).click(); - - // Step 2: Organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(testData.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); - - // Step 3: Website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(website); - await page.getByRole('button', { name: 'Next' }).click(); - - // Should redirect to upgrade page - await expect(page).toHaveURL(/\/upgrade\/org_/); - - // Extract orgId from URL - const orgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - expect(orgIdMatch).toBeTruthy(); - const orgId = orgIdMatch![0]; - - // Verify they see the book a call page (blocked by hasAccess = false) + await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/); + + await page.waitForSelector('input[type="checkbox"]:checked'); + // Wait for the button to be enabled (form validation needs to catch up) + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 10000 }, + ); + await page.waitForTimeout(500); // Brief pause for stability + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('e.g., Acme Inc.').fill(testData.organizationName); + // Wait for the button to be enabled after filling the organization name + await page.waitForFunction( + () => { + const button = document.querySelector( + '[data-testid="setup-next-button"]', + ) as HTMLButtonElement; + return button && !button.disabled && button.offsetParent !== null; + }, + { timeout: 5000 }, + ); + await page.waitForTimeout(300); // Brief pause for stability + await page.getByTestId('setup-next-button').click(); + + await page.getByPlaceholder('example.com').fill(website); + await Promise.all([ + page.waitForURL(/\/upgrade\/org_/), + page.getByTestId('setup-finish-button').click(), + ]); + + const orgId = page.url().match(/org_[a-zA-Z0-9]+/)?.[0]; + expect(orgId).toBeTruthy(); + + // Verify blocked await expect(page.getByText(`Let's get ${testData.organizationName} approved`)).toBeVisible(); - await expect( - page.getByText( - 'A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.', - ), - ).toBeVisible(); - - // Verify they can't access onboarding or product pages - await page.goto(`/onboarding/${orgId}`); - await expect(page).toHaveURL(`/upgrade/${orgId}`); - await page.goto(`/${orgId}/frameworks`); - await expect(page).toHaveURL(`/upgrade/${orgId}`); + // Try accessing onboarding - should be blocked + await page.goto(`/onboarding/${orgId!}`); + await expect(page).toHaveURL(`/upgrade/${orgId!}`); - // Now simulate access being granted (like after a successful call) - await grantAccess(page, orgId, true); - - // Refresh the page - they should now be able to access onboarding + // Grant access + await grantAccess(page, orgId!, true); await page.reload(); - // They should now be redirected to onboarding - await expect(page).toHaveURL(`/onboarding/${orgId}`); - - // Verify they can see the onboarding content - await expect(page.getByText('Step 4 of 12')).toBeVisible(); - await expect(page.getByText('Tell us a bit about your business')).toBeVisible(); - - // Verify they can now access onboarding directly - await page.goto(`/onboarding/${orgId}`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); - - // But if they try to access product pages, they should be redirected to onboarding - // (since they have access but haven't completed onboarding) - await page.goto(`/${orgId}/frameworks`); - await expect(page).toHaveURL(`/onboarding/${orgId}`); - }); - - test('existing user can create additional organization', async ({ page }) => { - // This assumes we have a test user with existing org - // First complete one org setup - const firstOrg = generateTestData(); - const firstWebsite = `example${Date.now()}.com`; - - // Authenticate user first - await authenticateTestUser(page, { - email: firstOrg.email, - name: firstOrg.userName, - skipOrg: true, - hasAccess: true, // This user has access - }); - - await page.goto('/setup'); - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+/, { timeout: 10000 }); - - // Select framework - const checkedFrameworks = await page.locator('input[type="checkbox"]:checked').count(); - if (checkedFrameworks === 0) { - await page.locator('label:has-text("SOC 2")').click(); - } - await page.getByRole('button', { name: 'Next' }).click(); - - // Fill organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(firstOrg.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); - - // Fill website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(firstWebsite); - await page.getByRole('button', { name: 'Next' }).click(); - - const firstOrgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - const firstOrgId = firstOrgIdMatch ? firstOrgIdMatch[0] : null; - expect(firstOrgId).toBeTruthy(); - - // Now create additional org using dropdown - await page.goto(`/upgrade/${firstOrgId}`); - - // Try navigating directly to setup with intent - await page.goto('/setup?intent=create-additional'); - - // Should redirect to /setup/[setupId] with intent preserved - await page.waitForURL(/\/setup\/[a-zA-Z0-9]+\?intent=create-additional/, { timeout: 10000 }); - - // Complete setup for second org - const secondOrg = generateTestData(); - const secondWebsite = `example${Date.now() + 1}.com`; - - // Select a different framework - await page.locator('label:has-text("ISO 27001")').click(); - await page.getByRole('button', { name: 'Next' }).click(); - - // Fill organization name - await page.waitForSelector('input[name="organizationName"]', { timeout: 10000 }); - await page.locator('input[name="organizationName"]').fill(secondOrg.organizationName); - await page.getByRole('button', { name: 'Next' }).click(); - - // Fill website - await page.waitForSelector('input[name="website"]', { timeout: 10000 }); - await page.locator('input[name="website"]').fill(secondWebsite); - await page.getByRole('button', { name: 'Next' }).click(); - - // Should redirect to upgrade for the new org - await expect(page).toHaveURL(/\/upgrade\/org_/); - const secondOrgIdMatch = page.url().match(/org_[a-zA-Z0-9]+/); - const secondOrgId = secondOrgIdMatch ? secondOrgIdMatch[0] : null; - expect(secondOrgId).not.toBe(firstOrgId); + // Should now redirect to onboarding + await expect(page).toHaveURL(`/onboarding/${orgId!}`); + await expect(page.getByText('Step 1 of 9')).toBeVisible(); }); }); diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index f8c348e93..00e33e6a4 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -129,6 +129,7 @@ export function PostPaymentOnboarding({ form="onboarding-form" disabled={isOnboarding || isFinalizing || isLoading} className="group transition-all hover:pl-3" + data-testid="onboarding-next-button" > {isFinalizing ? ( 'Setting up...' diff --git a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 55a8c8742..33c605ca3 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx @@ -56,6 +56,7 @@ export function OnboardingFormActions({ form="onboarding-form" // Important: links to the form in OrganizationSetupForm.tsx className="flex items-center gap-2" disabled={isSubmitting || !isCurrentStepValid} + data-testid="setup-finish-button" > form.setValue(currentStep.key, value)} - onLoadingChange={onLoadingChange} - /> +
+ form.setValue(currentStep.key, value)} + onLoadingChange={onLoadingChange} + /> +
); } @@ -42,7 +44,14 @@ export function OnboardingStepInput({ } + render={({ field }) => ( + + )} /> ); } @@ -55,23 +64,32 @@ export function OnboardingStepInput({ rows={2} maxLength={300} className="h-24 resize-none" + data-testid={`onboarding-input-${currentStep.key}`} /> ); } if (currentStep.options) { - if (currentStep.key === 'industry' || currentStep.key === 'teamSize') { + if ( + currentStep.key === 'industry' || + currentStep.key === 'teamSize' || + currentStep.key === 'workLocation' + ) { return ( + ); } diff --git a/apps/app/src/app/api/auth/test-login/route.ts b/apps/app/src/app/api/auth/test-login/route.ts index d2d05a768..4ca6f6e3c 100644 --- a/apps/app/src/app/api/auth/test-login/route.ts +++ b/apps/app/src/app/api/auth/test-login/route.ts @@ -12,6 +12,8 @@ export async function POST(request: NextRequest) { console.log('[TEST-LOGIN] Endpoint hit at:', new Date().toISOString()); console.log('[TEST-LOGIN] E2E_TEST_MODE:', process.env.E2E_TEST_MODE); console.log('[TEST-LOGIN] NODE_ENV:', process.env.NODE_ENV); + console.log('[TEST-LOGIN] Request URL:', request.url); + console.log('[TEST-LOGIN] Request headers:', Object.fromEntries(request.headers.entries())); console.log('[TEST-LOGIN] ========================='); // Only allow in E2E test mode @@ -31,7 +33,7 @@ export async function POST(request: NextRequest) { const result = await Promise.race([handleLogin(request), timeoutPromise]); return result as NextResponse; } catch (error) { - console.error('[TEST-LOGIN] Error:', error); + console.error('[TEST-LOGIN] Error in POST handler:', error); return NextResponse.json( { error: 'Failed to create test session', details: error }, { status: 500 }, @@ -40,26 +42,62 @@ export async function POST(request: NextRequest) { } async function handleLogin(request: NextRequest) { - const body = await request.json(); - console.log('[TEST-LOGIN] Request body:', body); + let body; + try { + body = await request.json(); + console.log('[TEST-LOGIN] Request body:', body); + } catch (err) { + console.error('[TEST-LOGIN] Failed to parse request body:', err); + return NextResponse.json( + { error: 'Invalid request body', details: String(err) }, + { status: 400 }, + ); + } const { email, name, hasAccess } = body; const testPassword = 'Test123456!'; // Use a stronger test password console.log('[TEST-LOGIN] Checking for existing user:', email); - // Check if user already exists - const existingUser = await db.user.findUnique({ - where: { email }, - }); + // For E2E tests, always start with a clean user state + // Delete existing user if present to avoid password/state issues + let existingUser; + try { + existingUser = await db.user.findUnique({ + where: { email }, + }); + console.log( + '[TEST-LOGIN] Existing user lookup result:', + existingUser ? existingUser.id : 'none', + ); + } catch (err) { + console.error('[TEST-LOGIN] Error looking up existing user:', err); + return NextResponse.json( + { error: 'Failed to check for existing user', details: String(err) }, + { status: 500 }, + ); + } - console.log('[TEST-LOGIN] Existing user found:', !!existingUser); + if (existingUser) { + try { + console.log('[TEST-LOGIN] Deleting existing user for clean state'); + await db.user.delete({ where: { email } }); + console.log('[TEST-LOGIN] Existing user deleted'); + } catch (err) { + console.error('[TEST-LOGIN] Error deleting existing user:', err); + return NextResponse.json( + { error: 'Failed to delete existing user', details: String(err) }, + { status: 500 }, + ); + } + } - if (!existingUser) { - console.log('[TEST-LOGIN] Creating new user via Better Auth'); + console.log('[TEST-LOGIN] Creating new user via Better Auth'); - // First, sign up the user using Better Auth's signUpEmail method - const signUpResponse = await auth.api.signUpEmail({ + // Create the user using Better Auth's signUpEmail method + let signUpResponse; + try { + signUpResponse = await auth.api.signUpEmail({ body: { email, password: testPassword, @@ -68,114 +106,249 @@ async function handleLogin(request: NextRequest) { headers: request.headers, // Pass the request headers asResponse: true, }); - console.log('[TEST-LOGIN] Sign up response status:', signUpResponse.status); + } catch (err) { + console.error('[TEST-LOGIN] Error during signUpEmail:', err); + return NextResponse.json( + { error: 'Failed to sign up (exception)', details: String(err) }, + { status: 500 }, + ); + } - if (!signUpResponse.ok) { - const errorData = await signUpResponse.json(); - return NextResponse.json({ error: 'Failed to sign up', details: errorData }, { status: 400 }); + if (!signUpResponse.ok) { + let errorData; + try { + errorData = await signUpResponse.json(); + } catch (err) { + errorData = { parseError: String(err) }; } + console.error('[TEST-LOGIN] Sign up failed:', errorData); + return NextResponse.json({ error: 'Failed to sign up', details: errorData }, { status: 400 }); + } - // Mark the user as verified (for test purposes) + // Mark the user as verified (for test purposes) + try { await db.user.update({ where: { email }, data: { emailVerified: true }, }); + console.log('[TEST-LOGIN] User marked as verified'); + } catch (err) { + console.error('[TEST-LOGIN] Error marking user as verified:', err); + return NextResponse.json( + { error: 'Failed to mark user as verified', details: String(err) }, + { status: 500 }, + ); + } - // Get the user to create organization - const user = await db.user.findUnique({ + // Get the user we just created + let user; + try { + user = await db.user.findUnique({ where: { email }, }); - - if (user && !body.skipOrg) { - // Create a test organization for the user only if skipOrg is not true - await db.organization.create({ - data: { - name: `Test Org ${Date.now()}`, - hasAccess: hasAccess || false, // Allow setting hasAccess for tests - members: { - create: { - userId: user.id, - role: 'owner', - department: Departments.hr, - isActive: true, - fleetDmLabelId: Math.floor(Math.random() * 10000), - }, - }, - }, - }); + if (!user) { + console.log('[TEST-LOGIN] User not found after creation'); + return NextResponse.json({ error: 'User not found after creation' }, { status: 400 }); } + console.log('[TEST-LOGIN] User found:', user.id, user.email); + } catch (err) { + console.error('[TEST-LOGIN] Error fetching user after creation:', err); + return NextResponse.json( + { error: 'Failed to fetch user after creation', details: String(err) }, + { status: 500 }, + ); } - // Always sign in to get a fresh session with updated user state - console.log('[TEST-LOGIN] Signing in user:', email); + // Try signing in with a small delay to ensure user is fully committed + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + console.log('[TEST-LOGIN] Delay after user creation complete'); + } catch (err) { + console.error('[TEST-LOGIN] Error during delay:', err); + } - const signInResponse = await auth.api.signInEmail({ - body: { - email, - password: testPassword, - }, - headers: request.headers, // Pass the request headers - asResponse: true, - }); + console.log('[TEST-LOGIN] Attempting sign in for user:', email, 'with password:', testPassword); - console.log('[TEST-LOGIN] Sign in response status:', signInResponse.status); + let responseData: any; + let signInResponse; + try { + signInResponse = await auth.api.signInEmail({ + body: { + email, + password: testPassword, + }, + headers: request.headers, + asResponse: true, + }); + console.log('[TEST-LOGIN] Sign in response status:', signInResponse.status); + } catch (err) { + console.error('[TEST-LOGIN] Error during signInEmail:', err); + return NextResponse.json( + { error: 'Failed to sign in (exception)', details: String(err) }, + { status: 500 }, + ); + } if (!signInResponse.ok) { - const errorData = await signInResponse.json(); - console.log('[TEST-LOGIN] Sign in failed:', errorData); - return NextResponse.json({ error: 'Failed to sign in', details: errorData }, { status: 400 }); - } + let errorData; + try { + errorData = await signInResponse.json(); + } catch (e) { + try { + errorData = await signInResponse.text(); + } catch (err) { + errorData = { parseError: String(err) }; + } + } + console.error('[TEST-LOGIN] Sign in failed with error:', errorData); + console.log( + '[TEST-LOGIN] Response headers:', + Object.fromEntries(signInResponse.headers.entries()), + ); - // Get the response data - const responseData = await signInResponse.json(); - console.log('[TEST-LOGIN] Sign in successful, user:', responseData.user.id); + // Try alternative approach - create session directly + console.log('[TEST-LOGIN] Attempting direct session creation...'); + + try { + const sessionResponse = await auth.api.createSession({ + body: { + userId: user.id, + }, + headers: request.headers, + asResponse: true, + }); + + if (sessionResponse.ok) { + const sessionData = await sessionResponse.json(); + console.log('[TEST-LOGIN] Direct session creation successful'); + // Continue with the original flow using the session data + responseData = { user, session: sessionData.session }; + } else { + let sessionErrorData; + try { + sessionErrorData = await sessionResponse.json(); + } catch (err) { + sessionErrorData = { parseError: String(err) }; + } + console.error('[TEST-LOGIN] Direct session creation also failed:', sessionErrorData); + return NextResponse.json( + { error: 'Failed to create session', details: errorData }, + { status: 400 }, + ); + } + } catch (sessionError) { + console.error('[TEST-LOGIN] Direct session creation error:', sessionError); + return NextResponse.json( + { error: 'Failed to create session', details: errorData }, + { status: 400 }, + ); + } + } else { + // Get the response data from successful sign-in + try { + responseData = await signInResponse.json(); + console.log('[TEST-LOGIN] Sign in successful, user:', responseData.user?.id); + } catch (err) { + console.error('[TEST-LOGIN] Error parsing sign in response JSON:', err); + return NextResponse.json( + { error: 'Failed to parse sign in response', details: String(err) }, + { status: 500 }, + ); + } + } // Create an organization for the user if skipOrg is not true let org = null; if (!body.skipOrg) { console.log('[TEST-LOGIN] Creating test organization'); - - org = await db.organization.create({ - data: { - id: `org_${Date.now()}`, - name: `Test Org ${Date.now()}`, - hasAccess: hasAccess || false, // Allow setting hasAccess for tests - members: { - create: { - id: `mem_${Date.now()}`, - userId: responseData.user.id, - role: 'owner', - department: Departments.it, - isActive: true, - fleetDmLabelId: 0, + try { + org = await db.organization.create({ + data: { + name: `Test Org ${Date.now()}`, + hasAccess: hasAccess || false, // Allow setting hasAccess for tests + members: { + create: { + userId: responseData.user.id, + role: 'owner', + department: Departments.it, + isActive: true, + fleetDmLabelId: 0, + }, }, }, - }, - }); + }); + console.log('[TEST-LOGIN] Created organization:', org.id); + } catch (err) { + console.error('[TEST-LOGIN] Error creating organization:', err); + return NextResponse.json( + { error: 'Failed to create organization', details: String(err) }, + { status: 500 }, + ); + } - console.log('[TEST-LOGIN] Created organization:', org.id); + // Set this as the active organization for the session (non-blocking) + try { + const setActiveOrgResponse = await auth.api.setActiveOrganization({ + headers: request.headers, + body: { + organizationId: org.id, + }, + asResponse: true, + }); - // Don't set active organization here - let the client handle it - // The session will have the organization available + if (!setActiveOrgResponse.ok) { + console.log('[TEST-LOGIN] Warning: setActiveOrganization returned non-ok status'); + // Try again with a small delay + await new Promise((resolve) => setTimeout(resolve, 500)); + await auth.api.setActiveOrganization({ + headers: request.headers, + body: { + organizationId: org.id, + }, + }); + } - console.log('[TEST-LOGIN] Organization created successfully'); + console.log('[TEST-LOGIN] Set organization as active:', org.id); + } catch (err) { + console.error( + '[TEST-LOGIN] Warning: Failed to set active organization (continuing anyway):', + err, + ); + // Don't fail the entire request - user can still authenticate + // The middleware will handle setting active org if needed + } } // Create a new response with the data - const response = NextResponse.json({ - success: true, - user: responseData.user, - session: responseData.session, - organizationId: body.skipOrg ? null : org?.id, - }); + let response; + try { + response = NextResponse.json({ + success: true, + user: responseData.user, + session: responseData.session, + organizationId: body.skipOrg ? null : org?.id, + }); + console.log('[TEST-LOGIN] Created response object'); + } catch (err) { + console.error('[TEST-LOGIN] Error creating response object:', err); + return NextResponse.json( + { error: 'Failed to create response', details: String(err) }, + { status: 500 }, + ); + } // Copy all cookies from Better Auth's response to our response - const cookies = signInResponse.headers.getSetCookie(); - console.log('[TEST-LOGIN] Setting cookies count:', cookies.length); - cookies.forEach((cookie: string) => { - response.headers.append('Set-Cookie', cookie); - }); + try { + const cookies = signInResponse.headers.getSetCookie(); + console.log('[TEST-LOGIN] Setting cookies count:', cookies.length); + cookies.forEach((cookie: string) => { + response.headers.append('Set-Cookie', cookie); + }); + } catch (err) { + console.error('[TEST-LOGIN] Error copying cookies:', err); + // Still return the response, but log the error + } console.log('[TEST-LOGIN] Returning success response'); return response; diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 5ee3e05c9..3be2e01e4 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -6,16 +6,16 @@ import { redirect } from 'next/navigation'; export default async function RootPage({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const session = await auth.api.getSession({ headers: await headers(), }); // Helper function to build URL with search params - const buildUrlWithParams = (path: string) => { + const buildUrlWithParams = async (path: string): Promise => { const params = new URLSearchParams(); - Object.entries(searchParams).forEach(([key, value]) => { + Object.entries(await searchParams).forEach(([key, value]) => { if (value !== undefined) { if (Array.isArray(value)) { value.forEach((v) => params.append(key, v)); @@ -29,13 +29,13 @@ export default async function RootPage({ }; if (!session) { - return redirect(buildUrlWithParams('/auth')); + return redirect(await buildUrlWithParams('/auth')); } const orgId = session.session.activeOrganizationId; if (!orgId) { - return redirect(buildUrlWithParams('/setup')); + return redirect(await buildUrlWithParams('/setup')); } const member = await db.member.findFirst({ @@ -46,12 +46,12 @@ export default async function RootPage({ }); if (member?.role === 'employee') { - return redirect(buildUrlWithParams('/no-access')); + return redirect(await buildUrlWithParams('/no-access')); } if (!member) { - return redirect(buildUrlWithParams('/setup')); + return redirect(await buildUrlWithParams('/setup')); } - return redirect(buildUrlWithParams(`/${orgId}/frameworks`)); + return redirect(await buildUrlWithParams(`/${orgId}/frameworks`)); } diff --git a/apps/app/src/test-utils/helpers/middleware.ts b/apps/app/src/test-utils/helpers/middleware.ts index 724abee51..556b63342 100644 --- a/apps/app/src/test-utils/helpers/middleware.ts +++ b/apps/app/src/test-utils/helpers/middleware.ts @@ -4,17 +4,21 @@ import { NextRequest, NextResponse } from 'next/server'; interface MockRequestOptions { session?: Session | null; headers?: Record; - searchParams?: Record; + searchParams?: Promise>; method?: string; } -export function createMockRequest(pathname: string, options: MockRequestOptions = {}): NextRequest { +export async function createMockRequest( + pathname: string, + options: MockRequestOptions = {}, +): Promise { const { headers = {}, searchParams = {}, method = 'GET' } = options; // Build URL with search params const url = new URL(pathname, 'http://localhost:3000'); - Object.entries(searchParams).forEach(([key, value]) => { - url.searchParams.set(key, value); + const searchParamsObj = await searchParams; + Object.entries(searchParamsObj).forEach(([key, value]) => { + url.searchParams.set(key, value as string); }); // Create headers diff --git a/packages/ui/src/components/select-pills.tsx b/packages/ui/src/components/select-pills.tsx index d60477772..150597cd1 100644 --- a/packages/ui/src/components/select-pills.tsx +++ b/packages/ui/src/components/select-pills.tsx @@ -22,6 +22,7 @@ interface SelectPillsProps { onValueChange?: (selectedValues: string[]) => void; placeholder?: string; disabled?: boolean; + 'data-testid'?: string; } export const SelectPills: FC = ({ @@ -31,6 +32,7 @@ export const SelectPills: FC = ({ onValueChange, placeholder = 'Type to search...', disabled = false, + 'data-testid': dataTestId, }) => { const [inputValue, setInputValue] = useState(''); const [selectedPills, setSelectedPills] = useState(value || defaultValue); @@ -219,6 +221,7 @@ export const SelectPills: FC = ({ isOpen && !inputValue ? 'Type to search or press Enter to add custom...' : placeholder } disabled={disabled} + data-testid={dataTestId} />
@@ -245,6 +248,7 @@ export const SelectPills: FC = ({ highlightedIndex === index && 'bg-accent', )} onClick={() => handleItemSelect(item)} + data-testid={`${dataTestId}-option-${item.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`} > = ({ className="sr-only" checked={highlightedIndex === index} onChange={() => handleItemSelect(item)} + data-testid={`pill-option-${index}`} />