feat: make billing better (#203)

This commit is contained in:
KM Koushik
2025-08-25 22:35:21 +10:00
committed by GitHub
parent 8ce5e4b2dd
commit 3f9094e95d
25 changed files with 956 additions and 360 deletions
@@ -0,0 +1,83 @@
import { CampaignStatus, type ContactBook } from "@prisma/client";
import { db } from "../db";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
export async function getContactBooks(teamId: number, search?: string) {
return db.contactBook.findMany({
where: {
teamId,
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
},
include: {
_count: {
select: { contacts: true },
},
},
});
}
export async function createContactBook(teamId: number, name: string) {
const { isLimitReached, reason } =
await LimitService.checkContactBookLimit(teamId);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Contact book limit reached",
});
}
return db.contactBook.create({
data: {
name,
teamId,
properties: {},
},
});
}
export async function getContactBookDetails(contactBookId: string) {
const [totalContacts, unsubscribedContacts, campaigns] = await Promise.all([
db.contact.count({
where: { contactBookId },
}),
db.contact.count({
where: { contactBookId, subscribed: false },
}),
db.campaign.findMany({
where: {
contactBookId,
status: CampaignStatus.SENT,
},
orderBy: {
createdAt: "desc",
},
take: 2,
}),
]);
return {
totalContacts,
unsubscribedContacts,
campaigns,
};
}
export async function updateContactBook(
contactBookId: string,
data: {
name?: string;
properties?: Record<string, string>;
emoji?: string;
}
) {
return db.contactBook.update({
where: { id: contactBookId },
data,
});
}
export async function deleteContactBook(contactBookId: string) {
return db.contactBook.delete({ where: { id: contactBookId } });
}
+15 -4
View File
@@ -6,6 +6,7 @@ import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log";
import { LimitService } from "./limit-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
@@ -58,7 +59,7 @@ export async function createDomain(
teamId: number,
name: string,
region: string,
sesTenantId?: string,
sesTenantId?: string
) {
const domainStr = tldts.getDomain(name);
@@ -74,6 +75,16 @@ export async function createDomain(
throw new Error("Ses setting not found");
}
const { isLimitReached, reason } =
await LimitService.checkDomainLimit(teamId);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Domain limit reached",
});
}
const subdomain = tldts.getSubdomain(name);
const publicKey = await ses.addDomain(name, region, sesTenantId);
@@ -105,7 +116,7 @@ export async function getDomain(id: number) {
if (domain.isVerifying) {
const domainIdentity = await ses.getDomainIdentity(
domain.name,
domain.region,
domain.region
);
const dkimStatus = domainIdentity.DkimAttributes?.Status;
@@ -150,7 +161,7 @@ export async function getDomain(id: number) {
export async function updateDomain(
id: number,
data: { clickTracking?: boolean; openTracking?: boolean },
data: { clickTracking?: boolean; openTracking?: boolean }
) {
return db.domain.update({
where: { id },
@@ -170,7 +181,7 @@ export async function deleteDomain(id: number) {
const deleted = await ses.deleteDomain(
domain.name,
domain.region,
domain.sesTenantId ?? undefined,
domain.sesTenantId ?? undefined
);
if (!deleted) {
@@ -0,0 +1,160 @@
import { PLAN_LIMITS, LimitReason } from "~/lib/constants/plans";
import { db } from "../db";
import { getThisMonthUsage } from "./usage-service";
function isLimitExceeded(current: number, limit: number): boolean {
if (limit === -1) return false; // unlimited
return current >= limit;
}
export class LimitService {
static async checkDomainLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
_count: {
select: {
domains: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
const limit = PLAN_LIMITS[team.plan].domains;
if (isLimitExceeded(team._count.domains, limit)) {
return {
isLimitReached: true,
limit,
reason: LimitReason.DOMAIN,
};
}
return {
isLimitReached: false,
limit,
};
}
static async checkContactBookLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
_count: {
select: {
contactBooks: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
const limit = PLAN_LIMITS[team.plan].contactBooks;
if (isLimitExceeded(team._count.contactBooks, limit)) {
return {
isLimitReached: true,
limit,
reason: LimitReason.CONTACT_BOOK,
};
}
return {
isLimitReached: false,
limit,
};
}
static async checkTeamMemberLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
teamUsers: true,
},
});
if (!team) {
throw new Error("Team not found");
}
const limit = PLAN_LIMITS[team.plan].teamMembers;
if (isLimitExceeded(team.teamUsers.length, limit)) {
return {
isLimitReached: true,
limit,
reason: LimitReason.TEAM_MEMBER,
};
}
return {
isLimitReached: false,
limit,
};
}
static async checkEmailLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
}
// FREE plan has hard limits; paid plans are unlimited (-1)
if (team.plan === "FREE") {
const usage = await getThisMonthUsage(teamId);
const monthlyUsage = usage.month.reduce(
(acc, curr) => acc + curr.sent,
0,
);
const dailyUsage = usage.day.reduce((acc, curr) => acc + curr.sent, 0);
const monthlyLimit = PLAN_LIMITS[team.plan].emailsPerMonth;
const dailyLimit = PLAN_LIMITS[team.plan].emailsPerDay;
if (isLimitExceeded(monthlyUsage, monthlyLimit)) {
return {
isLimitReached: true,
limit: monthlyLimit,
reason: LimitReason.EMAIL,
};
}
if (isLimitExceeded(dailyUsage, dailyLimit)) {
return {
isLimitReached: true,
limit: dailyLimit,
reason: LimitReason.EMAIL,
};
}
}
return {
isLimitReached: false,
limit: PLAN_LIMITS[team.plan].emailsPerMonth,
};
}
}
+293
View File
@@ -0,0 +1,293 @@
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import { db } from "~/server/db";
import { sendTeamInviteEmail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
import type { Team, TeamInvite } from "@prisma/client";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
export class TeamService {
static async createTeam(
userId: number,
name: string
): Promise<Team | undefined> {
const teams = await db.team.findMany({
where: {
teamUsers: {
some: {
userId: userId,
},
},
},
});
if (teams.length > 0) {
logger.info({ userId }, "User already has a team");
return;
}
if (!env.NEXT_PUBLIC_IS_CLOUD) {
const _team = await db.team.findFirst();
if (_team) {
throw new TRPCError({
message: "Can't have multiple teams in self hosted version",
code: "UNAUTHORIZED",
});
}
}
return db.team.create({
data: {
name,
teamUsers: {
create: {
userId,
role: "ADMIN",
},
},
},
});
}
static async getUserTeams(userId: number) {
return db.team.findMany({
where: {
teamUsers: {
some: {
userId: userId,
},
},
},
include: {
teamUsers: {
where: {
userId: userId,
},
},
},
});
}
static async getTeamUsers(teamId: number) {
return db.teamUser.findMany({
where: {
teamId,
},
include: {
user: true,
},
});
}
static async getTeamInvites(teamId: number) {
return db.teamInvite.findMany({
where: {
teamId,
},
});
}
static async createTeamInvite(
teamId: number,
email: string,
role: "MEMBER" | "ADMIN",
teamName: string,
sendEmail: boolean = true
): Promise<TeamInvite> {
if (!email) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email is required",
});
}
const { isLimitReached, reason } =
await LimitService.checkTeamMemberLimit(teamId);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Team invite limit reached",
});
}
const user = await db.user.findUnique({
where: {
email,
},
include: {
teamUsers: true,
},
});
if (user && user.teamUsers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User already part of a team",
});
}
const teamInvite = await db.teamInvite.create({
data: {
teamId,
email,
role,
},
});
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`;
if (sendEmail) {
await sendTeamInviteEmail(email, teamUrl, teamName);
}
return teamInvite;
}
static async updateTeamUserRole(
teamId: number,
userId: string,
role: "MEMBER" | "ADMIN"
) {
const teamUser = await db.teamUser.findFirst({
where: {
teamId,
userId: Number(userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
// Check if this is the last admin
const adminCount = await db.teamUser.count({
where: {
teamId,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return db.teamUser.update({
where: {
teamId_userId: {
teamId,
userId: Number(userId),
},
},
data: {
role,
},
});
}
static async deleteTeamUser(
teamId: number,
userId: string,
requestorRole: string,
requestorId: number
) {
const teamUser = await db.teamUser.findFirst({
where: {
teamId,
userId: Number(userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
if (requestorRole !== "ADMIN" && requestorId !== Number(userId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this team member",
});
}
// Check if this is the last admin
const adminCount = await db.teamUser.count({
where: {
teamId,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return db.teamUser.delete({
where: {
teamId_userId: {
teamId,
userId: Number(userId),
},
},
});
}
static async resendTeamInvite(inviteId: string, teamName: string) {
const invite = await db.teamInvite.findUnique({
where: {
id: inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${invite.id}`;
await sendTeamInviteEmail(invite.email, teamUrl, teamName);
return { success: true };
}
static async deleteTeamInvite(teamId: number, inviteId: string) {
const invite = await db.teamInvite.findFirst({
where: {
teamId,
id: {
equals: inviteId,
},
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
return db.teamInvite.delete({
where: {
teamId_email: {
teamId,
email: invite.email,
},
},
});
}
}
@@ -0,0 +1,63 @@
import { EmailUsageType, Subscription } from "@prisma/client";
import { db } from "../db";
import { format } from "date-fns";
/**
* Gets the monthly and daily usage for a team
* @param teamId - The team ID to get usage for
* @param db - Prisma database client
* @param subscription - Optional subscription to determine billing period start
* @returns Object containing month and day usage arrays
*/
export async function getThisMonthUsage(teamId: number) {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
}
let subscription: Subscription | null = null;
const isPaidPlan = team.plan !== "FREE";
if (isPaidPlan) {
subscription = await db.subscription.findFirst({
where: { teamId: team.id },
orderBy: { status: "asc" },
});
}
const isoStartDate = subscription?.currentPeriodStart
? format(subscription.currentPeriodStart, "yyyy-MM-dd")
: format(new Date(), "yyyy-MM-01"); // First day of current month
const today = format(new Date(), "yyyy-MM-dd");
const [monthUsage, dayUsage] = await Promise.all([
// Get month usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
GROUP BY "type"
`,
// Get today's usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" = ${today}
GROUP BY "type"
`,
]);
return {
month: monthUsage,
day: dayUsage,
};
}