feat(billing): implement robust in-app subscription cancellation & fix CI/CD socket port typo

This commit is contained in:
Antigravity
2026-05-28 20:50:11 +00:00
parent f5608372dc
commit 457c6fa626
22 changed files with 656 additions and 460 deletions

View File

@@ -0,0 +1,66 @@
import { prisma } from '@/lib/prisma';
import { stripe } from '@/lib/stripe';
export interface CancelSubscriptionResult {
success: boolean;
error?: string;
}
/**
* Cancels an active user subscription by setting its auto-renew (cancel_at_period_end) status to true
* in Stripe and synchronizing the status back into the local Prisma database.
*
* @param userId - Unique identifier of the user
* @returns An object indicating the success or failure of the operation
*/
export async function cancelSubscription(userId: string): Promise<CancelSubscriptionResult> {
if (!userId) {
return { success: false, error: 'User ID is required' };
}
try {
const subscription = await prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
return { success: false, error: 'No active subscription found' };
}
if (subscription.stripeSubscriptionId) {
// Direct call to Stripe API with period end cancellation set to true
const updatedSub = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
});
// Local database synchronization
await prisma.subscription.update({
where: { userId },
data: {
cancelAtPeriodEnd: true,
canceledAt: updatedSub.canceled_at ? new Date(updatedSub.canceled_at * 1000) : new Date(),
currentPeriodEnd: updatedSub.current_period_end ? new Date(updatedSub.current_period_end * 1000) : subscription.currentPeriodEnd,
updatedAt: new Date(),
},
});
} else {
// Mock mode cancel (e.g. for development / local bypass testing)
await prisma.subscription.update({
where: { userId },
data: {
cancelAtPeriodEnd: true,
canceledAt: new Date(),
updatedAt: new Date(),
},
});
}
return { success: true };
} catch (error: any) {
console.error('[cancelSubscription] Error processing cancellation:', error);
return {
success: false,
error: error.message || 'Failed to cancel subscription',
};
}
}

View File

@@ -16,12 +16,22 @@ export function resolvePriceId(tier: BillingTier, interval: BillingInterval): st
};
const priceId = map[tier][interval];
if (!priceId) {
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
if (isMock && process.env.NODE_ENV !== 'test') {
return `price_mock_${tier.toLowerCase()}_${interval}`;
}
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
}
return priceId;
}
export function priceIdToTier(priceId: string): SubscriptionTier | null {
if (priceId && priceId.startsWith('price_mock_')) {
if (priceId.includes('pro')) return 'PRO';
if (priceId.includes('business')) return 'BUSINESS';
return 'BASIC';
}
const entries: Array<[string | undefined, SubscriptionTier]> = [
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],

View File

@@ -44,8 +44,19 @@ export async function syncSubscriptionFromStripe(
const status = mapStripeStatus(subscription.status);
const currentPeriodStart = new Date(((subscription as any).current_period_start as number) * 1000);
const currentPeriodEnd = new Date(((subscription as any).current_period_end as number) * 1000);
const currentPeriodStartTimestamp =
subscription.current_period_start ??
(subscription as any).items?.data?.[0]?.current_period_start ??
(subscription as any).start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
subscription.current_period_end ??
(subscription as any).items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
const currentPeriodStart = new Date(currentPeriodStartTimestamp * 1000);
const currentPeriodEnd = new Date(currentPeriodEndTimestamp * 1000);
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },