diff --git a/__tests__/unit/tenant-billing.service.test.ts b/__tests__/unit/tenant-billing.service.test.ts index a6618d6..18a785a 100644 --- a/__tests__/unit/tenant-billing.service.test.ts +++ b/__tests__/unit/tenant-billing.service.test.ts @@ -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(), @@ -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(); + 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", @@ -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); @@ -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"); @@ -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( @@ -113,7 +137,6 @@ 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", @@ -121,18 +144,14 @@ describe("handleWebhookEvent", () => { 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(); }); @@ -146,7 +165,6 @@ describe("handleWebhookEvent", () => { }, customerId: null, }); - await expect(handleWebhookEvent(event)).rejects.toThrow( "Missing customer ID in checkout.session.completed", ); @@ -154,15 +172,100 @@ describe("handleWebhookEvent", () => { 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(); + }); }); diff --git a/lib/database/models/tenant.model.ts b/lib/database/models/tenant.model.ts index 1f05e72..803c30c 100644 --- a/lib/database/models/tenant.model.ts +++ b/lib/database/models/tenant.model.ts @@ -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; @@ -40,6 +41,7 @@ const TenantSchema = new Schema( }, billingEmail: { type: String, required: true }, stripeCustomerId: { type: String }, + stripeSubscriptionId: { type: String }, trialEndsAt: { type: Date }, quotas: { monthlyEvents: { type: Number, default: 100000 }, diff --git a/lib/email/index.ts b/lib/email/index.ts new file mode 100644 index 0000000..cf36935 --- /dev/null +++ b/lib/email/index.ts @@ -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(); +} diff --git a/lib/noop-email-service.ts b/lib/noop-email-service.ts new file mode 100644 index 0000000..02f2d60 --- /dev/null +++ b/lib/noop-email-service.ts @@ -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 }); + } +} diff --git a/services/tenant-billing.service.ts b/services/tenant-billing.service.ts index f9190db..9dbc842 100644 --- a/services/tenant-billing.service.ts +++ b/services/tenant-billing.service.ts @@ -1,9 +1,11 @@ import { WebhookEvent } from "@/types/types"; import { idempotencyStore } from "@/lib/idempotency/mongo-idempotency-store"; import Tenant from "@/lib/database/models/tenant.model"; -import { PLANS } from "@/lib/constants"; +import { getPlanById, PLANS } from "@/lib/constants"; import { log } from "@/lib/logger"; import { AppError } from "@/lib/errors"; +import { serverConfig } from "@/lib/config"; +import { getEmailService } from "@/lib/email"; export async function handleWebhookEvent(event: WebhookEvent) { if (await idempotencyStore.isProcessed(event.id)) { @@ -16,6 +18,18 @@ export async function handleWebhookEvent(event: WebhookEvent) { case "checkout.session.completed": await handleCheckoutCompleted(event); break; + case "customer.subscription.updated": + await handleSubscriptionUpdated(event); + break; + case "invoice.payment_succeeded": + await handleInvoicePaid(event); + break; + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event); + break; + case "customer.subscription.trial_will_end": + await handleTrialWillEnd(event); + break; case "customer.subscription.deleted": await handleSubscriptionDeleted(event); break; @@ -130,3 +144,171 @@ async function handleSubscriptionDeleted(event: WebhookEvent) { previousPlan: tenant.plan, }); } + +async function handleSubscriptionUpdated(event: WebhookEvent) { + const subscription = event.data; + const customerId = event.customerId || subscription.customer; + if (!customerId) { + log.warn("Missing customer ID in subscription updated event", { + eventId: event.id, + }); + return; + } + + const tenant = await Tenant.findOne({ stripeCustomerId: customerId }); + if (!tenant) { + log.warn("Missing Tenant in subscription updated event", { + customerId, + eventId: event.id, + }); + return; + } + + // Update Stripe subscription ID + tenant.stripeSubscriptionId = subscription.id; + + // Update plan if items changed (e.g, upgrade/downgrade) + const priceId = subscription.items?.data?.[0]?.price.id; + + if (priceId) { + const planId = + priceId === serverConfig.STRIPE_PRO_MONTHLY_PRICE_ID + ? "pro" + : priceId === serverConfig.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID + ? "enterprise" + : null; + if (planId && planId !== tenant.plan) { + const plan = getPlanById(planId); + if (plan) { + tenant.plan = planId; + tenant.quotas = { + monthlyEvents: plan.limits.monthlyEvents, + retentionDays: plan.limits.dataRetentionDays, + apiRateLimit: plan.limits.apiRateLimit, + seats: plan.limits.seats === -1 ? 999999 : plan.limits.seats, + }; + } + } + } + + // Map Stripe subscription status to our status + switch (subscription.status) { + case "active": + tenant.status = "active"; + break; + case "past_due": + tenant.status = "past_due"; + break; + case "suspended": + tenant.status = "suspended"; + break; + case "trialing": + tenant.status = "trialing"; + break; + } + // Update trial end + if (subscription.trial_end) { + tenant.trialEndsAt = new Date(subscription.trial_end * 1000); + } + + await tenant.save(); + log.info("Subscription updated", { + tenantId: tenant._id, + status: tenant.status, + }); +} + +async function handleInvoicePaid(event: WebhookEvent) { + const invoice = event.data; + const customerId = event.customerId || invoice.customer; + if (!customerId) { + log.warn("Missing customer ID in Invoice Paid event", { + eventId: event.id, + }); + return; + } + + const tenant = await Tenant.findOne({ stripeCustomerId: customerId }); + if (!tenant) { + log.warn("Missing Tenant in Invoice Paid event", { + customerId, + eventId: event.id, + }); + return; + } + + // Ensure status is active (might have been past_due) + if (tenant.status !== "active") { + tenant.status = "active"; + await tenant.save(); + } + + // Send receipt + const emailService = getEmailService(); + await emailService.sendPaymentSuccess( + tenant.billingEmail, + tenant.companyName, + invoice.amount_paid / 100, // cents to dollars + ); + + log.info("Invoice paid", { + tenantId: tenant._id, + amount: invoice.amount_paid, + }); +} +async function handleInvoicePaymentFailed(event: WebhookEvent) { + const invoice = event.data; + const customerId = event.customerId || invoice.customer; + if (!customerId) { + log.warn("Missing customer ID in Invoice Paid event", { + eventId: event.id, + }); + return; + } + + const tenant = await Tenant.findOne({ stripeCustomerId: customerId }); + if (!tenant) { + log.warn("Missing Tenant in Invoice Paid event", { + customerId, + eventId: event.id, + }); + return; + } + + tenant.status = "past_due"; + await tenant.save(); + + // Notify + const emailService = getEmailService(); + await emailService.sendPaymentFailed(tenant.billingEmail, tenant.companyName); + + log.warn("Invoice payment failed", { tenantId: tenant._id }); +} +async function handleTrialWillEnd(event: WebhookEvent) { + const subscription = event.data; + const customerId = event.customerId || subscription.customer; + if (!customerId) { + log.warn("Missing customer ID in subscription updated event", { + eventId: event.id, + }); + return; + } + + const tenant = await Tenant.findOne({ stripeCustomerId: customerId }); + if (!tenant) { + log.warn("Missing Tenant in subscription updated event", { + customerId, + eventId: event.id, + }); + return; + } + if (subscription.trial_end) { + const trialEndDate = new Date(subscription.trial_end * 1000); + const emailService = getEmailService(); + await emailService.sendTrialEnding( + tenant.billingEmail, + tenant.companyName, + trialEndDate, + ); + } +} diff --git a/types/types.ts b/types/types.ts index e1be4c0..4646121 100644 --- a/types/types.ts +++ b/types/types.ts @@ -24,3 +24,17 @@ export interface QueueJob { export interface QueueAdapter { enqueue(job: QueueJob, handler: (job: QueueJob) => Promise): void; } + +export interface EmailService { + sendPaymentSuccess( + email: string, + tenantName: string, + amount: number, + ): Promise; + sendPaymentFailed(email: string, tenantName: string): Promise; + sendTrialEnding( + email: string, + tenantName: string, + endDate: Date, + ): Promise; +}