mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
+51
-7
@@ -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
@@ -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 else’s 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user