Merge branch 'trial' into dev

This commit is contained in:
Owen
2026-04-21 15:05:23 -07:00
17 changed files with 629 additions and 19 deletions

View File

@@ -23,6 +23,14 @@
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
"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.",
"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",
"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}!",

View File

@@ -123,6 +123,7 @@ export enum ActionsEnum {
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode",

View File

@@ -91,6 +91,8 @@ export const subscriptions = pgTable("subscriptions", {
updatedAt: bigint("updatedAt", { mode: "number" }),
version: integer("version"),
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
});

View File

@@ -85,6 +85,8 @@ export const subscriptions = sqliteTable("subscriptions", {
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt"),
version: integer("version"),
expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false),
billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // tier1, tier2, tier3, or license
});

View 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;

View File

@@ -12,9 +12,10 @@
*/
import Stripe from "stripe";
import { customers, db } from "@server/db";
import { customers, db, subscriptions } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { generateId } from "@server/auth/sessions/app";
export async function handleCustomerCreated(
customer: Stripe.Customer
@@ -38,14 +39,31 @@ export async function handleCustomerCreated(
return;
}
await db.insert(customers).values({
customerId: customer.id,
orgId: customer.metadata.orgId,
email: customer.email || null,
name: customer.name || null,
createdAt: customer.created,
updatedAt: customer.created
await db.transaction(async (trx) => {
await trx.insert(customers).values({
customerId: customer.id,
orgId: customer.metadata.orgId,
email: customer.email || null,
name: customer.name || null,
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.`);
} catch (error) {
logger.error(

View File

@@ -70,6 +70,14 @@ authenticated.post(
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
logActionAudit(ActionsEnum.sendTrialNotification),
org.sendTrialNotification
);
authenticated.delete(
"/idp/:idpId",
verifyApiKeyIsRoot,

View File

@@ -12,3 +12,4 @@
*/
export * from "./sendUsageNotifications";
export * from "./sendTrialNotification";

View 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"
)
);
}
}

View File

@@ -12,7 +12,9 @@ import {
userOrgRoles,
userOrgs,
users,
actions
actions,
customers,
subscriptions
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -31,6 +33,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
import { doCidrsOverlap } from "@server/lib/ip";
import { generateCA } from "@server/lib/sshCA";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;

View File

@@ -21,6 +21,7 @@ import { Layout } from "@app/components/Layout";
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
import SubscriptionViolation from "@app/components/SubscriptionViolation";
export default async function OrgLayout(props: {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
@@ -110,6 +111,7 @@ export default async function OrgLayout(props: {
<ApplyInternalRedirect orgId={orgId} />
{props.children}
{build === "saas" && <SubscriptionViolation />}
<SetLastOrgCookie orgId={orgId} />
</SubscriptionStatusProvider>
);

View File

@@ -219,6 +219,7 @@ export default function BillingPage() {
);
const [hasSubscription, setHasSubscription] = useState(false);
const [isTrial, setIsTrial] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentTier, setCurrentTier] = useState<Tier | null>(null);
@@ -263,6 +264,7 @@ export default function BillingPage() {
setHasSubscription(
tierSub.subscription.status === "active"
);
setIsTrial(tierSub.subscription.expiresAt != null);
}
// Find license subscription
@@ -558,7 +560,7 @@ export default function BillingPage() {
// Get button label and action for each plan
const getPlanAction = (plan: PlanOption) => {
if (plan.id === "enterprise") {
if (plan.id === currentPlanId) {
if (plan.id === currentPlanId && !isTrial) {
return {
label: "Manage Current Plan",
action: handleModifySubscription,
@@ -597,6 +599,19 @@ export default function BillingPage() {
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 {
label: "Manage Current Plan",
action: handleModifySubscription,
@@ -610,7 +625,8 @@ export default function BillingPage() {
);
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 {
label: "Downgrade",
action: () => {
@@ -642,18 +658,23 @@ export default function BillingPage() {
label: "Upgrade",
action: () => {
if (plan.tierType) {
showTierConfirmation(
plan.tierType,
"upgrade",
plan.name,
plan.price + (" " + plan.priceDetail || "")
);
// During a trial, go straight to checkout instead of the tier-change flow
if (isTrial) {
handleStartSubscription(plan.tierType);
} else {
showTierConfirmation(
plan.tierType,
"upgrade",
plan.name,
plan.price + (" " + plan.priceDetail || "")
);
}
} else {
handleModifySubscription();
}
},
variant: "outline" as const,
disabled: isProblematicState
disabled: isProblematicState || (isTrial && plan.id == "basic")
};
};

View File

@@ -13,6 +13,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { cn } from "@app/lib/cn";
import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build";
@@ -31,6 +32,10 @@ const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
ssr: false
});
const ShowTrialCard = dynamic(() => import("./ShowTrialCard"), {
ssr: false
});
interface LayoutSidebarProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
@@ -55,6 +60,7 @@ export function LayoutSidebar({
const { user } = useUserContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
const { env } = useEnvContext();
const subscriptionContext = useSubscriptionStatusContext();
const t = useTranslations();
// 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 =
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
const showTrial =
build === "saas" &&
Boolean(orgId) &&
subscriptionContext?.isTrial
return (
<div
className={cn(
@@ -232,6 +243,12 @@ export function LayoutSidebar({
</div>
) : <div className="mt-0.2"></div>}
{showTrial && (
<div className="px-4">
<ShowTrialCard isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "enterprise" && (
<div className="px-4">
<SidebarLicenseButton
@@ -251,6 +268,7 @@ export function LayoutSidebar({
/>
</div>
)}
{!isSidebarCollapsed && (
<div className="px-4 space-y-2 pb-4">
{loadFooterLinks() ? (

View 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>
);
}

View 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 };

View File

@@ -10,6 +10,10 @@ type SubscriptionStatusContextType = {
subscribed: boolean;
/** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */
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<

View File

@@ -71,6 +71,19 @@ export function SubscriptionStatusProvider({
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 (
<SubscriptionStatusContext.Provider
value={{
@@ -79,7 +92,9 @@ export function SubscriptionStatusProvider({
getTier,
isSubscribed,
subscribed,
limitsExceeded
limitsExceeded,
trialExpiresAt,
isTrial: isTrial
}}
>
{children}