feat: make billing better (#203)
This commit is contained in:
@@ -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
|
||||
|
@@ -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 }) => {
|
||||
|
@@ -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
|
||||
|
28
apps/web/src/server/api/routers/limits.ts
Normal file
28
apps/web/src/server/api/routers/limits.ts
Normal 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");
|
||||
}
|
||||
}),
|
||||
});
|
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
Reference in New Issue
Block a user