feat: make billing better (#203)
This commit is contained in:
@@ -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 } });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user