feat(billing): integrate Stripe for subscription management and billing access

- Added billing-related fields to the User model in the database.
- Implemented functions for managing billing access, including trial periods and subscription statuses.
- Created new billing utility functions for Stripe integration.
- Updated onboarding page to include billing overview and workspace creation eligibility.
- Enhanced route access checks to require billing access for certain actions.
- Implemented cleanup scripts for expired billing workspaces and associated media.
- Updated header component to conditionally show app navigation based on billing access.
- Added new migrations for billing-related database changes.
This commit is contained in:
Yusuf İpek
2026-04-08 17:50:40 +03:00
parent 8db7a031e6
commit 6f22b0bf8b
45 changed files with 1859 additions and 218 deletions
+98
View File
@@ -1,11 +1,13 @@
import { notFound, redirect } from 'next/navigation';
import { auth, checkProjectAccess, checkWorkspaceAccess } from '@/lib/auth';
import { hasBillingAccess } from '@/lib/billing';
import { db } from '@/lib/db';
type AccessIntent = 'view' | 'manage';
const LOGIN_REDIRECT = '/login';
const FORBIDDEN_REDIRECT = '/dashboard';
const BILLING_REDIRECT = '/settings';
function redirectForMissingAuth() {
redirect(LOGIN_REDIRECT);
@@ -15,6 +17,10 @@ function redirectForForbidden() {
redirect(FORBIDDEN_REDIRECT);
}
function redirectForBilling() {
redirect(BILLING_REDIRECT);
}
function ensureGuestPolicy(options: { userId?: string; intent: AccessIntent; allowPublicView: boolean }) {
const { userId, intent, allowPublicView } = options;
if (userId) return;
@@ -60,6 +66,92 @@ export async function requireAuthOrRedirect() {
return session;
}
export async function requireBillingAccessOrRedirect(options?: {
userId?: string;
}) {
const resolvedUserId = options?.userId ?? (await auth())?.user?.id;
if (!resolvedUserId) {
redirectForMissingAuth();
}
const user = await db.user.findUnique({
where: { id: resolvedUserId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
if (!user || !hasBillingAccess(user)) {
redirectForBilling();
}
return user;
}
export async function hasCollaboratorBillingBackedAccess(userId: string) {
const now = new Date();
const [workspaceCount, projectCount] = await Promise.all([
db.workspace.count({
where: {
owner: {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
},
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
],
},
}),
db.project.count({
where: {
workspace: {
owner: {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
},
},
OR: [
{ ownerId: userId },
{ members: { some: { userId } } },
{ workspace: { members: { some: { userId } } } },
],
},
}),
]);
return workspaceCount > 0 || projectCount > 0;
}
export async function hasAppNavigationAccess(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
if (user && hasBillingAccess(user)) {
return true;
}
return hasCollaboratorBillingBackedAccess(userId);
}
export async function requireWorkspaceAccessOrRedirect(options: {
workspaceId: string;
userId?: string;
@@ -84,10 +176,16 @@ export async function requireWorkspaceAccessOrRedirect(options: {
const access = await checkWorkspaceAccess(workspace, resolvedUserId);
if (!access.hasAccess) {
if (!access.ownerBillingActive) {
redirectForBilling();
}
redirectForForbidden();
}
if (intent === 'manage' && !access.canEdit) {
if (!access.ownerBillingActive) {
redirectForBilling();
}
redirectForForbidden();
}