Skip to content

Commit b07561b

Browse files
committed
feat(stripe): add Stripe integration with auto-approval for internal team
1 parent e84e4b0 commit b07561b

File tree

6 files changed

+174
-12
lines changed

6 files changed

+174
-12
lines changed

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"remark-parse": "^11.0.0",
108108
"resend": "^4.4.1",
109109
"sonner": "^2.0.5",
110+
"stripe": "^20.0.0",
110111
"swr": "^2.3.4",
111112
"three": "^0.177.0",
112113
"ts-pattern": "^5.7.0",

apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,11 @@ export function PostPaymentOnboarding({
4545
userEmail,
4646
});
4747

48-
const isLocal = useMemo(() => {
49-
if (typeof window === 'undefined') return false;
50-
const host = window.location.host || '';
51-
return (
52-
process.env.NODE_ENV !== 'production' ||
53-
host.includes('localhost') ||
54-
host.startsWith('127.0.0.1') ||
55-
host.startsWith('::1')
56-
);
57-
}, []);
48+
// Only show skip button for internal team members
49+
const canSkipOnboarding = useMemo(() => {
50+
if (!userEmail) return false;
51+
return userEmail.endsWith('@trycomp.ai');
52+
}, [userEmail]);
5853

5954
// Check if current step has valid input
6055
const currentStepValue = form.watch(step?.key);
@@ -193,7 +188,7 @@ export function PostPaymentOnboarding({
193188
</motion.div>
194189
)}
195190
</AnimatePresence>
196-
{isLocal && (
191+
{canSkipOnboarding && (
197192
<motion.div
198193
key="complete-now"
199194
initial={{ opacity: 0, x: 20 }}

apps/app/src/app/(app)/upgrade/[orgId]/page.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { extractDomain, isDomainActiveStripeCustomer } from '@/lib/stripe';
12
import { auth } from '@/utils/auth';
23
import { db } from '@db';
34
import { headers } from 'next/headers';
@@ -39,7 +40,31 @@ export default async function UpgradePage({ params }: PageProps) {
3940
redirect('/');
4041
}
4142

42-
const hasAccess = member.organization.hasAccess;
43+
let hasAccess = member.organization.hasAccess;
44+
45+
// Auto-approve based on user's email domain
46+
if (!hasAccess) {
47+
const userEmail = authSession.user.email;
48+
const emailDomain = extractDomain(userEmail ?? '');
49+
50+
if (emailDomain) {
51+
// Auto-approve for trycomp.ai emails (internal team)
52+
const isTrycompEmail = emailDomain === 'trycomp.ai';
53+
54+
// Check Stripe for other domains
55+
const isStripeCustomer = isTrycompEmail
56+
? false
57+
: await isDomainActiveStripeCustomer(emailDomain);
58+
59+
if (isTrycompEmail || isStripeCustomer) {
60+
await db.organization.update({
61+
where: { id: orgId },
62+
data: { hasAccess: true },
63+
});
64+
hasAccess = true;
65+
}
66+
}
67+
}
4368

4469
// If user has access to org but hasn't completed onboarding, redirect to onboarding
4570
if (hasAccess && !member.organization.onboardingCompleted) {

apps/app/src/env.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const env = createEnv({
4242
GA4_MEASUREMENT_ID: z.string().optional(),
4343
LINKEDIN_CONVERSIONS_ACCESS_TOKEN: z.string().optional(),
4444
NOVU_API_KEY: z.string().optional(),
45+
STRIPE_SECRET_KEY: z.string().optional(),
4546
},
4647

4748
client: {
@@ -107,6 +108,7 @@ export const env = createEnv({
107108
NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
108109
NOVU_API_KEY: process.env.NOVU_API_KEY,
109110
NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER: process.env.NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER,
111+
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
110112
},
111113

112114
skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION,

apps/app/src/lib/stripe.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { env } from '@/env.mjs';
2+
import Stripe from 'stripe';
3+
4+
// Initialize Stripe client with secret key from environment
5+
const stripeSecretKey = env.STRIPE_SECRET_KEY;
6+
7+
if (!stripeSecretKey) {
8+
console.warn('STRIPE_SECRET_KEY is not set - Stripe auto-approval will be disabled');
9+
}
10+
11+
export const stripe = stripeSecretKey
12+
? new Stripe(stripeSecretKey, {
13+
apiVersion: '2025-11-17.clover',
14+
})
15+
: null;
16+
17+
/**
18+
* Extract domain from a website URL or email
19+
* @param input - URL (e.g., "https://example.com") or email (e.g., "user@example.com")
20+
* @returns Normalized domain (e.g., "example.com")
21+
*/
22+
export const extractDomain = (input: string): string | null => {
23+
if (!input) return null;
24+
25+
try {
26+
// If it looks like an email, extract domain from after @
27+
if (input.includes('@') && !input.includes('://')) {
28+
const domain = input.split('@')[1]?.toLowerCase().trim();
29+
return domain || null;
30+
}
31+
32+
// Otherwise, treat as URL
33+
let url = input.trim().toLowerCase();
34+
35+
// Add protocol if missing
36+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
37+
url = `https://${url}`;
38+
}
39+
40+
const parsed = new URL(url);
41+
return parsed.hostname.replace(/^www\./, '');
42+
} catch {
43+
return null;
44+
}
45+
};
46+
47+
/**
48+
* Check if a domain belongs to an existing Stripe customer
49+
* Searches by customer email domain and metadata
50+
*
51+
* @param domain - The domain to check (e.g., "acme.com")
52+
* @returns Customer ID if found, null otherwise
53+
*/
54+
export const findStripeCustomerByDomain = async (
55+
domain: string,
56+
): Promise<{ customerId: string; customerName: string | null } | null> => {
57+
if (!stripe) {
58+
console.warn('Stripe client not initialized - skipping customer lookup');
59+
return null;
60+
}
61+
62+
if (!domain) {
63+
return null;
64+
}
65+
66+
const normalizedDomain = domain.toLowerCase().trim();
67+
68+
try {
69+
// Search for customers with emails matching this domain
70+
// Stripe's search supports email domain matching
71+
const customers = await stripe.customers.search({
72+
query: `email~"@${normalizedDomain}"`,
73+
limit: 1,
74+
});
75+
76+
if (customers.data.length > 0) {
77+
const customer = customers.data[0];
78+
return {
79+
customerId: customer.id,
80+
customerName: customer.name ?? null,
81+
};
82+
}
83+
84+
// Fallback: Check customers with domain in metadata
85+
// This handles cases where customer email might not match company domain
86+
const customersWithMetadata = await stripe.customers.search({
87+
query: `metadata["domain"]:"${normalizedDomain}"`,
88+
limit: 1,
89+
});
90+
91+
if (customersWithMetadata.data.length > 0) {
92+
const customer = customersWithMetadata.data[0];
93+
return {
94+
customerId: customer.id,
95+
customerName: customer.name ?? null,
96+
};
97+
}
98+
99+
return null;
100+
} catch (error) {
101+
console.error('Error searching Stripe customers:', error);
102+
return null;
103+
}
104+
};
105+
106+
/**
107+
* Check if a domain is an active Stripe customer with a valid subscription
108+
*
109+
* @param domain - The domain to check
110+
* @returns true if domain has an active subscription
111+
*/
112+
export const isDomainActiveStripeCustomer = async (domain: string): Promise<boolean> => {
113+
const customer = await findStripeCustomerByDomain(domain);
114+
115+
if (!customer) {
116+
return false;
117+
}
118+
119+
if (!stripe) {
120+
return false;
121+
}
122+
123+
try {
124+
// Check if customer has an active subscription
125+
const subscriptions = await stripe.subscriptions.list({
126+
customer: customer.customerId,
127+
status: 'active',
128+
limit: 1,
129+
});
130+
131+
return subscriptions.data.length > 0;
132+
} catch (error) {
133+
console.error('Error checking Stripe subscriptions:', error);
134+
return false;
135+
}
136+
};

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@
254254
"remark-parse": "^11.0.0",
255255
"resend": "^4.4.1",
256256
"sonner": "^2.0.5",
257+
"stripe": "^20.0.0",
257258
"swr": "^2.3.4",
258259
"three": "^0.177.0",
259260
"ts-pattern": "^5.7.0",
@@ -5098,6 +5099,8 @@
50985099

50995100
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
51005101

5102+
"stripe": ["stripe@20.0.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg=="],
5103+
51015104
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
51025105

51035106
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],

0 commit comments

Comments
 (0)