- Add stripeCustomerId column to users table - Create Stripe client initialization (web/src/server/stripe.ts) - Add billing service with getOrCreateCustomer, checkout/portal sessions, subscription management, invoice listing, and webhook event handling - Create billing tRPC router with getSubscription, createCheckoutSession, createPortalSession, cancelSubscription, reactivateSubscription, listInvoices - Add raw webhook endpoint at /api/stripe/webhook with signature verification - Define Valibot schemas for all billing procedure inputs - Wire billing router into root tRPC router - Update schema tests for new column/index counts - Write unit tests for billing service and router
243 lines
8.9 KiB
TypeScript
243 lines
8.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { initTRPC, TRPCError } from "@trpc/server";
|
|
import { wrap } from "@typeschema/valibot";
|
|
import { UpdateUserSchema, InviteMemberSchema, RemoveMemberSchema, UpdateRoleSchema } from "../schemas/user";
|
|
|
|
vi.mock("~/server/services/user.service", () => ({
|
|
getUserById: vi.fn(),
|
|
updateUser: vi.fn(),
|
|
deleteUser: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/server/services/family.service", () => ({
|
|
getFamilyGroup: vi.fn(),
|
|
inviteMember: vi.fn(),
|
|
removeMember: vi.fn(),
|
|
updateMemberRole: vi.fn(),
|
|
}));
|
|
|
|
import { getUserById, updateUser, deleteUser } from "~/server/services/user.service";
|
|
import { getFamilyGroup, inviteMember, removeMember, updateMemberRole } from "~/server/services/family.service";
|
|
|
|
const mockGetUserById = vi.mocked(getUserById);
|
|
const mockUpdateUser = vi.mocked(updateUser);
|
|
const mockDeleteUser = vi.mocked(deleteUser);
|
|
const mockGetFamilyGroup = vi.mocked(getFamilyGroup);
|
|
const mockInviteMember = vi.mocked(inviteMember);
|
|
const mockRemoveMember = vi.mocked(removeMember);
|
|
const mockUpdateMemberRole = vi.mocked(updateMemberRole);
|
|
|
|
type User = {
|
|
id: string; email: string; name: string | null; image: string | null;
|
|
role: "user" | "family_admin" | "family_member" | "support"; emailVerified: Date | null; deletedAt: Date | null;
|
|
stripeCustomerId: string | null;
|
|
createdAt: Date; updatedAt: Date;
|
|
};
|
|
type Ctx = { db: object; user: User | null; apiKey: string | null };
|
|
|
|
function createCaller(user: User | null) {
|
|
const t = initTRPC.context<Ctx>().create();
|
|
const isAuthed = t.middleware(({ ctx, next }) => {
|
|
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
});
|
|
|
|
const router = t.router({
|
|
me: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
|
return mockGetUserById(ctx.user.id);
|
|
}),
|
|
update: t.procedure.use(isAuthed)
|
|
.input(wrap(UpdateUserSchema))
|
|
.mutation(async ({ ctx, input }) => {
|
|
return mockUpdateUser(ctx.user.id, input as Record<string, unknown>);
|
|
}),
|
|
delete: t.procedure.use(isAuthed)
|
|
.mutation(async ({ ctx }) => {
|
|
await mockDeleteUser(ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
listFamilyMembers: t.procedure.use(isAuthed)
|
|
.query(async ({ ctx }) => {
|
|
const group = await mockGetFamilyGroup(ctx.user.id);
|
|
return group.members;
|
|
}),
|
|
inviteFamilyMember: t.procedure.use(isAuthed)
|
|
.input(wrap(InviteMemberSchema))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const group = await mockGetFamilyGroup(ctx.user.id);
|
|
const callerMember = group.members.find(
|
|
(m: { userId: string }) => m.userId === ctx.user.id,
|
|
);
|
|
if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) {
|
|
throw new TRPCError({ code: "FORBIDDEN" });
|
|
}
|
|
const i = input as { email: string; role: "admin" | "member" };
|
|
return mockInviteMember(group.id, i.email, ctx.user.id, i.role);
|
|
}),
|
|
removeFamilyMember: t.procedure.use(isAuthed)
|
|
.input(wrap(RemoveMemberSchema))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const group = await mockGetFamilyGroup(ctx.user.id);
|
|
const i = input as { userId: string };
|
|
await mockRemoveMember(group.id, i.userId, ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
updateFamilyMemberRole: t.procedure.use(isAuthed)
|
|
.input(wrap(UpdateRoleSchema))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const group = await mockGetFamilyGroup(ctx.user.id);
|
|
const i = input as { userId: string; role: "owner" | "admin" | "member" };
|
|
return mockUpdateMemberRole(group.id, i.userId, i.role, ctx.user.id);
|
|
}),
|
|
});
|
|
|
|
const caller = t.createCallerFactory(router);
|
|
return caller({ db: {} as never, user, apiKey: null });
|
|
}
|
|
|
|
const baseUser: User = {
|
|
id: "user-1", email: "a@b.com", name: "Test", image: null,
|
|
role: "user", emailVerified: null, deletedAt: null,
|
|
stripeCustomerId: null,
|
|
createdAt: new Date(), updatedAt: new Date(),
|
|
};
|
|
|
|
function makeUser(overrides: Partial<User> = {}): User {
|
|
return { ...baseUser, ...overrides };
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("user.me", () => {
|
|
it("returns authenticated user", async () => {
|
|
mockGetUserById.mockResolvedValue({
|
|
...makeUser(), accounts: [], sessions: [], deviceTokens: [],
|
|
familyGroups: [], familyGroupOwned: [], subscriptions: [],
|
|
});
|
|
const api = createCaller(makeUser());
|
|
expect((await api.me()).id).toBe("user-1");
|
|
});
|
|
|
|
it("rejects unauthenticated", async () => {
|
|
const api = createCaller(null);
|
|
await expect(api.me()).rejects.toThrow(TRPCError);
|
|
});
|
|
});
|
|
|
|
describe("user.update", () => {
|
|
it("updates name", async () => {
|
|
mockUpdateUser.mockResolvedValue(makeUser({ name: "New" }));
|
|
const api = createCaller(makeUser());
|
|
expect((await api.update({ name: "New" })).name).toBe("New");
|
|
});
|
|
|
|
it("updates email", async () => {
|
|
mockUpdateUser.mockResolvedValue(makeUser({ email: "new@b.com" }));
|
|
const api = createCaller(makeUser());
|
|
expect((await api.update({ email: "new@b.com" })).email).toBe("new@b.com");
|
|
});
|
|
});
|
|
|
|
describe("user.delete", () => {
|
|
it("deletes user", async () => {
|
|
mockDeleteUser.mockResolvedValue(makeUser({ deletedAt: now }));
|
|
const api = createCaller(makeUser());
|
|
expect((await api.delete()).success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("user.listFamilyMembers", () => {
|
|
it("returns family members", async () => {
|
|
mockGetFamilyGroup.mockResolvedValue({
|
|
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
|
|
members: [{
|
|
id: "m1", groupId: "g1", userId: "u1", role: "owner",
|
|
joinedAt: now, createdAt: now, updatedAt: now,
|
|
user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
|
|
}],
|
|
owner: makeUser(),
|
|
});
|
|
const api = createCaller(makeUser());
|
|
const result = await api.listFamilyMembers();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].role).toBe("owner");
|
|
});
|
|
});
|
|
|
|
describe("user.inviteFamilyMember", () => {
|
|
it("creates invitation", async () => {
|
|
mockGetFamilyGroup.mockResolvedValue({
|
|
id: "g1", name: "Fam", ownerId: "user-1", createdAt: now, updatedAt: now,
|
|
members: [{
|
|
id: "m1", groupId: "g1", userId: "user-1", role: "owner",
|
|
joinedAt: now, createdAt: now, updatedAt: now,
|
|
user: { id: "user-1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
|
|
}],
|
|
owner: makeUser(),
|
|
});
|
|
mockInviteMember.mockResolvedValue({
|
|
id: "i1", groupId: "g1", email: "new@b.com", role: "member",
|
|
invitedBy: "user-1", status: "pending", expiresAt: now, createdAt: now, updatedAt: now,
|
|
});
|
|
const api = createCaller(makeUser());
|
|
expect((await api.inviteFamilyMember({ email: "new@b.com" })).status).toBe("pending");
|
|
});
|
|
|
|
it("rejects non-admin", async () => {
|
|
mockGetFamilyGroup.mockResolvedValue({
|
|
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
|
|
members: [{
|
|
id: "m1", groupId: "g1", userId: "u2", role: "member",
|
|
joinedAt: now, createdAt: now, updatedAt: now,
|
|
user: { id: "u2", email: "b@b.com", name: "B", image: null, role: "user" } as unknown as User,
|
|
}],
|
|
owner: makeUser(),
|
|
});
|
|
const api = createCaller(makeUser({ id: "u2" }));
|
|
await expect(api.inviteFamilyMember({ email: "new@b.com" })).rejects.toThrow(TRPCError);
|
|
});
|
|
});
|
|
|
|
describe("user.removeFamilyMember", () => {
|
|
it("removes a member", async () => {
|
|
mockGetFamilyGroup.mockResolvedValue({
|
|
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
|
|
members: [{
|
|
id: "m1", groupId: "g1", userId: "u1", role: "owner",
|
|
joinedAt: now, createdAt: now, updatedAt: now,
|
|
user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
|
|
}],
|
|
owner: makeUser(),
|
|
});
|
|
mockRemoveMember.mockResolvedValue(undefined);
|
|
const api = createCaller(makeUser());
|
|
expect((await api.removeFamilyMember({ userId: "u3" })).success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("user.updateFamilyMemberRole", () => {
|
|
it("updates role", async () => {
|
|
mockGetFamilyGroup.mockResolvedValue({
|
|
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
|
|
members: [{
|
|
id: "m1", groupId: "g1", userId: "u1", role: "owner",
|
|
joinedAt: now, createdAt: now, updatedAt: now,
|
|
user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
|
|
}],
|
|
owner: makeUser(),
|
|
});
|
|
mockUpdateMemberRole.mockResolvedValue({
|
|
id: "m2", groupId: "g1", userId: "u2", role: "admin",
|
|
joinedAt: now, createdAt: now, updatedAt: now,
|
|
});
|
|
const api = createCaller(makeUser());
|
|
expect(
|
|
(await api.updateFamilyMemberRole({ userId: "u2", role: "admin" })).role,
|
|
).toBe("admin");
|
|
});
|
|
});
|