Skip to content

Commit 2ef223e

Browse files
committed
fix: move Stripe webhook helpers to separate file to fix Next.js route export error
1 parent 119670c commit 2ef223e

File tree

3 files changed

+71
-66
lines changed

3 files changed

+71
-66
lines changed

web/src/app/api/stripe/webhook/__tests__/org-billing-events.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ const setupMocks = async () => {
3838
}))
3939

4040
// Import after mocking
41-
const webhookModule = await import('../route')
42-
isOrgBillingEvent = webhookModule.isOrgBillingEvent
43-
isOrgCustomer = webhookModule.isOrgCustomer
41+
const helpersModule = await import('../_helpers')
42+
isOrgBillingEvent = helpersModule.isOrgBillingEvent
43+
isOrgCustomer = helpersModule.isOrgCustomer
4444
}
4545

4646
// Setup mocks at module load time (following ban-conditions.test.ts pattern)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import db from '@codebuff/internal/db'
2+
import * as schema from '@codebuff/internal/db/schema'
3+
import { eq } from 'drizzle-orm'
4+
5+
import type Stripe from 'stripe'
6+
7+
import { logger } from '@/util/logger'
8+
9+
/**
10+
* Checks whether a Stripe customer ID belongs to an organization.
11+
*
12+
* Uses `org.stripe_customer_id` which is set at org creation time, making it
13+
* reliable regardless of webhook ordering (unlike `stripe_subscription_id`
14+
* which may not be populated yet when early invoice events arrive).
15+
*/
16+
export async function isOrgCustomer(stripeCustomerId: string): Promise<boolean> {
17+
try {
18+
const orgs = await db
19+
.select({ id: schema.org.id })
20+
.from(schema.org)
21+
.where(eq(schema.org.stripe_customer_id, stripeCustomerId))
22+
.limit(1)
23+
return orgs.length > 0
24+
} catch (error) {
25+
logger.error(
26+
{ stripeCustomerId, error },
27+
'Failed to check if customer is an org - defaulting to false',
28+
)
29+
return false
30+
}
31+
}
32+
33+
/**
34+
* BILLING_DISABLED: Checks if a Stripe event is related to organization billing.
35+
* Used to reject org billing events while keeping personal billing working.
36+
*/
37+
export async function isOrgBillingEvent(event: Stripe.Event): Promise<boolean> {
38+
const eventData = event.data.object as unknown as Record<string, unknown>
39+
const metadata = (eventData.metadata || {}) as Record<string, string>
40+
41+
// Check metadata for organization markers
42+
if (metadata.organization_id || metadata.organizationId) {
43+
return true
44+
}
45+
if (metadata.grantType === 'organization_purchase') {
46+
return true
47+
}
48+
49+
// For invoice events, check if customer belongs to an org
50+
// (metadata.organizationId is already checked above in the generic metadata check)
51+
if (event.type.startsWith('invoice.')) {
52+
const customerId = eventData.customer
53+
if (customerId && typeof customerId === 'string') {
54+
return await isOrgCustomer(customerId)
55+
}
56+
}
57+
58+
// For subscription events, check if customer is an org
59+
if (event.type.startsWith('customer.subscription.')) {
60+
const customerId = eventData.customer
61+
if (customerId && typeof customerId === 'string') {
62+
return await isOrgCustomer(customerId)
63+
}
64+
}
65+
66+
return false
67+
}

web/src/app/api/stripe/webhook/route.ts

Lines changed: 1 addition & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -25,66 +25,7 @@ import {
2525
} from '@/lib/ban-conditions'
2626
import { ORG_BILLING_ENABLED } from '@/lib/billing-config'
2727
import { logger } from '@/util/logger'
28-
29-
/**
30-
* Checks whether a Stripe customer ID belongs to an organization.
31-
*
32-
* Uses `org.stripe_customer_id` which is set at org creation time, making it
33-
* reliable regardless of webhook ordering (unlike `stripe_subscription_id`
34-
* which may not be populated yet when early invoice events arrive).
35-
*/
36-
async function isOrgCustomer(stripeCustomerId: string): Promise<boolean> {
37-
try {
38-
const orgs = await db
39-
.select({ id: schema.org.id })
40-
.from(schema.org)
41-
.where(eq(schema.org.stripe_customer_id, stripeCustomerId))
42-
.limit(1)
43-
return orgs.length > 0
44-
} catch (error) {
45-
logger.error(
46-
{ stripeCustomerId, error },
47-
'Failed to check if customer is an org - defaulting to false',
48-
)
49-
return false
50-
}
51-
}
52-
53-
/**
54-
* BILLING_DISABLED: Checks if a Stripe event is related to organization billing.
55-
* Used to reject org billing events while keeping personal billing working.
56-
*/
57-
async function isOrgBillingEvent(event: Stripe.Event): Promise<boolean> {
58-
const eventData = event.data.object as unknown as Record<string, unknown>
59-
const metadata = (eventData.metadata || {}) as Record<string, string>
60-
61-
// Check metadata for organization markers
62-
if (metadata.organization_id || metadata.organizationId) {
63-
return true
64-
}
65-
if (metadata.grantType === 'organization_purchase') {
66-
return true
67-
}
68-
69-
// For invoice events, check if customer belongs to an org
70-
// (metadata.organizationId is already checked above in the generic metadata check)
71-
if (event.type.startsWith('invoice.')) {
72-
const customerId = eventData.customer
73-
if (customerId && typeof customerId === 'string') {
74-
return await isOrgCustomer(customerId)
75-
}
76-
}
77-
78-
// For subscription events, check if customer is an org
79-
if (event.type.startsWith('customer.subscription.')) {
80-
const customerId = eventData.customer
81-
if (customerId && typeof customerId === 'string') {
82-
return await isOrgCustomer(customerId)
83-
}
84-
}
85-
86-
return false
87-
}
28+
import { isOrgBillingEvent, isOrgCustomer } from './_helpers'
8829

8930
async function handleCheckoutSessionCompleted(
9031
session: Stripe.Checkout.Session,
@@ -678,6 +619,3 @@ const webhookHandler = async (req: NextRequest): Promise<NextResponse> => {
678619
}
679620

680621
export { webhookHandler as POST }
681-
682-
// Exported for testing
683-
export { isOrgBillingEvent, isOrgCustomer }

0 commit comments

Comments
 (0)