mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-28 03:32:20 +00:00
Add trial system
This commit is contained in:
@@ -123,6 +123,7 @@ export enum ActionsEnum {
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain",
|
||||
sendUsageNotification = "sendUsageNotification",
|
||||
sendTrialNotification = "sendTrialNotification",
|
||||
createRemoteExitNode = "createRemoteExitNode",
|
||||
updateRemoteExitNode = "updateRemoteExitNode",
|
||||
getRemoteExitNode = "getRemoteExitNode",
|
||||
|
||||
@@ -90,6 +90,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
|
||||
});
|
||||
|
||||
|
||||
@@ -84,6 +84,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
|
||||
});
|
||||
|
||||
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 { 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(
|
||||
|
||||
@@ -48,6 +48,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,
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
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_]+)*$/;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user