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
+51 -7
View File
@@ -3,6 +3,7 @@ import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { hasBillingAccess } from '@/lib/billing';
// Dummy hash for timing-safe comparison when user doesn't exist
// This prevents user enumeration via timing attacks
@@ -114,6 +115,7 @@ export async function checkProjectAccess(
// Check workspace membership/role
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
let workspaceOwnerBillingAccess = false;
if (shouldLoadWorkspaceRole && userId) {
const [wsMember, wsOwner] = await Promise.all([
db.workspaceMember.findUnique({
@@ -121,7 +123,17 @@ export async function checkProjectAccess(
}),
db.workspace.findUnique({
where: { id: project.workspaceId },
select: { ownerId: true },
select: {
ownerId: true,
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
}),
]);
@@ -130,13 +142,32 @@ export async function checkProjectAccess(
} else if (wsMember) {
workspaceRole = wsMember.role;
}
if (wsOwner?.owner) {
workspaceOwnerBillingAccess = hasBillingAccess(wsOwner.owner);
}
} else {
const wsOwner = await db.workspace.findUnique({
where: { id: project.workspaceId },
select: {
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
});
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
}
const isWorkspaceMember = !!workspaceRole;
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
const hasAccess = isOwner || isProjectMember || isPublic || isWorkspaceMember;
const canEdit = isOwner || isProjectAdmin || isWorkspaceAdmin;
const canDelete = isOwner || workspaceRole === 'OWNER';
const hasAccess = workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
return {
isOwner,
@@ -147,6 +178,7 @@ export async function checkProjectAccess(
hasAccess,
canEdit,
canDelete,
ownerBillingActive: workspaceOwnerBillingAccess,
};
}
@@ -166,9 +198,20 @@ export async function checkWorkspaceAccess(
const isMember = !!workspaceMember;
const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN;
const hasAccess = isOwner || isMember;
const canEdit = isOwner || isAdmin;
const canDelete = isOwner;
const owner = await db.user.findUnique({
where: { id: workspace.ownerId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
const ownerBillingActive = owner ? hasBillingAccess(owner) : false;
const hasAccess = ownerBillingActive && (isOwner || isMember);
const canEdit = ownerBillingActive && (isOwner || isAdmin);
const canDelete = ownerBillingActive && isOwner;
return {
isOwner,
@@ -177,5 +220,6 @@ export async function checkWorkspaceAccess(
hasAccess,
canEdit,
canDelete,
ownerBillingActive,
};
}
+418
View File
@@ -0,0 +1,418 @@
import type { Prisma } from '@prisma/client';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.ACTIVE,
BillingSubscriptionStatus.TRIALING,
]);
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
const STORAGE_CLEANUP_GRACE_DAYS = 15;
type BillingAccessSubject = {
subscriptionStatus: BillingSubscriptionStatus;
trialEndsAt: Date | null;
stripeCurrentPeriodEnd: Date | null;
stripeCancelAtPeriodEnd?: boolean | null;
stripeCancelAt?: Date | null;
billingAccessEndedAt: Date | null;
};
export function getDefaultTrialEndsAt(from: Date = new Date()) {
return new Date(from.getTime() + DEFAULT_TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000);
}
export function hasActiveTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) {
return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime());
}
export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) {
if (!status) return false;
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
}
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
if (hasActiveSubscription(subject.subscriptionStatus)) {
return true;
}
if (hasActiveTrial(subject.trialEndsAt, now)) {
return true;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
if (subject.billingAccessEndedAt) {
return subject.billingAccessEndedAt;
}
if (subject.stripeCurrentPeriodEnd) {
return subject.stripeCurrentPeriodEnd;
}
return subject.trialEndsAt;
}
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
const accessEndDate = getBillingAccessEndDate(subject);
if (!accessEndDate) return null;
return new Date(accessEndDate.getTime() + STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000);
}
export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
return {
OR: [
{ subscriptionStatus: { in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING] } },
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
],
};
}
export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
const cleanupCutoff = new Date(now.getTime() - STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000);
return {
AND: [
{
NOT: buildBillingAccessWhereInput(now),
},
{
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
AND: [
{ billingAccessEndedAt: null },
{ trialEndsAt: { lte: cleanupCutoff } },
],
},
],
},
],
};
}
export function mapStripeSubscriptionStatus(
status: Stripe.Subscription.Status | null | undefined
): BillingSubscriptionStatus {
switch (status) {
case 'trialing':
return BillingSubscriptionStatus.TRIALING;
case 'active':
return BillingSubscriptionStatus.ACTIVE;
case 'past_due':
return BillingSubscriptionStatus.PAST_DUE;
case 'canceled':
return BillingSubscriptionStatus.CANCELED;
case 'unpaid':
return BillingSubscriptionStatus.UNPAID;
case 'incomplete':
return BillingSubscriptionStatus.INCOMPLETE;
case 'incomplete_expired':
return BillingSubscriptionStatus.INCOMPLETE_EXPIRED;
default:
return BillingSubscriptionStatus.FREE;
}
}
export function getBillingStatusLabel(status: BillingSubscriptionStatus) {
switch (status) {
case BillingSubscriptionStatus.TRIALING:
return 'Trialing';
case BillingSubscriptionStatus.ACTIVE:
return 'Active';
case BillingSubscriptionStatus.PAST_DUE:
return 'Past due';
case BillingSubscriptionStatus.CANCELED:
return 'Canceled';
case BillingSubscriptionStatus.UNPAID:
return 'Unpaid';
case BillingSubscriptionStatus.INCOMPLETE:
return 'Incomplete';
case BillingSubscriptionStatus.INCOMPLETE_EXPIRED:
return 'Expired';
case BillingSubscriptionStatus.FREE:
default:
return 'Free';
}
}
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
billingTrialConsumedAt: true,
},
});
if (!user) {
throw new Error(`User ${userId} not found`);
}
return {
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
isTrialEligible: !user.billingTrialConsumedAt,
};
}
export async function getWorkspaceCreationEligibility(userId: string) {
const [user, ownedWorkspaceCount, invitedWorkspaceCount, projectOnlyCollaborationCount] = await Promise.all([
db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
stripePriceId: true,
stripeCurrentPeriodEnd: true,
stripeCancelAtPeriodEnd: true,
stripeCancelAt: true,
billingAccessEndedAt: true,
},
}),
db.workspace.count({
where: { ownerId: userId },
}),
db.workspaceMember.count({
where: {
userId,
workspace: {
ownerId: {
not: userId,
},
},
},
}),
db.projectMember.count({
where: {
userId,
project: {
ownerId: {
not: userId,
},
workspace: {
ownerId: {
not: userId,
},
},
},
},
}),
]);
if (!user) {
throw new Error(`User ${userId} not found`);
}
const billingAccess = hasBillingAccess(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
const canCreateWorkspace =
billingAccess || (ownedWorkspaceCount === 0 && collaborationCount === 0);
let reason: string | null = null;
if (!canCreateWorkspace) {
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
reason =
'You are currently collaborating in someone elses workspace or project. Start a subscription to create a workspace of your own.';
} else {
reason =
'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
}
return {
canCreateWorkspace,
reason,
ownedWorkspaceCount,
invitedWorkspaceCount,
projectOnlyCollaborationCount,
subscription: {
status: user.subscriptionStatus,
label: getBillingStatusLabel(user.subscriptionStatus),
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
hasBillingAccess: billingAccess,
stripeCustomerId: user.stripeCustomerId,
stripeSubscriptionId: user.stripeSubscriptionId,
stripePriceId: user.stripePriceId,
currentPeriodEnd: user.stripeCurrentPeriodEnd,
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
cancelAt: user.stripeCancelAt,
trialEndsAt: user.trialEndsAt,
billingAccessEndedAt: user.billingAccessEndedAt,
storageCleanupEligibleAt: getStorageCleanupEligibleAt(user),
},
};
}
export async function getBillingOverview(userId: string) {
const billing = await getWorkspaceCreationEligibility(userId);
return {
workspaceCreation: {
canCreateWorkspace: billing.canCreateWorkspace,
reason: billing.reason,
ownedWorkspaceCount: billing.ownedWorkspaceCount,
invitedWorkspaceCount: billing.invitedWorkspaceCount,
},
subscription: billing.subscription,
};
}
export async function getOrCreateStripeCustomerId(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
stripeCustomerId: true,
},
});
if (!user) {
throw new Error(`User ${userId} not found`);
}
if (user.stripeCustomerId) {
return user.stripeCustomerId;
}
const stripe = getStripe();
const customer = await stripe.customers.create({
email: user.email ?? undefined,
name: user.name ?? undefined,
metadata: { userId: user.id },
});
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id },
});
return customer.id;
}
function getStripeTimestamp(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
function getInactiveBillingAccessEndedAt(subscription: Stripe.Subscription, currentPeriodEnd: number | null) {
const endedAt = getStripeTimestamp((subscription as Stripe.Subscription & { ended_at?: unknown }).ended_at);
const canceledAt = getStripeTimestamp((subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at);
const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
return reference ? new Date(reference * 1000) : new Date();
}
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
const configuredPriceId = getStripePriceId();
return subscription.items.data.find((item) => item.price.id === configuredPriceId)?.price.id ?? null;
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
billingTrialConsumedAt: true,
},
});
if (!user) {
return null;
}
const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? subscription.current_period_end
: null;
const cancelAt =
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
? subscription.cancel_at
: null;
const cancelAtPeriodEnd =
'cancel_at_period_end' in subscription && typeof subscription.cancel_at_period_end === 'boolean'
? subscription.cancel_at_period_end
: false;
const trialEnd =
'trial_end' in subscription && typeof subscription.trial_end === 'number'
? subscription.trial_end
: null;
const entitledPriceId = getEntitledStripePriceId(subscription);
const hasEntitledPrice = Boolean(entitledPriceId);
const mappedStatus = hasEntitledPrice
? mapStripeSubscriptionStatus(subscription.status)
: BillingSubscriptionStatus.FREE;
const effectiveCurrentPeriodEnd = hasEntitledPrice && currentPeriodEnd
? new Date(currentPeriodEnd * 1000)
: null;
const effectiveTrialEnd = hasEntitledPrice && trialEnd
? new Date(trialEnd * 1000)
: null;
const hasAccess = hasEntitledPrice
&& (hasActiveSubscription(mappedStatus) || Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
return db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: entitledPriceId ?? subscription.items.data[0]?.price.id ?? null,
stripeCurrentPeriodEnd: effectiveCurrentPeriodEnd,
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
subscriptionStatus: mappedStatus,
trialEndsAt: effectiveTrialEnd,
billingTrialConsumedAt: hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
billingAccessEndedAt: hasAccess
? null
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
}
export async function markSubscriptionCanceledByCustomerId(
customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) {
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
select: { id: true },
});
if (!user) {
return null;
}
return db.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
trialEndsAt: null,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
}
+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();
}
+23
View File
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs';
import type { ShareLink, SharePermission } from '@prisma/client';
import { db } from '@/lib/db';
import { hasBillingAccess } from '@/lib/billing';
export const MAX_SHARE_PASSWORD_LENGTH = 128;
@@ -45,6 +46,24 @@ export async function validateShareLinkAccess({
}: ValidateShareLinkParams): Promise<ShareLinkAccessResult> {
const link = await db.shareLink.findUnique({
where: { token },
include: {
project: {
select: {
workspace: {
select: {
owner: {
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
},
},
},
},
},
},
});
if (!link) {
@@ -60,6 +79,10 @@ export async function validateShareLinkAccess({
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
}
if (!link.project?.workspace.owner || !hasBillingAccess(link.project.workspace.owner)) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link };
}
if (link.passwordHash && !passwordVerified) {
if (!presentedPassword) {
return { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: true, link };
+38
View File
@@ -0,0 +1,38 @@
import Stripe from 'stripe';
let stripeClient: Stripe | null = null;
export function isStripeConfigured() {
return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID);
}
export function getStripe() {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('STRIPE_SECRET_KEY is not configured');
}
if (!stripeClient) {
stripeClient = new Stripe(secretKey);
}
return stripeClient;
}
export function getStripePriceId() {
const priceId = process.env.STRIPE_PRICE_ID;
if (!priceId) {
throw new Error('STRIPE_PRICE_ID is not configured');
}
return priceId;
}
export function getStripeWebhookSecret() {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
}
return webhookSecret;
}