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

View File

@@ -11,6 +11,7 @@ import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard";
import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
/**
* This is the primary router for your server.
@@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({
invitation: invitationRouter,
dashboard: dashboardRouter,
suppression: suppressionRouter,
limits: limitsRouter,
});
// export type definition of API

View File

@@ -2,6 +2,7 @@ import { DailyEmailUsage, EmailUsageType, Subscription } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { format, sub } from "date-fns";
import { z } from "zod";
import { getThisMonthUsage } from "~/server/service/usage-service";
import {
apiKeyProcedure,
@@ -25,48 +26,7 @@ export const billingRouter = createTRPCRouter({
}),
getThisMonthUsage: teamProcedure.query(async ({ ctx }) => {
const isPaidPlan = ctx.team.plan !== "FREE";
let subscription: Subscription | null = null;
if (isPaidPlan) {
subscription = await db.subscription.findFirst({
where: { teamId: ctx.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" = ${ctx.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" = ${ctx.team.id}
AND "date" = ${today}
GROUP BY "type"
`,
]);
return {
month: monthUsage,
day: dayUsage,
};
return await getThisMonthUsage(ctx.team.id);
}),
getSubscriptionDetails: teamProcedure.query(async ({ ctx }) => {

View File

@@ -7,24 +7,13 @@ import {
teamProcedure,
} from "~/server/api/trpc";
import * as contactService from "~/server/service/contact-service";
import * as contactBookService from "~/server/service/contact-book-service";
export const contactsRouter = createTRPCRouter({
getContactBooks: teamProcedure
.input(z.object({ search: z.string().optional() }))
.query(async ({ ctx: { db, team }, input }) => {
return db.contactBook.findMany({
where: {
teamId: team.id,
...(input.search
? { name: { contains: input.search, mode: "insensitive" } }
: {}),
},
include: {
_count: {
select: { contacts: true },
},
},
});
.query(async ({ ctx: { team }, input }) => {
return contactBookService.getContactBooks(team.id, input.search);
}),
createContactBook: teamProcedure
@@ -33,40 +22,15 @@ export const contactsRouter = createTRPCRouter({
name: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
.mutation(async ({ ctx: { team }, input }) => {
const { name } = input;
const contactBook = await db.contactBook.create({
data: {
name,
teamId: team.id,
properties: {},
},
});
return contactBook;
return contactBookService.createContactBook(team.id, name);
}),
getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook, db } }) => {
const [totalContacts, unsubscribedContacts, campaigns] =
await Promise.all([
db.contact.count({
where: { contactBookId: contactBook.id },
}),
db.contact.count({
where: { contactBookId: contactBook.id, subscribed: false },
}),
db.campaign.findMany({
where: {
contactBookId: contactBook.id,
status: CampaignStatus.SENT,
},
orderBy: {
createdAt: "desc",
},
take: 2,
}),
]);
async ({ ctx: { contactBook } }) => {
const { totalContacts, unsubscribedContacts, campaigns } =
await contactBookService.getContactBookDetails(contactBook.id);
return {
...contactBook,
@@ -86,18 +50,14 @@ export const contactsRouter = createTRPCRouter({
emoji: z.string().optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
const { contactBookId, ...data } = input;
return db.contactBook.update({
where: { id: contactBookId },
data,
});
.mutation(async ({ ctx: { contactBook }, input }) => {
return contactBookService.updateContactBook(contactBook.id, input);
}),
deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { db }, input }) => {
return db.contactBook.delete({ where: { id: input.contactBookId } });
.mutation(async ({ ctx: { contactBook }, input }) => {
return contactBookService.deleteContactBook(contactBook.id);
}),
contacts: contactBookProcedure

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { LimitService } from "~/server/service/limit-service";
import { LimitReason } from "~/lib/constants/plans";
export const limitsRouter = createTRPCRouter({
get: teamProcedure
.input(
z.object({
type: z.nativeEnum(LimitReason),
}),
)
.query(async ({ ctx, input }) => {
switch (input.type) {
case LimitReason.CONTACT_BOOK:
return LimitService.checkContactBookLimit(ctx.team.id);
case LimitReason.DOMAIN:
return LimitService.checkDomainLimit(ctx.team.id);
case LimitReason.TEAM_MEMBER:
return LimitService.checkTeamMemberLimit(ctx.team.id);
case LimitReason.EMAIL:
return LimitService.checkEmailLimit(ctx.team.id);
default:
// exhaustive guard
throw new Error("Unsupported limit type");
}
}),
});

View File

@@ -1,6 +1,4 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import {
createTRPCRouter,
@@ -8,94 +6,25 @@ import {
teamProcedure,
teamAdminProcedure,
} from "~/server/api/trpc";
import { sendTeamInviteEmail } from "~/server/mailer";
import send from "~/server/public-api/api/emails/send-email";
import { logger } from "~/server/logger/log";
import { TeamService } from "~/server/service/team-service";
export const teamRouter = createTRPCRouter({
createTeam: protectedProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
const teams = await ctx.db.team.findMany({
where: {
teamUsers: {
some: {
userId: ctx.session.user.id,
},
},
},
});
if (teams.length > 0) {
logger.info({ userId: ctx.session.user.id }, "User already has a team");
return;
}
if (!env.NEXT_PUBLIC_IS_CLOUD) {
const _team = await ctx.db.team.findFirst();
if (_team) {
throw new TRPCError({
message: "Can't have multiple teams in self hosted version",
code: "UNAUTHORIZED",
});
}
}
return ctx.db.team.create({
data: {
name: input.name,
teamUsers: {
create: {
userId: ctx.session.user.id,
role: "ADMIN",
},
},
},
});
return TeamService.createTeam(ctx.session.user.id, input.name);
}),
getTeams: protectedProcedure.query(async ({ ctx }) => {
const teams = await ctx.db.team.findMany({
where: {
teamUsers: {
some: {
userId: ctx.session.user.id,
},
},
},
include: {
teamUsers: {
where: {
userId: ctx.session.user.id,
},
},
},
});
return teams;
return TeamService.getUserTeams(ctx.session.user.id);
}),
getTeamUsers: teamProcedure.query(async ({ ctx }) => {
const teamUsers = await ctx.db.teamUser.findMany({
where: {
teamId: ctx.team.id,
},
include: {
user: true,
},
});
return teamUsers;
return TeamService.getTeamUsers(ctx.team.id);
}),
getTeamInvites: teamProcedure.query(async ({ ctx }) => {
const teamInvites = await ctx.db.teamInvite.findMany({
where: {
teamId: ctx.team.id,
},
});
return teamInvites;
return TeamService.getTeamInvites(ctx.team.id);
}),
createTeamInvite: teamAdminProcedure
@@ -107,44 +36,13 @@ export const teamRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
if (!input.email) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email is required",
});
}
const user = await ctx.db.user.findUnique({
where: {
email: input.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 ctx.db.teamInvite.create({
data: {
teamId: ctx.team.id,
email: input.email,
role: input.role,
},
});
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`;
if (input.sendEmail) {
await sendTeamInviteEmail(input.email, teamUrl, ctx.team.name);
}
return teamInvite;
return TeamService.createTeamInvite(
ctx.team.id,
input.email,
input.role,
ctx.team.name,
input.sendEmail
);
}),
updateTeamUserRole: teamAdminProcedure
@@ -155,150 +53,33 @@ export const teamRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({
where: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.update({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
data: {
role: input.role,
},
});
return TeamService.updateTeamUserRole(
ctx.team.id,
input.userId,
input.role
);
}),
deleteTeamUser: teamProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({
where: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
if (
ctx.teamUser.role !== "ADMIN" &&
ctx.session.user.id !== Number(input.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 ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.delete({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
});
return TeamService.deleteTeamUser(
ctx.team.id,
input.userId,
ctx.teamUser.role,
ctx.session.user.id
);
}),
resendTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${invite.id}`;
// TODO: Implement email sending logic
await sendTeamInviteEmail(invite.email, teamUrl, ctx.team.name);
return { success: true };
return TeamService.resendTeamInvite(input.inviteId, ctx.team.name);
}),
deleteTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findFirst({
where: {
teamId: ctx.team.id,
id: {
equals: input.inviteId,
},
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
return ctx.db.teamInvite.delete({
where: {
teamId_email: {
teamId: ctx.team.id,
email: invite.email,
},
},
});
return TeamService.deleteTeamInvite(ctx.team.id, input.inviteId);
}),
});