Skip to content

Commit f232d73

Browse files
committed
chore: merge main into release for new releases
2 parents 8479bea + da9bab9 commit f232d73

File tree

8 files changed

+468
-311
lines changed

8 files changed

+468
-311
lines changed

apps/app/package.json

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

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,13 @@ export function PostPaymentOnboarding({
4747
userEmail,
4848
});
4949

50-
const isLocal = useMemo(() => {
51-
if (typeof window === 'undefined') return false;
52-
const host = window.location.host || '';
53-
return (
54-
process.env.NODE_ENV !== 'production' ||
55-
host.includes('localhost') ||
56-
host.startsWith('127.0.0.1') ||
57-
host.startsWith('::1')
58-
);
59-
}, []);
50+
const isLocal = process.env.NODE_ENV !== 'production';
51+
52+
// Internal-only: fast-path to complete onboarding (not exposed to customers)
53+
const canSkipOnboarding = useMemo(() => {
54+
if (!userEmail) return false;
55+
return userEmail.endsWith('@trycomp.ai');
56+
}, [userEmail]);
6057

6158
// Check if current step has valid input
6259
const currentStepValue = form.watch(step?.key);
@@ -217,7 +214,7 @@ export function PostPaymentOnboarding({
217214
</motion.div>
218215
)}
219216
</AnimatePresence>
220-
{isLocal && (
217+
{(isLocal || canSkipOnboarding) && (
221218
<motion.div
222219
key="complete-now"
223220
initial={{ opacity: 0, x: 20 }}

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { extractDomain, isDomainActiveStripeCustomer, isPublicEmailDomain } from '@/lib/stripe';
12
import { auth } from '@/utils/auth';
23
import { db } from '@db';
34
import { headers } from 'next/headers';
@@ -39,7 +40,38 @@ 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 userEmailDomain = extractDomain(userEmail ?? '');
49+
const orgWebsiteDomain = extractDomain(member.organization.website ?? '');
50+
51+
if (userEmailDomain) {
52+
// Auto-approve for trycomp.ai emails (internal team)
53+
const isTrycompEmail = userEmailDomain === 'trycomp.ai';
54+
55+
const canAutoApproveViaDomain =
56+
!isTrycompEmail &&
57+
Boolean(orgWebsiteDomain) &&
58+
userEmailDomain === orgWebsiteDomain &&
59+
!isPublicEmailDomain(userEmailDomain);
60+
61+
// Check Stripe for other domains
62+
const isStripeCustomer = canAutoApproveViaDomain
63+
? await isDomainActiveStripeCustomer(userEmailDomain)
64+
: false;
65+
66+
if (isTrycompEmail || isStripeCustomer) {
67+
await db.organization.update({
68+
where: { id: orgId },
69+
data: { hasAccess: true },
70+
});
71+
hasAccess = true;
72+
}
73+
}
74+
}
4375

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

apps/app/src/app/posthog.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use server';
2-
31
import { Properties } from 'posthog-js';
42
import { PostHog } from 'posthog-node';
53

apps/app/src/env.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const env = createEnv({
4444
GA4_MEASUREMENT_ID: z.string().optional(),
4545
LINKEDIN_CONVERSIONS_ACCESS_TOKEN: z.string().optional(),
4646
NOVU_API_KEY: z.string().optional(),
47+
STRIPE_SECRET_KEY: z.string().optional(),
4748
},
4849

4950
client: {
@@ -111,6 +112,7 @@ export const env = createEnv({
111112
NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
112113
NOVU_API_KEY: process.env.NOVU_API_KEY,
113114
NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER: process.env.NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER,
115+
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
114116
},
115117

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

apps/app/src/lib/stripe.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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+
// Domains that should NEVER be used for domain-based auto-approval.
12+
// These are shared/public mailbox providers where domain ownership does not imply company affiliation.
13+
const PUBLIC_EMAIL_DOMAINS = new Set([
14+
// Google
15+
'gmail.com',
16+
'googlemail.com',
17+
// Microsoft
18+
'outlook.com',
19+
'hotmail.com',
20+
'live.com',
21+
'msn.com',
22+
// Yahoo
23+
'yahoo.com',
24+
'ymail.com',
25+
// Apple
26+
'icloud.com',
27+
'me.com',
28+
'mac.com',
29+
// Proton
30+
'proton.me',
31+
'protonmail.com',
32+
'pm.me',
33+
// AOL
34+
'aol.com',
35+
]);
36+
37+
export const isPublicEmailDomain = (domain: string): boolean => {
38+
const normalized = domain.toLowerCase().trim().replace(/\.$/, '');
39+
return PUBLIC_EMAIL_DOMAINS.has(normalized);
40+
};
41+
42+
export const stripe = stripeSecretKey
43+
? new Stripe(stripeSecretKey, {
44+
apiVersion: '2025-12-15.clover',
45+
})
46+
: null;
47+
48+
/**
49+
* Extract domain from a website URL or email
50+
* @param input - URL (e.g., "https://example.com") or email (e.g., "[email protected]")
51+
* @returns Normalized domain (e.g., "example.com")
52+
*/
53+
export const extractDomain = (input: string): string | null => {
54+
if (!input) return null;
55+
56+
try {
57+
// If it looks like an email, extract domain from after @
58+
if (input.includes('@') && !input.includes('://')) {
59+
const domain = input.split('@')[1]?.toLowerCase().trim();
60+
return domain || null;
61+
}
62+
63+
// Otherwise, treat as URL
64+
let url = input.trim().toLowerCase();
65+
66+
// Add protocol if missing
67+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
68+
url = `https://${url}`;
69+
}
70+
71+
const parsed = new URL(url);
72+
return parsed.hostname.replace(/^www\./, '');
73+
} catch {
74+
return null;
75+
}
76+
};
77+
78+
/**
79+
* Check if a domain belongs to an existing Stripe customer
80+
* Searches by customer email domain and metadata
81+
*
82+
* @param domain - The domain to check (e.g., "acme.com")
83+
* @returns Customer ID if found, null otherwise
84+
*/
85+
export const findStripeCustomerByDomain = async (
86+
domain: string,
87+
): Promise<{ customerId: string; customerName: string | null } | null> => {
88+
if (!stripe) {
89+
console.warn('Stripe client not initialized - skipping customer lookup');
90+
return null;
91+
}
92+
93+
if (!domain) {
94+
return null;
95+
}
96+
97+
const normalizedDomain = domain.toLowerCase().trim().replace(/\.$/, '');
98+
99+
// Defense-in-depth: never treat public mailbox domains as proof of company ownership.
100+
if (isPublicEmailDomain(normalizedDomain)) {
101+
return null;
102+
}
103+
104+
try {
105+
// Prefer exact domain match via metadata when available.
106+
const customersWithMetadata = await stripe.customers.search({
107+
query: `metadata["domain"]:"${normalizedDomain}"`,
108+
limit: 1,
109+
});
110+
111+
if (customersWithMetadata.data.length > 0) {
112+
const customer = customersWithMetadata.data[0];
113+
return {
114+
customerId: customer.id,
115+
customerName: customer.name ?? null,
116+
};
117+
}
118+
119+
// Fallback: Stripe's email~ operator is substring matching; post-filter for exact email domain.
120+
const customers = await stripe.customers.search({
121+
query: `email~"@${normalizedDomain}"`,
122+
limit: 25,
123+
});
124+
125+
const exactDomainCustomer = customers.data.find((customer) => {
126+
const email = customer.email ?? '';
127+
const emailDomain = email.split('@')[1]?.toLowerCase().trim() ?? '';
128+
return emailDomain === normalizedDomain;
129+
});
130+
131+
if (exactDomainCustomer) {
132+
return {
133+
customerId: exactDomainCustomer.id,
134+
customerName: exactDomainCustomer.name ?? null,
135+
};
136+
}
137+
138+
return null;
139+
} catch (error) {
140+
console.error('Error searching Stripe customers:', error);
141+
return null;
142+
}
143+
};
144+
145+
/**
146+
* Check if a domain is an active Stripe customer with a valid subscription
147+
*
148+
* @param domain - The domain to check
149+
* @returns true if domain has an active subscription
150+
*/
151+
export const isDomainActiveStripeCustomer = async (domain: string): Promise<boolean> => {
152+
const normalizedDomain = domain.toLowerCase().trim().replace(/\.$/, '');
153+
154+
if (!normalizedDomain) {
155+
return false;
156+
}
157+
158+
// Never auto-approve based on public email domains.
159+
if (isPublicEmailDomain(normalizedDomain)) {
160+
return false;
161+
}
162+
163+
const customer = await findStripeCustomerByDomain(normalizedDomain);
164+
165+
if (!customer) {
166+
return false;
167+
}
168+
169+
if (!stripe) {
170+
return false;
171+
}
172+
173+
try {
174+
// Check if customer has an active subscription
175+
const subscriptions = await stripe.subscriptions.list({
176+
customer: customer.customerId,
177+
status: 'active',
178+
limit: 1,
179+
});
180+
181+
return subscriptions.data.length > 0;
182+
} catch (error) {
183+
console.error('Error checking Stripe subscriptions:', error);
184+
return false;
185+
}
186+
};

0 commit comments

Comments
 (0)