183 lines
8.0 KiB
JavaScript
183 lines
8.0 KiB
JavaScript
import BillingService from "@/services/system/BillingService.js";
|
|
import ApiError from "@/utils/ApiError.js";
|
|
|
|
// Mock Org model
|
|
jest.mock("@/db/models/index.js", () => ({
|
|
Org: { findById: jest.fn(), findOne: jest.fn() },
|
|
}));
|
|
import { Org } from "@/db/models/index.js";
|
|
|
|
// Capture a shared mock Stripe client instance
|
|
jest.mock("stripe", () => {
|
|
const mockClient = {
|
|
prices: { list: jest.fn() },
|
|
customers: { create: jest.fn() },
|
|
checkout: { sessions: { create: jest.fn() } },
|
|
billingPortal: { sessions: { create: jest.fn() } },
|
|
subscriptions: { retrieve: jest.fn() },
|
|
};
|
|
const MockStripe = jest.fn(() => mockClient);
|
|
MockStripe.__mockClient = mockClient;
|
|
return { __esModule: true, default: MockStripe };
|
|
});
|
|
import Stripe from "stripe";
|
|
|
|
describe("BillingService", () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe("listPlans", () => {
|
|
it("lists plans in order and maps prices by lookup_key", async () => {
|
|
(Stripe.__mockClient).prices.list.mockResolvedValue({
|
|
data: [
|
|
{ lookup_key: "free", unit_amount: 0 },
|
|
{ lookup_key: "pro", unit_amount: 1000 },
|
|
{ lookup_key: "business", unit_amount: 2500 },
|
|
{ lookup_key: "enterprise", unit_amount: 9999 },
|
|
],
|
|
});
|
|
const svc = new BillingService();
|
|
const plans = await svc.listPlans();
|
|
expect(plans.map((p) => p.plan)).toEqual([
|
|
"free",
|
|
"pro",
|
|
"business",
|
|
"enterprise",
|
|
]);
|
|
expect(plans.find((p) => p.plan === "pro")?.price).toBe(1000);
|
|
expect(plans.find((p) => p.plan === "enterprise")?.price).toBe(9999);
|
|
});
|
|
});
|
|
|
|
describe("subscribePlan", () => {
|
|
it("throws when planKey missing", async () => {
|
|
const svc = new BillingService();
|
|
await expect(svc.subscribePlan("e@x.com", "org1", undefined)).rejects.toMatchObject({
|
|
message: "planKey is required",
|
|
status: 400,
|
|
});
|
|
});
|
|
|
|
it("throws when planKey invalid", async () => {
|
|
const svc = new BillingService();
|
|
await expect(svc.subscribePlan("e@x.com", "org1", "bogus"))
|
|
.rejects.toMatchObject({ message: "Invalid planKey", status: 400 });
|
|
});
|
|
|
|
it("throws when org not found", async () => {
|
|
Org.findById.mockResolvedValue(null);
|
|
const svc = new BillingService();
|
|
await expect(svc.subscribePlan("e@x.com", "org1", "pro"))
|
|
.rejects.toMatchObject({ message: "Organization not found", status: 404 });
|
|
});
|
|
|
|
it("creates customer and new subscription checkout when none exists", async () => {
|
|
const org = { _id: "org1", save: jest.fn(), billingCustomerId: null, subscriptionId: null };
|
|
Org.findById.mockResolvedValue(org);
|
|
(Stripe.__mockClient).customers.create.mockResolvedValue({ id: "cus_123" });
|
|
(Stripe.__mockClient).prices.list.mockResolvedValue({ data: [{ id: "price_123", lookup_key: "pro" }] });
|
|
(Stripe.__mockClient).checkout.sessions.create.mockResolvedValue({ url: "https://checkout" });
|
|
|
|
const svc = new BillingService();
|
|
const url = await svc.subscribePlan("e@x.com", "org1", "pro");
|
|
expect(org.billingCustomerId).toBe("cus_123");
|
|
expect(org.save).toHaveBeenCalled();
|
|
expect(Stripe.__mockClient.checkout.sessions.create).toHaveBeenCalled();
|
|
expect(url).toBe("https://checkout");
|
|
});
|
|
|
|
it("updates existing subscription via billing portal when subscriptionId exists", async () => {
|
|
const org = { _id: "org1", save: jest.fn(), billingCustomerId: "cus_123", subscriptionId: "sub_1" };
|
|
Org.findById.mockResolvedValue(org);
|
|
(Stripe.__mockClient).prices.list.mockResolvedValue({ data: [{ id: "price_123", lookup_key: "pro" }] });
|
|
(Stripe.__mockClient).billingPortal.sessions.create.mockResolvedValue({ url: "https://portal" });
|
|
|
|
const svc = new BillingService();
|
|
const url = await svc.subscribePlan("e@x.com", "org1", "pro");
|
|
expect(Stripe.__mockClient.billingPortal.sessions.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ customer: "cus_123" })
|
|
);
|
|
expect(url).toBe("https://portal");
|
|
});
|
|
|
|
it("throws when no price found for plan", async () => {
|
|
const org = { _id: "org1", save: jest.fn(), billingCustomerId: "cus_123", subscriptionId: null };
|
|
Org.findById.mockResolvedValue(org);
|
|
(Stripe.__mockClient).prices.list.mockResolvedValue({ data: [] });
|
|
const svc = new BillingService();
|
|
await expect(svc.subscribePlan("e@x.com", "org1", "pro"))
|
|
.rejects.toMatchObject({ message: "Price not found for the selected plan", status: 404 });
|
|
});
|
|
});
|
|
|
|
describe("cancelSubscription", () => {
|
|
it("throws when org not found", async () => {
|
|
Org.findById.mockResolvedValue(null);
|
|
const svc = new BillingService();
|
|
await expect(svc.cancelSubscription("org1"))
|
|
.rejects.toMatchObject({ message: "Organization not found", status: 404 });
|
|
});
|
|
|
|
it("throws when no active subscription found", async () => {
|
|
Org.findById.mockResolvedValue({ _id: "org1", subscriptionId: null });
|
|
const svc = new BillingService();
|
|
await expect(svc.cancelSubscription("org1"))
|
|
.rejects.toMatchObject({ message: "No active subscription found", status: 400 });
|
|
});
|
|
|
|
it("returns billing portal url on success", async () => {
|
|
Org.findById.mockResolvedValue({ _id: "org1", billingCustomerId: "cus_1", subscriptionId: "sub_1" });
|
|
(Stripe.__mockClient).billingPortal.sessions.create.mockResolvedValue({ url: "https://cancel" });
|
|
const svc = new BillingService();
|
|
const url = await svc.cancelSubscription("org1");
|
|
expect(url).toBe("https://cancel");
|
|
});
|
|
|
|
it("throws when billing portal session lacks url", async () => {
|
|
Org.findById.mockResolvedValue({ _id: "org1", billingCustomerId: "cus_1", subscriptionId: "sub_1" });
|
|
(Stripe.__mockClient).billingPortal.sessions.create.mockResolvedValue({ url: null });
|
|
const svc = new BillingService();
|
|
await expect(svc.cancelSubscription("org1"))
|
|
.rejects.toMatchObject({ message: "Failed to create billing portal session", status: 500 });
|
|
});
|
|
});
|
|
|
|
describe("confirmPlan", () => {
|
|
it("throws when org not found", async () => {
|
|
Org.findOne.mockResolvedValue(null);
|
|
const svc = new BillingService();
|
|
await expect(svc.confirmPlan("org1", "pro"))
|
|
.rejects.toMatchObject({ message: "Organization not found", status: 404 });
|
|
});
|
|
|
|
it("returns true only when expected is free if no subscriptionId", async () => {
|
|
Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: null });
|
|
const svc = new BillingService();
|
|
await expect(svc.confirmPlan("org1", "free")).resolves.toBe(true);
|
|
await expect(svc.confirmPlan("org1", "pro")).resolves.toBe(false);
|
|
});
|
|
|
|
it("returns true when active plan equals org.planKey", async () => {
|
|
Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: "sub1", planKey: "business" });
|
|
(Stripe.__mockClient).subscriptions.retrieve.mockResolvedValue({ items: { data: [{ price: { lookup_key: "business" } }] } });
|
|
const svc = new BillingService();
|
|
await expect(svc.confirmPlan("org1", "pro")).resolves.toBe(true);
|
|
});
|
|
|
|
it("returns true when active plan equals expectedPlanKey", async () => {
|
|
Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: "sub1", planKey: "pro" });
|
|
(Stripe.__mockClient).subscriptions.retrieve.mockResolvedValue({ items: { data: [{ price: { lookup_key: "enterprise" } }] } });
|
|
const svc = new BillingService();
|
|
await expect(svc.confirmPlan("org1", "enterprise")).resolves.toBe(true);
|
|
});
|
|
|
|
it("returns false when active plan differs from expected", async () => {
|
|
Org.findOne.mockResolvedValue({ _id: "org1", subscriptionId: "sub1", planKey: "pro" });
|
|
(Stripe.__mockClient).subscriptions.retrieve.mockResolvedValue({ items: { data: [{ price: { lookup_key: "business" } }] } });
|
|
const svc = new BillingService();
|
|
await expect(svc.confirmPlan("org1", "enterprise")).resolves.toBe(false);
|
|
});
|
|
});
|
|
});
|