Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 121 additions & 18 deletions __tests__/unit/tenant-billing.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock logger with all possible exports
vi.mock("@/lib/logger", () => ({
log: {
debug: vi.fn(),
Expand All @@ -27,29 +26,57 @@ vi.mock("@/lib/logger", () => ({
},
}));

vi.mock("@/lib/email", () => ({
getEmailService: vi.fn(),
}));

vi.mock("@/lib/config", () => ({
serverConfig: {
STRIPE_PRO_MONTHLY_PRICE_ID: "price_pro_monthly",
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: "price_enterprise_monthly",
},
}));

vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
getPlanById: (id: string) => actual.PLANS.find((p) => p.id === id),
};
});

import { handleWebhookEvent } from "@/services/tenant-billing.service";
import { idempotencyStore } from "@/lib/idempotency/mongo-idempotency-store";
import Tenant from "@/lib/database/models/tenant.model";
import { PLANS } from "@/lib/constants";
import { WebhookEvent } from "@/types/types";
import { getEmailService } from "@/lib/email";
import type { EmailService } from "@/types/types";

vi.mock("@/lib/idempotency/mongo-idempotency-store");
vi.mock("@/lib/database/models/tenant.model");

describe("handleWebhookEvent", () => {
let mockTenant: any;
let emailServiceMock: EmailService;

beforeEach(() => {
vi.clearAllMocks();

// Stub environment variables
vi.stubEnv("STRIPE_PRO_MONTHLY_PRICE_ID", "price_pro_monthly");
vi.stubEnv(
"STRIPE_ENTERPRISE_MONTHLY_PRICE_ID",
"price_enterprise_monthly",
);

// Create fresh mock tenant for each test
emailServiceMock = {
sendPaymentSuccess: vi.fn(),
sendPaymentFailed: vi.fn(),
sendTrialEnding: vi.fn(),
} as EmailService;

vi.mocked(getEmailService).mockReturnValue(emailServiceMock);

mockTenant = {
_id: "tenant1",
plan: "free",
Expand All @@ -62,6 +89,8 @@ describe("handleWebhookEvent", () => {
},
save: vi.fn().mockResolvedValue(undefined),
stripeCustomerId: null,
billingEmail: "test@example.com",
companyName: "Test Corp",
};

vi.mocked(idempotencyStore.isProcessed).mockResolvedValue(false);
Expand All @@ -83,7 +112,6 @@ describe("handleWebhookEvent", () => {

it("upgrades tenant to pro on checkout.session.completed", async () => {
await handleWebhookEvent(buildEvent());

expect(mockTenant.plan).toBe("pro");
expect(mockTenant.status).toBe("active");
expect(mockTenant.stripeCustomerId).toBe("cus_123");
Expand All @@ -97,13 +125,9 @@ describe("handleWebhookEvent", () => {
it("downgrades to free on subscription deleted", async () => {
const event = buildEvent({
type: "customer.subscription.deleted",
data: {
customer: "cus_123",
},
data: { customer: "cus_123" },
});

await handleWebhookEvent(event);

expect(mockTenant.plan).toBe("free");
expect(mockTenant.status).toBe("suspended");
expect(mockTenant.quotas.monthlyEvents).toBe(
Expand All @@ -113,26 +137,21 @@ describe("handleWebhookEvent", () => {

it("upgrades to enterprise when enterprise price matches", async () => {
vi.stubEnv("STRIPE_PRO_MONTHLY_PRICE_ID", "price_pro_monthly");

const event = buildEvent({
data: {
customer: "cus_123",
client_reference_id: "user_1",
metadata: { price_id: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID },
},
});

await handleWebhookEvent(event);

expect(mockTenant.plan).toBe("enterprise");
expect(mockTenant.status).toBe("active");
});

it("skips processing if idempotency key already exists", async () => {
vi.mocked(idempotencyStore.isProcessed).mockResolvedValue(true);

await handleWebhookEvent(buildEvent());

expect(Tenant.findOne).not.toHaveBeenCalled();
expect(idempotencyStore.markProcessed).not.toHaveBeenCalled();
});
Expand All @@ -146,23 +165,107 @@ describe("handleWebhookEvent", () => {
},
customerId: null,
});

await expect(handleWebhookEvent(event)).rejects.toThrow(
"Missing customer ID in checkout.session.completed",
);
});

it("does not update tenant when customer not found", async () => {
vi.mocked(Tenant.findOne).mockResolvedValue(null);

const event = buildEvent();
await expect(handleWebhookEvent(event)).rejects.toThrow(
"Tenant not found for checkout session",
);

// Tenant should not be modified since it wasn't found
expect(mockTenant.plan).toBe("free");
expect(mockTenant.save).not.toHaveBeenCalled();
expect(idempotencyStore.markProcessed).not.toHaveBeenCalled();
});

it("updates plan and quotas on subscription updated with new price", async () => {
const event = buildEvent({
type: "customer.subscription.updated",
data: {
id: "sub_1",
customer: "cus_123",
status: "active",
items: {
data: [
{
price: {
id: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID,
},
},
],
},
},
});
await handleWebhookEvent(event);
expect(mockTenant.plan).toBe("enterprise");
expect(mockTenant.quotas.monthlyEvents).toBe(
PLANS.find((p) => p.id === "enterprise")!.limits.monthlyEvents,
);
});

it("sets status to past_due on invoice.payment_failed", async () => {
const event = buildEvent({ type: "invoice.payment_failed" });
await handleWebhookEvent(event);
expect(mockTenant.status).toBe("past_due");
expect(emailServiceMock.sendPaymentFailed).toHaveBeenCalledWith(
mockTenant.billingEmail,
mockTenant.companyName,
);
});

it("sets status back to active on invoice.payment_succeeded", async () => {
mockTenant.status = "past_due";
const event = buildEvent({
type: "invoice.payment_succeeded",
data: { amount_paid: 4900 },
});
await handleWebhookEvent(event);
expect(mockTenant.status).toBe("active");
expect(emailServiceMock.sendPaymentSuccess).toHaveBeenCalledWith(
mockTenant.billingEmail,
mockTenant.companyName,
49,
);
});

it("sends trial ending email", async () => {
const event = buildEvent({
type: "customer.subscription.trial_will_end",
data: { trial_end: Math.floor(Date.now() / 1000) + 3 * 86400 },
});
await handleWebhookEvent(event);
expect(emailServiceMock.sendTrialEnding).toHaveBeenCalledWith(
mockTenant.billingEmail,
mockTenant.companyName,
expect.any(Date),
);
});

it("handles subscription updated with missing tenant", async () => {
vi.mocked(Tenant.findOne).mockResolvedValue(null);
const event = buildEvent({ type: "customer.subscription.updated" });
// Should not throw, just log and return
await expect(handleWebhookEvent(event)).resolves.not.toThrow();
expect(mockTenant.save).not.toHaveBeenCalled();
});

it("handles invoice payment failed with missing tenant", async () => {
vi.mocked(Tenant.findOne).mockResolvedValue(null);
const event = buildEvent({ type: "invoice.payment_failed" });
await handleWebhookEvent(event);
expect(mockTenant.save).not.toHaveBeenCalled();
});

it("handles subscription deleted with missing customer ID", async () => {
const event = buildEvent({
type: "customer.subscription.deleted",
customerId: undefined,
data: { customer: undefined },
});
await handleWebhookEvent(event);
expect(Tenant.findOne).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions lib/database/models/tenant.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface ITenant extends mongoose.Document {
status: "active" | "trialing" | "past_due" | "suspended";
billingEmail: string;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
trialEndsAt?: Date;
quotas: {
monthlyEvents: number;
Expand Down Expand Up @@ -40,6 +41,7 @@ const TenantSchema = new Schema<ITenant>(
},
billingEmail: { type: String, required: true },
stripeCustomerId: { type: String },
stripeSubscriptionId: { type: String },
trialEndsAt: { type: Date },
quotas: {
monthlyEvents: { type: Number, default: 100000 },
Expand Down
11 changes: 11 additions & 0 deletions lib/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { EmailService } from "@/types/types";
import { NoopEmailService } from "../noop-email-service";
// import { SmtpEmailService } from './smtp-email-service'; // to be built later

export function getEmailService(): EmailService {
// if (serverConfig.SENDING_EMAILS === "true") {
// return new SmtpEmailService();
// throw new Error("Real email service not implemented yet");
// }
return new NoopEmailService();
}
14 changes: 14 additions & 0 deletions lib/noop-email-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { EmailService } from "@/types/types";
import { log } from "@/lib/logger/index";

export class NoopEmailService implements EmailService {
async sendPaymentSuccess(email: string, tenantName: string, amount: number) {
log.info("[EMAIL NOOP] Payment success", { email, tenantName, amount });
}
async sendPaymentFailed(email: string, tenantName: string) {
log.info("[EMAIL NOOP] Payment failed", { email, tenantName });
}
async sendTrialEnding(email: string, tenantName: string, endDate: Date) {
log.info("[EMAIL NOOP] Trial ending soon", { email, tenantName, endDate });
}
}
Loading
Loading