mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-27 11:12:55 +00:00
Add trial system
This commit is contained in:
@@ -23,6 +23,14 @@
|
|||||||
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
|
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
|
||||||
|
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
|
||||||
|
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
|
||||||
|
"trialActive": "Free Trial Active",
|
||||||
|
"trialExpired": "Trial Expired",
|
||||||
|
"trialHasEnded": "Your trial has ended.",
|
||||||
|
"trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}",
|
||||||
|
"trialDaysLeftShort": "{days}d left in trial",
|
||||||
|
"trialGoToBilling": "Go to billing page",
|
||||||
"subscriptionViolationViewBilling": "View billing",
|
"subscriptionViolationViewBilling": "View billing",
|
||||||
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
|
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
|
||||||
"componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!",
|
"componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!",
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export enum ActionsEnum {
|
|||||||
deleteOrgDomain = "deleteOrgDomain",
|
deleteOrgDomain = "deleteOrgDomain",
|
||||||
restartOrgDomain = "restartOrgDomain",
|
restartOrgDomain = "restartOrgDomain",
|
||||||
sendUsageNotification = "sendUsageNotification",
|
sendUsageNotification = "sendUsageNotification",
|
||||||
|
sendTrialNotification = "sendTrialNotification",
|
||||||
createRemoteExitNode = "createRemoteExitNode",
|
createRemoteExitNode = "createRemoteExitNode",
|
||||||
updateRemoteExitNode = "updateRemoteExitNode",
|
updateRemoteExitNode = "updateRemoteExitNode",
|
||||||
getRemoteExitNode = "getRemoteExitNode",
|
getRemoteExitNode = "getRemoteExitNode",
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const subscriptions = pgTable("subscriptions", {
|
|||||||
updatedAt: bigint("updatedAt", { mode: "number" }),
|
updatedAt: bigint("updatedAt", { mode: "number" }),
|
||||||
version: integer("version"),
|
version: integer("version"),
|
||||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||||
|
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||||
|
trial: boolean("trial").default(false),
|
||||||
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
createdAt: integer("createdAt").notNull(),
|
createdAt: integer("createdAt").notNull(),
|
||||||
updatedAt: integer("updatedAt"),
|
updatedAt: integer("updatedAt"),
|
||||||
version: integer("version"),
|
version: integer("version"),
|
||||||
|
expiresAt: integer("expiresAt"),
|
||||||
|
trial: integer("trial", { mode: "boolean" }).default(false),
|
||||||
billingCycleAnchor: integer("billingCycleAnchor"),
|
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||||
type: text("type") // tier1, tier2, tier3, or license
|
type: text("type") // tier1, tier2, tier3, or license
|
||||||
});
|
});
|
||||||
|
|||||||
127
server/emails/templates/NotifyTrialExpiring.tsx
Normal file
127
server/emails/templates/NotifyTrialExpiring.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
|
import { themeColors } from "./lib/theme";
|
||||||
|
import {
|
||||||
|
EmailContainer,
|
||||||
|
EmailFooter,
|
||||||
|
EmailGreeting,
|
||||||
|
EmailHeading,
|
||||||
|
EmailLetterHead,
|
||||||
|
EmailSignature,
|
||||||
|
EmailText
|
||||||
|
} from "./components/Email";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
orgName: string;
|
||||||
|
trialEndsAt: string;
|
||||||
|
daysRemaining: number | null;
|
||||||
|
billingLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotifyTrialExpiring = ({
|
||||||
|
email,
|
||||||
|
orgName,
|
||||||
|
trialEndsAt,
|
||||||
|
daysRemaining,
|
||||||
|
billingLink
|
||||||
|
}: Props) => {
|
||||||
|
const hasEnded = daysRemaining === null || daysRemaining === 0;
|
||||||
|
const isLastDay = daysRemaining === 1;
|
||||||
|
|
||||||
|
const previewText = hasEnded
|
||||||
|
? `Your trial for ${orgName} has ended.`
|
||||||
|
: isLastDay
|
||||||
|
? `Your trial for ${orgName} ends tomorrow.`
|
||||||
|
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
|
||||||
|
|
||||||
|
const heading = hasEnded
|
||||||
|
? "Your Trial Has Ended"
|
||||||
|
: "Your Trial Is Ending Soon";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind config={themeColors}>
|
||||||
|
<Body className="font-sans bg-gray-50">
|
||||||
|
<EmailContainer>
|
||||||
|
<EmailLetterHead />
|
||||||
|
|
||||||
|
<EmailHeading>{heading}</EmailHeading>
|
||||||
|
|
||||||
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
|
{hasEnded ? (
|
||||||
|
<>
|
||||||
|
<EmailText>
|
||||||
|
Your free trial for{" "}
|
||||||
|
<strong>{orgName}</strong> ended on{" "}
|
||||||
|
<strong>{trialEndsAt}</strong>. Your account
|
||||||
|
has been moved to the free plan, which
|
||||||
|
includes limited functionality.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
Some features and resources may now be
|
||||||
|
restricted or disconnected. To restore full
|
||||||
|
access and continue using all the features
|
||||||
|
you had during your trial, please upgrade to
|
||||||
|
a paid plan.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
You can{" "}
|
||||||
|
<a href={billingLink}>
|
||||||
|
upgrade your plan here
|
||||||
|
</a>{" "}
|
||||||
|
to get back up and running right away.
|
||||||
|
</EmailText>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EmailText>
|
||||||
|
Just a reminder that your free trial for{" "}
|
||||||
|
<strong>{orgName}</strong> will end on{" "}
|
||||||
|
<strong>{trialEndsAt}</strong>
|
||||||
|
{isLastDay
|
||||||
|
? " — that's tomorrow!"
|
||||||
|
: `, in ${daysRemaining} days`}
|
||||||
|
.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
After your trial ends, your account will be
|
||||||
|
moved to the free plan and some
|
||||||
|
functionality may be restricted or your
|
||||||
|
sites may disconnect.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
To avoid any interruption to your service,
|
||||||
|
we encourage you to upgrade before your
|
||||||
|
trial expires. You can{" "}
|
||||||
|
<a href={billingLink}>
|
||||||
|
upgrade your plan here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</EmailText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
If you have any questions or need assistance, please
|
||||||
|
don't hesitate to reach out to our support team.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailFooter>
|
||||||
|
<EmailSignature />
|
||||||
|
</EmailFooter>
|
||||||
|
</EmailContainer>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotifyTrialExpiring;
|
||||||
@@ -12,9 +12,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { customers, db } from "@server/db";
|
import { customers, db, subscriptions } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
export async function handleCustomerCreated(
|
export async function handleCustomerCreated(
|
||||||
customer: Stripe.Customer
|
customer: Stripe.Customer
|
||||||
@@ -38,14 +39,31 @@ export async function handleCustomerCreated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(customers).values({
|
await db.transaction(async (trx) => {
|
||||||
customerId: customer.id,
|
await trx.insert(customers).values({
|
||||||
orgId: customer.metadata.orgId,
|
customerId: customer.id,
|
||||||
email: customer.email || null,
|
orgId: customer.metadata.orgId,
|
||||||
name: customer.name || null,
|
email: customer.email || null,
|
||||||
createdAt: customer.created,
|
name: customer.name || null,
|
||||||
updatedAt: customer.created
|
createdAt: customer.created,
|
||||||
|
updatedAt: customer.created
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert a 14-day trial subscription at tier3
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const trialExpiresAt = now + 10 * 24 * 60 * 60;
|
||||||
|
const subscriptionId = `trial-${generateId(15)}`;
|
||||||
|
await trx.insert(subscriptions).values({
|
||||||
|
subscriptionId,
|
||||||
|
customerId: customer.id,
|
||||||
|
status: "active",
|
||||||
|
type: "tier3",
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: trialExpiresAt,
|
||||||
|
trial: true
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Customer with ID ${customer.id} created successfully.`);
|
logger.info(`Customer with ID ${customer.id} created successfully.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ authenticated.post(
|
|||||||
org.sendUsageNotification
|
org.sendUsageNotification
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/org/:orgId/send-trial-notification`,
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
|
||||||
|
logActionAudit(ActionsEnum.sendTrialNotification),
|
||||||
|
org.sendTrialNotification
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/idp/:idpId",
|
"/idp/:idpId",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./sendUsageNotifications";
|
export * from "./sendUsageNotifications";
|
||||||
|
export * from "./sendTrialNotification";
|
||||||
|
|||||||
224
server/private/routers/org/sendTrialNotification.ts
Normal file
224
server/private/routers/org/sendTrialNotification.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db";
|
||||||
|
import { eq, and, or } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
const sendTrialNotificationParamsSchema = z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendTrialNotificationBodySchema = z.object({
|
||||||
|
notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]),
|
||||||
|
orgName: z.string(),
|
||||||
|
trialEndsAt: z.number(),
|
||||||
|
billingLink: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendTrialNotificationResponse = {
|
||||||
|
success: boolean;
|
||||||
|
emailsSent: number;
|
||||||
|
adminEmails: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getOrgAdmins(orgId: string) {
|
||||||
|
const admins = await db
|
||||||
|
.select({
|
||||||
|
userId: users.userId,
|
||||||
|
email: users.email,
|
||||||
|
name: users.name,
|
||||||
|
isOwner: userOrgs.isOwner,
|
||||||
|
roleName: roles.name,
|
||||||
|
isAdminRole: roles.isAdmin
|
||||||
|
})
|
||||||
|
.from(userOrgs)
|
||||||
|
.innerJoin(users, eq(userOrgs.userId, users.userId))
|
||||||
|
.leftJoin(
|
||||||
|
userOrgRoles,
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userOrgRoles.userId),
|
||||||
|
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
or(eq(userOrgs.isOwner, true), eq(roles.isAdmin, true))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const byUserId = new Map(
|
||||||
|
admins.map((a) => [a.userId, a])
|
||||||
|
);
|
||||||
|
const orgAdmins = Array.from(byUserId.values()).filter(
|
||||||
|
(admin) => admin.email && admin.email.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return orgAdmins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTrialNotification(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = sendTrialNotificationParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = sendTrialNotificationBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
|
// Verify organization exists
|
||||||
|
const org = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (org.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all admin users for this organization
|
||||||
|
const orgAdmins = await getOrgAdmins(orgId);
|
||||||
|
|
||||||
|
if (orgAdmins.length === 0) {
|
||||||
|
logger.warn(`No admin users found for organization ${orgId}`);
|
||||||
|
return response<SendTrialNotificationResponse>(res, {
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
emailsSent: 0,
|
||||||
|
adminEmails: []
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "No admin users found to notify",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingLink =
|
||||||
|
bodyBillingLink ??
|
||||||
|
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
|
||||||
|
|
||||||
|
const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{ year: "numeric", month: "long", day: "numeric" }
|
||||||
|
);
|
||||||
|
|
||||||
|
let daysRemaining: number | null;
|
||||||
|
let subject: string;
|
||||||
|
|
||||||
|
if (notificationType === "trial_ending_5d") {
|
||||||
|
daysRemaining = 5;
|
||||||
|
subject = "Your trial ends in 5 days";
|
||||||
|
} else if (notificationType === "trial_ending_24h") {
|
||||||
|
daysRemaining = 1;
|
||||||
|
subject = "Your trial ends tomorrow";
|
||||||
|
} else {
|
||||||
|
daysRemaining = null;
|
||||||
|
subject = "Your trial has ended";
|
||||||
|
}
|
||||||
|
|
||||||
|
let emailsSent = 0;
|
||||||
|
const adminEmails: string[] = [];
|
||||||
|
|
||||||
|
for (const admin of orgAdmins) {
|
||||||
|
if (!admin.email) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = NotifyTrialExpiring({
|
||||||
|
email: admin.email,
|
||||||
|
orgName,
|
||||||
|
trialEndsAt: trialEndsAtFormatted,
|
||||||
|
daysRemaining,
|
||||||
|
billingLink
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail(template, {
|
||||||
|
to: admin.email,
|
||||||
|
from: config.getNoReplyEmail(),
|
||||||
|
subject
|
||||||
|
});
|
||||||
|
|
||||||
|
emailsSent++;
|
||||||
|
adminEmails.push(admin.email);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Trial notification sent to admin ${admin.email} for org ${orgId}`
|
||||||
|
);
|
||||||
|
} catch (emailError) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send trial notification to ${admin.email}:`,
|
||||||
|
emailError
|
||||||
|
);
|
||||||
|
// Continue with other admins even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<SendTrialNotificationResponse>(res, {
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
emailsSent,
|
||||||
|
adminEmails
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: `Trial notifications sent to ${emailsSent} administrators`,
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error sending trial notifications:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to send trial notifications"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
users,
|
users,
|
||||||
actions
|
actions,
|
||||||
|
customers,
|
||||||
|
subscriptions
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -31,6 +33,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
|
|||||||
import { doCidrsOverlap } from "@server/lib/ip";
|
import { doCidrsOverlap } from "@server/lib/ip";
|
||||||
import { generateCA } from "@server/lib/sshCA";
|
import { generateCA } from "@server/lib/sshCA";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Layout } from "@app/components/Layout";
|
|||||||
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
|
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
|
||||||
import SubscriptionViolation from "@app/components/SubscriptionViolation";
|
import SubscriptionViolation from "@app/components/SubscriptionViolation";
|
||||||
|
|
||||||
|
|
||||||
export default async function OrgLayout(props: {
|
export default async function OrgLayout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -110,6 +111,7 @@ export default async function OrgLayout(props: {
|
|||||||
<ApplyInternalRedirect orgId={orgId} />
|
<ApplyInternalRedirect orgId={orgId} />
|
||||||
{props.children}
|
{props.children}
|
||||||
{build === "saas" && <SubscriptionViolation />}
|
{build === "saas" && <SubscriptionViolation />}
|
||||||
|
|
||||||
<SetLastOrgCookie orgId={orgId} />
|
<SetLastOrgCookie orgId={orgId} />
|
||||||
</SubscriptionStatusProvider>
|
</SubscriptionStatusProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export default function BillingPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [hasSubscription, setHasSubscription] = useState(false);
|
const [hasSubscription, setHasSubscription] = useState(false);
|
||||||
|
const [isTrial, setIsTrial] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentTier, setCurrentTier] = useState<Tier | null>(null);
|
const [currentTier, setCurrentTier] = useState<Tier | null>(null);
|
||||||
|
|
||||||
@@ -263,6 +264,7 @@ export default function BillingPage() {
|
|||||||
setHasSubscription(
|
setHasSubscription(
|
||||||
tierSub.subscription.status === "active"
|
tierSub.subscription.status === "active"
|
||||||
);
|
);
|
||||||
|
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find license subscription
|
// Find license subscription
|
||||||
@@ -558,7 +560,7 @@ export default function BillingPage() {
|
|||||||
// Get button label and action for each plan
|
// Get button label and action for each plan
|
||||||
const getPlanAction = (plan: PlanOption) => {
|
const getPlanAction = (plan: PlanOption) => {
|
||||||
if (plan.id === "enterprise") {
|
if (plan.id === "enterprise") {
|
||||||
if (plan.id === currentPlanId) {
|
if (plan.id === currentPlanId && !isTrial) {
|
||||||
return {
|
return {
|
||||||
label: "Manage Current Plan",
|
label: "Manage Current Plan",
|
||||||
action: handleModifySubscription,
|
action: handleModifySubscription,
|
||||||
@@ -597,6 +599,19 @@ export default function BillingPage() {
|
|||||||
disabled: false
|
disabled: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// If this is a trial subscription, show an upgrade button that starts a real checkout
|
||||||
|
if (isTrial) {
|
||||||
|
return {
|
||||||
|
label: "Upgrade",
|
||||||
|
action: () => {
|
||||||
|
if (plan.tierType) {
|
||||||
|
handleStartSubscription(plan.tierType);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variant: "default" as const,
|
||||||
|
disabled: isProblematicState
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: "Manage Current Plan",
|
label: "Manage Current Plan",
|
||||||
action: handleModifySubscription,
|
action: handleModifySubscription,
|
||||||
@@ -610,7 +625,8 @@ export default function BillingPage() {
|
|||||||
);
|
);
|
||||||
const planIndex = planOptions.findIndex((p) => p.id === plan.id);
|
const planIndex = planOptions.findIndex((p) => p.id === plan.id);
|
||||||
|
|
||||||
if (planIndex < currentIndex) {
|
// During a trial, never show a downgrade option — all non-current plans are upgrades
|
||||||
|
if (!isTrial && planIndex < currentIndex) {
|
||||||
return {
|
return {
|
||||||
label: "Downgrade",
|
label: "Downgrade",
|
||||||
action: () => {
|
action: () => {
|
||||||
@@ -642,18 +658,23 @@ export default function BillingPage() {
|
|||||||
label: "Upgrade",
|
label: "Upgrade",
|
||||||
action: () => {
|
action: () => {
|
||||||
if (plan.tierType) {
|
if (plan.tierType) {
|
||||||
showTierConfirmation(
|
// During a trial, go straight to checkout instead of the tier-change flow
|
||||||
plan.tierType,
|
if (isTrial) {
|
||||||
"upgrade",
|
handleStartSubscription(plan.tierType);
|
||||||
plan.name,
|
} else {
|
||||||
plan.price + (" " + plan.priceDetail || "")
|
showTierConfirmation(
|
||||||
);
|
plan.tierType,
|
||||||
|
"upgrade",
|
||||||
|
plan.name,
|
||||||
|
plan.price + (" " + plan.priceDetail || "")
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
handleModifySubscription();
|
handleModifySubscription();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
variant: "outline" as const,
|
variant: "outline" as const,
|
||||||
disabled: isProblematicState
|
disabled: isProblematicState || (isTrial && plan.id == "basic")
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { approvalQueries } from "@app/lib/queries";
|
import { approvalQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -31,6 +32,10 @@ const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
|
|||||||
ssr: false
|
ssr: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ShowTrialCard = dynamic(() => import("./ShowTrialCard"), {
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
|
||||||
interface LayoutSidebarProps {
|
interface LayoutSidebarProps {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgs?: ListUserOrgsResponse["orgs"];
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
@@ -55,6 +60,7 @@ export function LayoutSidebar({
|
|||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
const subscriptionContext = useSubscriptionStatusContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
// Fetch pending approval count if we have an orgId and it's not an admin page
|
// Fetch pending approval count if we have an orgId and it's not an admin page
|
||||||
@@ -122,6 +128,11 @@ export function LayoutSidebar({
|
|||||||
const canShowProductUpdates =
|
const canShowProductUpdates =
|
||||||
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
|
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
|
||||||
|
|
||||||
|
const showTrial =
|
||||||
|
build === "saas" &&
|
||||||
|
Boolean(orgId) &&
|
||||||
|
subscriptionContext?.isTrial
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -227,6 +238,12 @@ export function LayoutSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showTrial && (
|
||||||
|
<div className="px-4">
|
||||||
|
<ShowTrialCard isCollapsed={isSidebarCollapsed} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{build === "enterprise" && (
|
{build === "enterprise" && (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<SidebarLicenseButton
|
<SidebarLicenseButton
|
||||||
@@ -246,6 +263,7 @@ export function LayoutSidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSidebarCollapsed && (
|
{!isSidebarCollapsed && (
|
||||||
<div className="px-4 space-y-2 pb-4">
|
<div className="px-4 space-y-2 pb-4">
|
||||||
{loadFooterLinks() ? (
|
{loadFooterLinks() ? (
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { Tier } from "@server/types/Tiers";
|
import { Tier } from "@server/types/Tiers";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
// const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
||||||
|
const TIER_ORDER: Tier[] = ["tier2", "tier3", "enterprise"];
|
||||||
|
|
||||||
const TIER_TRANSLATION_KEYS: Record<
|
const TIER_TRANSLATION_KEYS: Record<
|
||||||
Tier,
|
Tier,
|
||||||
|
|||||||
98
src/components/ShowTrialCard.tsx
Normal file
98
src/components/ShowTrialCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClockIcon, ArrowRight } from "lucide-react";
|
||||||
|
import { ProgressBackwards } from "@app/components/ui/progress-backwards";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@app/components/ui/tooltip";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
const TRIAL_DURATION_DAYS = 14;
|
||||||
|
|
||||||
|
export default function ShowTrialCard({
|
||||||
|
isCollapsed
|
||||||
|
}: {
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
}) {
|
||||||
|
const context = useSubscriptionStatusContext();
|
||||||
|
const params = useParams();
|
||||||
|
const orgId = params?.orgId as string | undefined;
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const trialExpiresAt = context?.trialExpiresAt ?? null;
|
||||||
|
|
||||||
|
if (trialExpiresAt == null) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const remainingMs = trialExpiresAt - now;
|
||||||
|
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));
|
||||||
|
const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100));
|
||||||
|
// Inverted: full bar at start, drains to empty as trial ends
|
||||||
|
const displayPct = 100 - progressPct;
|
||||||
|
|
||||||
|
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={billingHref}
|
||||||
|
className="flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
|
||||||
|
>
|
||||||
|
<ClockIcon className="h-4 w-4 flex-none" />
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
<p>
|
||||||
|
{remainingDays === 0
|
||||||
|
? t("trialExpired")
|
||||||
|
: t("trialDaysLeftShort", { days: remainingDays })}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={billingHref}
|
||||||
|
className={cn(
|
||||||
|
"group cursor-pointer block",
|
||||||
|
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
|
||||||
|
"transition duration-200 ease-in-out hover:bg-secondary/80 dark:hover:bg-secondary/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClockIcon className="flex-none size-4 text-muted-foreground" />
|
||||||
|
<p className="font-medium flex-1 leading-tight">
|
||||||
|
{remainingDays === 0
|
||||||
|
? t("trialExpired")
|
||||||
|
: t("trialActive")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<ProgressBackwards value={displayPct} className="h-1.5" />
|
||||||
|
<small className="text-muted-foreground">
|
||||||
|
{remainingDays === 0
|
||||||
|
? t("trialHasEnded")
|
||||||
|
: t("trialDaysRemaining", { count: remainingDays })}
|
||||||
|
</small>
|
||||||
|
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
<span>{t("trialGoToBilling")}</span>
|
||||||
|
<ArrowRight className="flex-none size-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/progress-backwards.tsx
Normal file
58
src/components/ui/progress-backwards.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const progressVariants = cva(
|
||||||
|
"border relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
success: "bg-muted",
|
||||||
|
warning: "bg-muted",
|
||||||
|
danger: "bg-muted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const indicatorVariants = cva("h-full w-full flex-1 transition-all", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary",
|
||||||
|
success: "bg-green-500",
|
||||||
|
warning: "bg-yellow-500",
|
||||||
|
danger: "bg-red-500"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> &
|
||||||
|
VariantProps<typeof progressVariants>;
|
||||||
|
|
||||||
|
function ProgressBackwards({ className, value, variant, ...props }: ProgressProps) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(progressVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn(indicatorVariants({ variant }))}
|
||||||
|
style={{ transform: `translateX(${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ProgressBackwards };
|
||||||
@@ -10,6 +10,10 @@ type SubscriptionStatusContextType = {
|
|||||||
subscribed: boolean;
|
subscribed: boolean;
|
||||||
/** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */
|
/** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */
|
||||||
limitsExceeded: boolean;
|
limitsExceeded: boolean;
|
||||||
|
/** Unix timestamp (ms) when the trial expires, or null if not in trial. */
|
||||||
|
trialExpiresAt: number | null;
|
||||||
|
/** True if the organization is currently in a trial period. */
|
||||||
|
isTrial: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SubscriptionStatusContext = createContext<
|
const SubscriptionStatusContext = createContext<
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ export function SubscriptionStatusProvider({
|
|||||||
|
|
||||||
const limitsExceeded = subscriptionStatusState?.limitsExceeded ?? false;
|
const limitsExceeded = subscriptionStatusState?.limitsExceeded ?? false;
|
||||||
|
|
||||||
|
const trialExpiresAt = (() => {
|
||||||
|
if (subscriptionStatusState?.subscriptions) {
|
||||||
|
for (const { subscription } of subscriptionStatusState.subscriptions) {
|
||||||
|
if (subscription.expiresAt != null) {
|
||||||
|
return subscription.expiresAt * 1000; // convert seconds to ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isTrial = subscriptionStatusState?.subscriptions?.some(({ subscription }) => subscription.trial) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubscriptionStatusContext.Provider
|
<SubscriptionStatusContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -79,7 +92,9 @@ export function SubscriptionStatusProvider({
|
|||||||
getTier,
|
getTier,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
subscribed,
|
subscribed,
|
||||||
limitsExceeded
|
limitsExceeded,
|
||||||
|
trialExpiresAt,
|
||||||
|
isTrial: isTrial
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user