Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions api-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class MockVercelRequest implements VercelRequest {
url: string;
method: string;
headers: IncomingMessage['headers'];
body: any;
body: unknown;
query: { [key: string]: string | string[] };
cookies: { [key: string]: string };

Expand Down Expand Up @@ -61,13 +61,13 @@ class MockVercelResponse implements VercelResponse {
return this;
}

json(data: any): void {
json(data: unknown): void {
this.setHeader('Content-Type', 'application/json');
this.res.writeHead(this.statusCode, this.headers);
this.res.end(JSON.stringify(data));
}

send(data: any): void {
send(data: string | Buffer): void {
this.res.writeHead(this.statusCode, this.headers);
this.res.end(data);
}
Expand Down Expand Up @@ -152,8 +152,9 @@ const server = createServer(async (req: IncomingMessage, res: ServerResponse) =>
res.writeHead(500);
res.end(JSON.stringify({ error: 'Invalid API handler export' }));
}
} catch (error: any) {
console.error(`Error handling ${endpoint}:`, error);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`Error handling ${endpoint}:`, errorMessage);
res.writeHead(500);
res.end(JSON.stringify({
error: 'Internal Server Error'
Expand Down
14 changes: 7 additions & 7 deletions api/create-checkout-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,19 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {

const stripe = new Stripe(stripeSecretKey);

// Map planId to Stripe price IDs based on period
// Map planId to Stripe price IDs based on period (standardized naming)
const priceIdMap: Record<string, Record<string, string>> = {
'pro': {
'month': process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly',
'year': process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly',
'month': process.env.STRIPE_PRICE_PRO_MONTH || process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly',
'year': process.env.STRIPE_PRICE_PRO_YEAR || process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly',
},
'enterprise': {
'month': process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly',
'year': process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly',
'month': process.env.STRIPE_PRICE_ENTERPRISE_MONTH || process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly',
'year': process.env.STRIPE_PRICE_ENTERPRISE_YEAR || process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly',
},
'starter': {
'month': process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly',
'year': process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly',
'month': process.env.STRIPE_PRICE_STARTER_MONTH || process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly',
'year': process.env.STRIPE_PRICE_STARTER_YEAR || process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly',
},
};

Expand Down
42 changes: 30 additions & 12 deletions api/get-subscription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth';
import Stripe from 'stripe';
import {
StripeSubscription,
StripeCustomer,
SubscriptionData,
PlanType,
getSubscriptionPeriod,
isStripeSubscription,
isStripeCustomer
} from '../src/types/stripe';

function withCors(res: VercelResponse, allowOrigin?: string) {
const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*';
Expand Down Expand Up @@ -132,26 +141,35 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const subscription = subscriptions.data[0];
const priceId = subscription.items.data[0]?.price.id;

// Map Stripe price IDs back to plan IDs
// Map Stripe price IDs back to plan IDs (standardized naming)
const planIdMap: Record<string, string> = {
[process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro',
[process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro',
[process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise',
[process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise',
[process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter',
[process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter',
[process.env.STRIPE_PRICE_PRO_MONTH || process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro',
[process.env.STRIPE_PRICE_PRO_YEAR || process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro',
[process.env.STRIPE_PRICE_ENTERPRISE_MONTH || process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise',
[process.env.STRIPE_PRICE_ENTERPRISE_YEAR || process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise',
[process.env.STRIPE_PRICE_STARTER_MONTH || process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter',
[process.env.STRIPE_PRICE_STARTER_YEAR || process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter',
};

const planId = planIdMap[priceId] || 'free';

const subscriptionData = {
planId: planId,
// Type-safe period extraction
const subscriptionPeriod = getSubscriptionPeriod(subscription);
if (!subscriptionPeriod) {
console.error('Invalid subscription object structure');
return withCors(res, allowedOrigin).status(500).json({
error: 'Invalid subscription data'
});
}

const subscriptionData: SubscriptionData = {
planId: planId as PlanType,
status: subscription.status,
currentPeriodStart: (subscription as any).current_period_start * 1000, // Convert to milliseconds
currentPeriodEnd: (subscription as any).current_period_end * 1000, // Convert to milliseconds
currentPeriodStart: subscriptionPeriod.currentPeriodStart,
currentPeriodEnd: subscriptionPeriod.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
stripeSubscriptionId: subscription.id,
stripeCustomerId: customer.id,
stripeCustomerId: typeof customer === 'string' ? customer : customer.id,
};

console.log('Retrieved subscription data for user:', authenticatedUserId, 'plan:', planId);
Expand Down
67 changes: 45 additions & 22 deletions api/stripe-webhook.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import Stripe from 'stripe';

// Helper function to map Stripe price IDs to plan types
import {
StripeSubscription,
StripeCustomer,
StripeInvoice,
StripeWebhookEvent,
PlanType,
getSubscriptionPeriod,
getCustomerEmail,
getCustomerMetadata,
getInvoiceSubscriptionId,
isStripeSubscription,
isStripeCustomer,
isStripeInvoice
} from '../src/types/stripe';

// Helper function to map Stripe price IDs to plan types (standardized naming)
function mapPriceIdToPlanType(priceId: string): 'free' | 'pro' | 'enterprise' | 'starter' {
const priceIdMap: Record<string, 'free' | 'pro' | 'enterprise' | 'starter'> = {
[process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro',
[process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro',
[process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise',
[process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise',
[process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter',
[process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter',
[process.env.STRIPE_PRICE_PRO_MONTH || process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro',
[process.env.STRIPE_PRICE_PRO_YEAR || process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro',
[process.env.STRIPE_PRICE_ENTERPRISE_MONTH || process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise',
[process.env.STRIPE_PRICE_ENTERPRISE_YEAR || process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise',
[process.env.STRIPE_PRICE_STARTER_MONTH || process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter',
[process.env.STRIPE_PRICE_STARTER_YEAR || process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter',
};
return priceIdMap[priceId] || 'free';
}
Expand All @@ -18,7 +32,7 @@ function mapPriceIdToPlanType(priceId: string): 'free' | 'pro' | 'enterprise' |

// Simplified subscription sync - for now just log the webhook events
// In production, you would want to use Convex HTTP actions or a queue system
async function logWebhookEvent(eventType: string, data: any): Promise<void> {
async function logWebhookEvent(eventType: string, data: unknown): Promise<void> {
console.log(`[WEBHOOK] ${eventType}:`, JSON.stringify(data, null, 2));

// TODO: Implement proper Convex HTTP action calls or use a queue system
Expand Down Expand Up @@ -53,15 +67,22 @@ async function syncSubscriptionToConvex(
const priceId = subscription.items.data[0]?.price.id;
const planType = mapPriceIdToPlanType(priceId);

// Type-safe period extraction
const subscriptionPeriod = getSubscriptionPeriod(subscription);
if (!subscriptionPeriod) {
console.error('Invalid subscription structure, cannot sync');
return;
}

// Log the subscription sync
await logWebhookEvent('subscription_sync', {
userId,
stripeCustomerId,
planId: priceId,
planType,
status: subscription.status,
currentPeriodStart: (subscription as any).current_period_start * 1000,
currentPeriodEnd: (subscription as any).current_period_end * 1000,
currentPeriodStart: subscriptionPeriod.currentPeriodStart,
currentPeriodEnd: subscriptionPeriod.currentPeriodEnd,
timestamp: now
});

Expand Down Expand Up @@ -199,7 +220,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
// Ensure customer mapping exists
const customer = await stripe.customers.retrieve(session.customer as string);
if (!customer.deleted) {
await ensureCustomerMapping(userId, customer.id, (customer as any).email || '');
await ensureCustomerMapping(userId, customer.id, getCustomerEmail(customer));
}

// Get the subscription ID from the session
Expand All @@ -225,15 +246,15 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
break;
}

const userId = !customer.deleted ? (customer as any).metadata?.userId : null;
const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null;
if (!userId) {
console.error('No userId found in customer metadata for subscription:', subscription.id);
break;
}

// Ensure customer mapping exists
if (!customer.deleted) {
await ensureCustomerMapping(userId, customer.id, (customer as any).email || '');
await ensureCustomerMapping(userId, customer.id, getCustomerEmail(customer));
}

// Sync subscription data
Expand All @@ -253,7 +274,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
break;
}

const userId = !customer.deleted ? (customer as any).metadata?.userId : null;
const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null;
if (!userId) {
console.error('No userId found in customer metadata for subscription:', subscription.id);
break;
Expand All @@ -275,7 +296,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
break;
}

const userId = !customer.deleted ? (customer as any).metadata?.userId : null;
const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null;
if (!userId) {
console.error('No userId found in customer metadata for subscription:', subscription.id);
break;
Expand All @@ -292,16 +313,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
console.log('Payment succeeded for invoice:', invoice.id);

// Get subscription if this is a subscription invoice
if ((invoice as any).subscription) {
const subscription = await stripe.subscriptions.retrieve((invoice as any).subscription as string);
const subscriptionId = getInvoiceSubscriptionId(invoice);
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const customer = await stripe.customers.retrieve(subscription.customer as string);

if (customer.deleted) {
console.error('Customer was deleted:', subscription.customer);
break;
}

const userId = !customer.deleted ? (customer as any).metadata?.userId : null;
const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null;
if (userId) {
await syncSubscriptionToConvex(userId, subscription.customer as string, subscription);
console.log('Successfully processed payment success for user:', userId);
Expand All @@ -315,16 +337,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
console.log('Payment failed for invoice:', invoice.id);

// Get subscription if this is a subscription invoice
if ((invoice as any).subscription) {
const subscription = await stripe.subscriptions.retrieve((invoice as any).subscription as string);
const subscriptionId = getInvoiceSubscriptionId(invoice);
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const customer = await stripe.customers.retrieve(subscription.customer as string);

if (customer.deleted) {
console.error('Customer was deleted:', subscription.customer);
break;
}

const userId = !customer.deleted ? (customer as any).metadata?.userId : null;
const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null;
if (userId) {
// Sync the current subscription status (might be past_due)
await syncSubscriptionToConvex(userId, subscription.customer as string, subscription);
Expand Down
Loading