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
16 changes: 16 additions & 0 deletions app/api/webhooks/stripe/__tests__/routeTestMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,19 @@ vi.mock("@/lib/stripe/processCreditsTopupSession", () => ({
vi.mock("@/lib/stripe/processCreditsTopupPaymentIntent", () => ({
processCreditsTopupPaymentIntent: vi.fn(),
}));

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

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

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

vi.mock("@/lib/stripe/processSubscriptionDeleted", () => ({
processSubscriptionDeleted: vi.fn(),
}));
62 changes: 62 additions & 0 deletions lib/stripe/__tests__/buildSubscriptionSalesContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { getCustomerEmailMock, getLtvMock } = vi.hoisted(() => ({
getCustomerEmailMock: vi.fn(),
getLtvMock: vi.fn(),
}));

vi.mock("@/lib/stripe/getCustomerEmail", () => ({ getCustomerEmail: getCustomerEmailMock }));
vi.mock("@/lib/stripe/getCustomerLifetimeValue", () => ({
getCustomerLifetimeValue: getLtvMock,
}));

const { buildSubscriptionSalesContext } = await import(
"@/lib/stripe/buildSubscriptionSalesContext"
);

const sub = (overrides: object = {}): Stripe.Subscription =>
({
id: "sub_1",
customer: "cus_9",
items: {
data: [{ price: { unit_amount: 9900, recurring: { interval: "month" } } }],
},
...overrides,
}) as unknown as Stripe.Subscription;

describe("buildSubscriptionSalesContext", () => {
beforeEach(() => {
vi.clearAllMocks();
getCustomerEmailMock.mockResolvedValue("fan@example.com");
getLtvMock.mockResolvedValue(12345);
});

it("builds customer, plan, and lifetime lines from the subscription", async () => {
await expect(buildSubscriptionSalesContext(sub())).resolves.toEqual({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
planLine: "Plan: $99.00/month",
lifetimeLine: "Lifetime value: $123.45",
});
expect(getCustomerEmailMock).toHaveBeenCalledWith("cus_9");
expect(getLtvMock).toHaveBeenCalledWith("cus_9");
});

it("uses the id when the customer field is an expanded object", async () => {
const ctx = await buildSubscriptionSalesContext(sub({ customer: { id: "cus_9" } }));
expect(ctx.customerLine).toBe("Customer: fan@example.com (cus_9)");
});

it("falls back to the customer id when no email is found", async () => {
getCustomerEmailMock.mockResolvedValue(null);
const ctx = await buildSubscriptionSalesContext(sub());
expect(ctx.email).toBeNull();
expect(ctx.customerLine).toBe("Customer: cus_9");
});

it("tolerates a subscription without price data", async () => {
const ctx = await buildSubscriptionSalesContext(sub({ items: { data: [] } }));
expect(ctx.planLine).toBe("Plan: unknown");
});
});
8 changes: 8 additions & 0 deletions lib/stripe/__tests__/formatStripeTimestamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, it, expect } from "vitest";
import { formatStripeTimestamp } from "@/lib/stripe/formatStripeTimestamp";

describe("formatStripeTimestamp", () => {
it("formats a unix-seconds timestamp as an ISO date", () => {
expect(formatStripeTimestamp(1783415103)).toBe("2026-07-07");
});
});
18 changes: 18 additions & 0 deletions lib/stripe/__tests__/formatUsd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { formatUsd } from "@/lib/stripe/formatUsd";

describe("formatUsd", () => {
it("formats cents as dollars", () => {
expect(formatUsd(9900)).toBe("$99.00");
expect(formatUsd(546)).toBe("$5.46");
expect(formatUsd(500000)).toBe("$5000.00");
});

it("formats zero", () => {
expect(formatUsd(0)).toBe("$0.00");
});

it("formats negative amounts (net refunds)", () => {
expect(formatUsd(-150)).toBe("-$1.50");
});
});
39 changes: 39 additions & 0 deletions lib/stripe/__tests__/getCustomerEmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const { customersRetrieveMock } = vi.hoisted(() => ({ customersRetrieveMock: vi.fn() }));

vi.mock("@/lib/stripe/client", () => ({
default: { customers: { retrieve: customersRetrieveMock } },
}));

const { getCustomerEmail } = await import("@/lib/stripe/getCustomerEmail");

describe("getCustomerEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => vi.mocked(console.error).mockRestore());

it("returns the email of a live customer", async () => {
customersRetrieveMock.mockResolvedValue({ id: "cus_1", email: "fan@example.com" });
await expect(getCustomerEmail("cus_1")).resolves.toBe("fan@example.com");
expect(customersRetrieveMock).toHaveBeenCalledWith("cus_1");
});

it("returns null for a deleted customer", async () => {
customersRetrieveMock.mockResolvedValue({ id: "cus_1", deleted: true });
await expect(getCustomerEmail("cus_1")).resolves.toBeNull();
});

it("returns null when the customer has no email", async () => {
customersRetrieveMock.mockResolvedValue({ id: "cus_1", email: null });
await expect(getCustomerEmail("cus_1")).resolves.toBeNull();
});

it("returns null and logs when the lookup fails", async () => {
customersRetrieveMock.mockRejectedValue(new Error("stripe down"));
await expect(getCustomerEmail("cus_1")).resolves.toBeNull();
expect(console.error).toHaveBeenCalled();
});
});
59 changes: 59 additions & 0 deletions lib/stripe/__tests__/getCustomerLifetimeValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const { chargesListMock } = vi.hoisted(() => ({ chargesListMock: vi.fn() }));

vi.mock("@/lib/stripe/client", () => ({
default: { charges: { list: chargesListMock } },
}));

const { getCustomerLifetimeValue } = await import("@/lib/stripe/getCustomerLifetimeValue");

const charge = (amount: number, amountRefunded = 0, status = "succeeded") => ({
amount,
amount_refunded: amountRefunded,
status,
});

describe("getCustomerLifetimeValue", () => {
beforeEach(() => vi.clearAllMocks());

it("sums succeeded charges in cents, net of refunds", async () => {
chargesListMock.mockResolvedValue({
data: [charge(9900), charge(546, 546), charge(2000, 500)],
has_more: false,
});

await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(9900 + 0 + 1500);
expect(chargesListMock).toHaveBeenCalledWith({ customer: "cus_1", limit: 100 });
});

it("ignores charges that did not succeed", async () => {
chargesListMock.mockResolvedValue({
data: [charge(9900, 0, "failed"), charge(546)],
has_more: false,
});

await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(546);
});

it("paginates until has_more is false", async () => {
chargesListMock
.mockResolvedValueOnce({
data: [charge(100), { ...charge(200), id: "ch_last" }],
has_more: true,
})
.mockResolvedValueOnce({ data: [charge(300)], has_more: false });

await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(600);
expect(chargesListMock).toHaveBeenNthCalledWith(2, {
customer: "cus_1",
limit: 100,
starting_after: "ch_last",
});
});

it("returns 0 for a customer with no charges", async () => {
chargesListMock.mockResolvedValue({ data: [], has_more: false });
await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(0);
});
});
58 changes: 58 additions & 0 deletions lib/stripe/__tests__/processSubscriptionCreated.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { buildContextMock, sendMock } = vi.hoisted(() => ({
buildContextMock: vi.fn(),
sendMock: vi.fn(),
}));

vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({
buildSubscriptionSalesContext: buildContextMock,
}));
vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock }));

const { processSubscriptionCreated } = await import("@/lib/stripe/processSubscriptionCreated");

const CTX = {
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
planLine: "Plan: $99.00/month",
lifetimeLine: "Lifetime value: $0.00",
};

const sub = (overrides: object = {}): Stripe.Subscription =>
({
id: "sub_1",
customer: "cus_9",
status: "active",
...overrides,
}) as unknown as Stripe.Subscription;

describe("processSubscriptionCreated", () => {
beforeEach(() => {
vi.clearAllMocks();
buildContextMock.mockResolvedValue(CTX);
});

it("notifies a trial signup with the trial end date (new card on file)", async () => {
await processSubscriptionCreated(sub({ status: "trialing", trial_end: 1783415103 }));

expect(sendMock).toHaveBeenCalledTimes(1);
const { email, text } = sendMock.mock.calls[0][0];
expect(email).toBe("fan@example.com");
expect(text).toContain("New subscription (trial)");
expect(text).toContain("Customer: fan@example.com (cus_9)");
expect(text).toContain("Plan: $99.00/month");
expect(text).toContain("Trial ends: 2026-07-07");
expect(text).toContain("Lifetime value: $0.00");
});

it("notifies a direct (non-trial) subscription without a trial line", async () => {
await processSubscriptionCreated(sub());

const { text } = sendMock.mock.calls[0][0];
expect(text).toContain("New subscription");
expect(text).not.toContain("(trial)");
expect(text).not.toContain("Trial ends");
});
});
51 changes: 51 additions & 0 deletions lib/stripe/__tests__/processSubscriptionDeleted.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { buildContextMock, sendMock } = vi.hoisted(() => ({
buildContextMock: vi.fn(),
sendMock: vi.fn(),
}));

vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({
buildSubscriptionSalesContext: buildContextMock,
}));
vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock }));

const { processSubscriptionDeleted } = await import("@/lib/stripe/processSubscriptionDeleted");

describe("processSubscriptionDeleted", () => {
beforeEach(() => {
vi.clearAllMocks();
buildContextMock.mockResolvedValue({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
planLine: "Plan: $99.00/month",
lifetimeLine: "Lifetime value: $0.00",
});
});

it("notifies final churn with cancellation feedback", async () => {
await processSubscriptionDeleted({
id: "sub_1",
customer: "cus_9",
cancellation_details: { feedback: "too_expensive", reason: "cancellation_requested" },
} as unknown as Stripe.Subscription);

expect(sendMock).toHaveBeenCalledTimes(1);
const { email, text } = sendMock.mock.calls[0][0];
expect(email).toBe("fan@example.com");
expect(text).toContain("Subscription canceled");
expect(text).toContain("Feedback: too_expensive");
expect(text).toContain("Lifetime value: $0.00");
});

it("omits the feedback line when Stripe has none", async () => {
await processSubscriptionDeleted({
id: "sub_1",
customer: "cus_9",
cancellation_details: { feedback: null, reason: null },
} as unknown as Stripe.Subscription);

expect(sendMock.mock.calls[0][0].text).not.toContain("Feedback:");
});
});
43 changes: 43 additions & 0 deletions lib/stripe/__tests__/processSubscriptionTrialWillEnd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { buildContextMock, sendMock } = vi.hoisted(() => ({
buildContextMock: vi.fn(),
sendMock: vi.fn(),
}));

vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({
buildSubscriptionSalesContext: buildContextMock,
}));
vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock }));

const { processSubscriptionTrialWillEnd } = await import(
"@/lib/stripe/processSubscriptionTrialWillEnd"
);

describe("processSubscriptionTrialWillEnd", () => {
beforeEach(() => {
vi.clearAllMocks();
buildContextMock.mockResolvedValue({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
planLine: "Plan: $99.00/month",
lifetimeLine: "Lifetime value: $0.00",
});
});

it("notifies that the trial is ending with the conversion date", async () => {
await processSubscriptionTrialWillEnd({
id: "sub_1",
trial_end: 1783415103,
} as unknown as Stripe.Subscription);

expect(sendMock).toHaveBeenCalledTimes(1);
const { email, text } = sendMock.mock.calls[0][0];
expect(email).toBe("fan@example.com");
expect(text).toContain("Trial ending soon");
expect(text).toContain("reach out now");
expect(text).toContain("Trial ends: 2026-07-07");
expect(text).toContain("Lifetime value: $0.00");
});
});
Loading
Loading