660 lines
27 KiB
TypeScript
660 lines
27 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
|
|
|
|
const stripeMocks = vi.hoisted(() => {
|
|
const client = {
|
|
billing: {
|
|
meterEvents: { create: vi.fn() },
|
|
meters: { listEventSummaries: vi.fn() },
|
|
},
|
|
billingPortal: {
|
|
sessions: { create: vi.fn() },
|
|
},
|
|
checkout: {
|
|
sessions: {
|
|
create: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
},
|
|
},
|
|
coupons: { retrieve: vi.fn() },
|
|
customers: {
|
|
create: vi.fn(),
|
|
del: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
invoices: {
|
|
createPreview: vi.fn(),
|
|
list: vi.fn(),
|
|
},
|
|
paymentIntents: {
|
|
list: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
},
|
|
paymentMethods: {
|
|
detach: vi.fn(),
|
|
list: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
},
|
|
prices: {
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
products: {
|
|
create: vi.fn(),
|
|
del: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
promotionCodes: { retrieve: vi.fn() },
|
|
subscriptions: {
|
|
cancel: vi.fn(),
|
|
create: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
|
|
return {
|
|
client,
|
|
config: {
|
|
secretKey: "sk_test_stripe_unit_default",
|
|
webhookSigningSecret: "whsec_unit",
|
|
},
|
|
logger: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
stripeConstructor: vi.fn(() => client),
|
|
};
|
|
});
|
|
|
|
vi.mock("stripe", () => ({
|
|
default: stripeMocks.stripeConstructor,
|
|
}));
|
|
|
|
vi.mock("@toolctrl/config", () => ({
|
|
loadWebConfig: vi.fn(() => ({
|
|
ok: true,
|
|
diagnostics: [],
|
|
config: {
|
|
stripe: {
|
|
secretKey: stripeMocks.config.secretKey,
|
|
webhookSigningSecret: stripeMocks.config.webhookSigningSecret,
|
|
},
|
|
},
|
|
})),
|
|
}));
|
|
|
|
vi.mock("./cache.server", () => ({
|
|
cachified: vi.fn((options: { getFreshValue: () => unknown }) => options.getFreshValue()),
|
|
}));
|
|
|
|
vi.mock("./db/subscriptionProducts.db.server", () => ({
|
|
getAllSubscriptionProducts: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./logger.server", () => ({
|
|
createScopedLogger: vi.fn(() => stripeMocks.logger),
|
|
}));
|
|
|
|
vi.mock("./services/.server/subscriptionService", () => ({
|
|
convertToCurrency: vi.fn(({ price }: { price: number }) => price * 2),
|
|
}));
|
|
|
|
vi.mock("./url.server", () => ({
|
|
getAbsoluteCurrentUrl: vi.fn(() => "https://app.example.test/settings/subscription"),
|
|
}));
|
|
|
|
async function importStripeServer(secretKey = "sk_test_stripe_unit_default") {
|
|
stripeMocks.config.secretKey = secretKey;
|
|
vi.resetModules();
|
|
return await import("./stripe.server");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
stripeMocks.config.secretKey = "sk_test_stripe_unit_default";
|
|
stripeMocks.config.webhookSigningSecret = "whsec_unit";
|
|
stripeMocks.client.checkout.sessions.create.mockResolvedValue({ id: "cs_1" });
|
|
stripeMocks.client.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_1", line_items: [] });
|
|
stripeMocks.client.invoices.createPreview.mockResolvedValue({ id: "in_preview" });
|
|
stripeMocks.client.invoices.list.mockResolvedValue({ data: [], has_more: false });
|
|
stripeMocks.client.paymentIntents.list.mockResolvedValue({ data: [], has_more: false });
|
|
stripeMocks.client.paymentIntents.retrieve.mockResolvedValue({ id: "pi_1" });
|
|
stripeMocks.client.paymentMethods.detach.mockResolvedValue({ id: "pm_1" });
|
|
stripeMocks.client.paymentMethods.list.mockResolvedValue({ data: [] });
|
|
stripeMocks.client.paymentMethods.retrieve.mockResolvedValue({ id: "pm_1", customer: "cus_1" });
|
|
stripeMocks.client.prices.create.mockResolvedValue({ id: "price_1" });
|
|
stripeMocks.client.prices.update.mockResolvedValue({ id: "price_1", active: false });
|
|
stripeMocks.client.products.create.mockResolvedValue({ id: "prod_1" });
|
|
stripeMocks.client.products.del.mockResolvedValue({ id: "prod_1", deleted: true });
|
|
stripeMocks.client.products.update.mockResolvedValue({ id: "prod_1" });
|
|
stripeMocks.client.subscriptions.cancel.mockResolvedValue({ id: "sub_1", status: "canceled" });
|
|
stripeMocks.client.subscriptions.create.mockResolvedValue({ id: "sub_1" });
|
|
stripeMocks.client.subscriptions.retrieve.mockResolvedValue({ id: "sub_1" });
|
|
stripeMocks.client.subscriptions.update.mockResolvedValue({ id: "sub_1", cancel_at_period_end: false });
|
|
});
|
|
|
|
describe("stripe server config and client", () => {
|
|
it("reads typed Stripe config and fails fast when no secret key is configured", async () => {
|
|
const stripeServer = await importStripeServer("");
|
|
|
|
expect(stripeServer.getStripeSecretKey()).toBe("");
|
|
expect(stripeServer.getStripeWebhookSigningSecret()).toBe("whsec_unit");
|
|
expect(stripeServer.isStripeConfigured()).toBe(false);
|
|
expect(stripeServer.isStripeTestMode()).toBe(false);
|
|
expect(() => stripeServer.getStripeClient()).toThrow("STRIPE_SK is required before using Stripe services.");
|
|
});
|
|
|
|
it("constructs the Stripe client with the pinned API version", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_client");
|
|
|
|
expect(stripeServer.isStripeConfigured()).toBe(true);
|
|
expect(stripeServer.isStripeTestMode()).toBe(true);
|
|
expect(stripeServer.getStripeClient()).toBe(stripeMocks.client);
|
|
expect(stripeMocks.stripeConstructor).toHaveBeenCalledWith("sk_test_stripe_unit_client", {
|
|
apiVersion: "2025-10-29.clover",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("stripe checkout and payment methods", () => {
|
|
it("maps checkout session options for coupons, referrals, and subscription billing settings", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_checkout");
|
|
|
|
await expect(
|
|
stripeServer.createStripeCheckoutSession({
|
|
subscriptionProduct: { billingAddressCollection: "required" },
|
|
customer: "cus_1",
|
|
line_items: [{ price: "price_monthly", quantity: 2 }],
|
|
mode: "subscription",
|
|
success_url: "https://app.example.test/success",
|
|
cancel_url: "https://app.example.test/cancel",
|
|
freeTrialDays: 14,
|
|
coupon: "coupon_1",
|
|
referral: "ref_1",
|
|
})
|
|
).resolves.toEqual({ id: "cs_1" });
|
|
|
|
expect(stripeMocks.client.checkout.sessions.create).toHaveBeenCalledWith({
|
|
discounts: [{ coupon: "coupon_1" }],
|
|
customer: "cus_1",
|
|
line_items: [{ price: "price_monthly", quantity: 2 }],
|
|
mode: "subscription",
|
|
success_url: "https://app.example.test/success",
|
|
cancel_url: "https://app.example.test/cancel",
|
|
customer_creation: undefined,
|
|
payment_method_collection: "if_required",
|
|
subscription_data: { trial_period_days: 14 },
|
|
billing_address_collection: "required",
|
|
client_reference_id: "ref_1",
|
|
allow_promotion_codes: undefined,
|
|
});
|
|
});
|
|
|
|
it("enables promotion codes and customer creation for one-time payment sessions without an existing customer", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_payment");
|
|
|
|
await stripeServer.createStripeCheckoutSession({
|
|
subscriptionProduct: { billingAddressCollection: "auto" },
|
|
line_items: [{ price: "price_once" }],
|
|
mode: "payment",
|
|
success_url: "https://app.example.test/success",
|
|
cancel_url: "https://app.example.test/cancel",
|
|
});
|
|
|
|
expect(stripeMocks.client.checkout.sessions.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
allow_promotion_codes: true,
|
|
billing_address_collection: "auto",
|
|
customer_creation: "always",
|
|
discounts: undefined,
|
|
payment_method_collection: undefined,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("maps explicit promotion-code discounts without enabling open promotion codes", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_promo");
|
|
|
|
await stripeServer.createStripeCheckoutSession({
|
|
subscriptionProduct: { billingAddressCollection: null },
|
|
line_items: [{ price: "price_once" }],
|
|
mode: "payment",
|
|
success_url: "https://app.example.test/success",
|
|
cancel_url: "https://app.example.test/cancel",
|
|
promo_code: "promo_1",
|
|
});
|
|
|
|
expect(stripeMocks.client.checkout.sessions.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
discounts: [{ promotion_code: "promo_1" }],
|
|
allow_promotion_codes: undefined,
|
|
billing_address_collection: undefined,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("logs and rethrows checkout session creation failures", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_checkout_error");
|
|
const error = new Error("stripe checkout down");
|
|
stripeMocks.client.checkout.sessions.create.mockRejectedValueOnce(error);
|
|
|
|
await expect(
|
|
stripeServer.createStripeCheckoutSession({
|
|
subscriptionProduct: { billingAddressCollection: null },
|
|
line_items: [{ price: "price_once" }],
|
|
mode: "payment",
|
|
success_url: "https://app.example.test/success",
|
|
cancel_url: "https://app.example.test/cancel",
|
|
})
|
|
).rejects.toBe(error);
|
|
expect(stripeMocks.logger.error).toHaveBeenCalledWith({ err: error }, "Stripe checkout session creation failed");
|
|
});
|
|
|
|
it("detaches a payment method without ownership lookup when no customer id is provided", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_pm_no_customer");
|
|
|
|
await expect(stripeServer.deleteStripePaymentMethod("pm_1")).resolves.toEqual({ id: "pm_1" });
|
|
|
|
expect(stripeMocks.client.paymentMethods.retrieve).not.toHaveBeenCalled();
|
|
expect(stripeMocks.client.paymentMethods.detach).toHaveBeenCalledWith("pm_1");
|
|
});
|
|
|
|
it("prevents detaching a payment method that belongs to a different customer", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_pm");
|
|
stripeMocks.client.paymentMethods.retrieve.mockResolvedValue({ id: "pm_1", customer: { id: "cus_other" } });
|
|
|
|
await expect(stripeServer.deleteStripePaymentMethod("pm_1", "cus_1")).rejects.toThrow("Payment method not found");
|
|
|
|
expect(stripeMocks.client.paymentMethods.detach).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("detaches a payment method when ownership matches", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_pm_ok");
|
|
stripeMocks.client.paymentMethods.retrieve.mockResolvedValue({ id: "pm_1", customer: "cus_1" });
|
|
|
|
await expect(stripeServer.deleteStripePaymentMethod("pm_1", "cus_1")).resolves.toEqual({ id: "pm_1" });
|
|
|
|
expect(stripeMocks.client.paymentMethods.detach).toHaveBeenCalledWith("pm_1");
|
|
});
|
|
});
|
|
|
|
describe("stripe price creation", () => {
|
|
it("returns undefined instead of creating a price when the product id is missing", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_price_empty");
|
|
|
|
await expect(
|
|
stripeServer.createStripePrice("", {
|
|
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
|
|
currency: "eur",
|
|
price: 12,
|
|
})
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(stripeMocks.client.prices.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps recurring price billing periods and cents", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_price");
|
|
|
|
await stripeServer.createStripePrice("prod_1", {
|
|
billingPeriod: SubscriptionBillingPeriod.QUARTERLY,
|
|
currency: "eur",
|
|
price: 12.34,
|
|
trialDays: 7,
|
|
});
|
|
|
|
expect(stripeMocks.client.prices.create).toHaveBeenCalledWith({
|
|
unit_amount: 1234,
|
|
currency: "eur",
|
|
recurring: { interval: "month", interval_count: 3, trial_period_days: 7 },
|
|
product: "prod_1",
|
|
active: true,
|
|
});
|
|
});
|
|
|
|
it("maps all remaining recurring price billing periods", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_price_periods");
|
|
const cases = [
|
|
[SubscriptionBillingPeriod.MONTHLY, { interval: "month", trial_period_days: undefined }],
|
|
[SubscriptionBillingPeriod.SEMI_ANNUAL, { interval: "month", interval_count: 6, trial_period_days: undefined }],
|
|
[SubscriptionBillingPeriod.WEEKLY, { interval: "week", trial_period_days: undefined }],
|
|
[SubscriptionBillingPeriod.YEARLY, { interval: "year", trial_period_days: undefined }],
|
|
[SubscriptionBillingPeriod.DAILY, { interval: "day", trial_period_days: undefined }],
|
|
] as const;
|
|
|
|
for (const [billingPeriod, recurring] of cases) {
|
|
await stripeServer.createStripePrice("prod_1", {
|
|
billingPeriod,
|
|
currency: "eur",
|
|
price: 5,
|
|
});
|
|
}
|
|
|
|
expect(stripeMocks.client.prices.create).toHaveBeenCalledTimes(cases.length);
|
|
cases.forEach(([, recurring], index) => {
|
|
expect(stripeMocks.client.prices.create).toHaveBeenNthCalledWith(
|
|
index + 1,
|
|
expect.objectContaining({ recurring })
|
|
);
|
|
});
|
|
});
|
|
|
|
it("returns undefined instead of creating a usage-based price when the product id is missing", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_usage_price_empty");
|
|
|
|
await expect(
|
|
stripeServer.createStripeUsageBasedPrice("", {
|
|
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
|
|
billingScheme: "tiered",
|
|
currency: "eur",
|
|
tiersMode: "graduated",
|
|
unitTitle: "Seats",
|
|
usageType: "licensed",
|
|
tiers: [{ from: 1, to: null }],
|
|
})
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(stripeMocks.client.prices.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps usage-based tiers, metering, and open-ended tier bounds", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_usage_price");
|
|
|
|
await stripeServer.createStripeUsageBasedPrice("prod_usage", {
|
|
billingPeriod: SubscriptionBillingPeriod.SEMI_ANNUAL,
|
|
billingScheme: "tiered",
|
|
currency: "usd",
|
|
tiersMode: "graduated",
|
|
unitTitle: "Seats",
|
|
usageType: "licensed",
|
|
tiers: [
|
|
{ from: 1, to: 10, perUnitPrice: 10 },
|
|
{ from: 11, to: null, perUnitPrice: 8, flatFeePrice: 20 },
|
|
],
|
|
});
|
|
|
|
expect(stripeMocks.client.prices.create).toHaveBeenCalledWith({
|
|
currency: "usd",
|
|
tiers_mode: "graduated",
|
|
billing_scheme: "tiered",
|
|
nickname: "Seats",
|
|
recurring: { interval: "month", interval_count: 6, usage_type: "licensed" },
|
|
product: "prod_usage",
|
|
tiers: [
|
|
{ up_to: 10, unit_amount_decimal: "1000", flat_amount_decimal: undefined },
|
|
{ up_to: "inf", unit_amount_decimal: "800", flat_amount_decimal: "2000" },
|
|
],
|
|
active: true,
|
|
expand: ["tiers"],
|
|
});
|
|
});
|
|
|
|
it("maps usage-based billing periods and default tier settings", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_usage_price_defaults");
|
|
const cases = [
|
|
[SubscriptionBillingPeriod.MONTHLY, { interval: "month", interval_count: undefined, usage_type: "metered" }],
|
|
[SubscriptionBillingPeriod.QUARTERLY, { interval: "month", interval_count: 3, usage_type: "metered" }],
|
|
[SubscriptionBillingPeriod.WEEKLY, { interval: "week", interval_count: undefined, usage_type: "metered" }],
|
|
[SubscriptionBillingPeriod.YEARLY, { interval: "year", interval_count: undefined, usage_type: "metered" }],
|
|
[SubscriptionBillingPeriod.DAILY, { interval: "day", interval_count: undefined, usage_type: "metered" }],
|
|
] as const;
|
|
|
|
for (const [billingPeriod] of cases) {
|
|
await stripeServer.createStripeUsageBasedPrice("prod_usage", {
|
|
billingPeriod,
|
|
billingScheme: "per_unit",
|
|
currency: "eur",
|
|
tiersMode: "volume",
|
|
unitTitle: "API calls",
|
|
usageType: "metered",
|
|
tiers: [
|
|
{ from: 11, to: null },
|
|
{ from: 1, to: 10, perUnitPrice: 2 },
|
|
],
|
|
});
|
|
}
|
|
|
|
expect(stripeMocks.client.prices.create).toHaveBeenCalledTimes(cases.length);
|
|
cases.forEach(([, recurring], index) => {
|
|
expect(stripeMocks.client.prices.create).toHaveBeenNthCalledWith(
|
|
index + 1,
|
|
expect.objectContaining({
|
|
billing_scheme: "per_unit",
|
|
tiers_mode: "volume",
|
|
recurring,
|
|
tiers: [
|
|
{ up_to: "inf", unit_amount_decimal: undefined, flat_amount_decimal: undefined },
|
|
{ up_to: 10, unit_amount_decimal: "200", flat_amount_decimal: undefined },
|
|
],
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("stripe read fallback helpers", () => {
|
|
it("returns null or empty arrays when optional Stripe lookups fail", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_fallbacks");
|
|
stripeMocks.client.checkout.sessions.retrieve.mockRejectedValue(new Error("stripe down"));
|
|
stripeMocks.client.invoices.list.mockRejectedValue(new Error("stripe down"));
|
|
stripeMocks.client.paymentIntents.list.mockRejectedValue(new Error("stripe down"));
|
|
|
|
await expect(stripeServer.getStripeSession("cs_missing")).resolves.toBeNull();
|
|
await expect(stripeServer.getStripeInvoices("cus_1")).resolves.toEqual([]);
|
|
await expect(stripeServer.getStripeInvoices(null)).resolves.toEqual([]);
|
|
await expect(stripeServer.getStripePaymentIntents("cus_1")).resolves.toEqual([]);
|
|
await expect(stripeServer.getStripePaymentIntents(null)).resolves.toEqual([]);
|
|
});
|
|
|
|
it("caps invoice list requests with an explicit Stripe limit", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_invoice_limit");
|
|
stripeMocks.client.invoices.list.mockResolvedValueOnce({ data: [{ id: "in_1" }], has_more: false });
|
|
|
|
await expect(stripeServer.getStripeInvoices("cus_1", { limit: 250 })).resolves.toEqual([{ id: "in_1" }]);
|
|
|
|
expect(stripeMocks.client.invoices.list).toHaveBeenCalledWith({ customer: "cus_1", limit: 100 });
|
|
});
|
|
|
|
it("uses the conservative invoice list limit by default", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_invoice_default_limit");
|
|
|
|
await expect(stripeServer.getStripeInvoices("cus_1")).resolves.toEqual([]);
|
|
|
|
expect(stripeMocks.client.invoices.list).toHaveBeenCalledWith({
|
|
customer: "cus_1",
|
|
limit: stripeServer.DEFAULT_STRIPE_INVOICE_LIST_LIMIT,
|
|
});
|
|
});
|
|
|
|
it("returns null or empty arrays when read wrapper helpers reject", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_read_errors");
|
|
stripeMocks.client.subscriptions.retrieve.mockRejectedValueOnce(new Error("stripe down"));
|
|
stripeMocks.client.paymentIntents.retrieve.mockRejectedValueOnce(new Error("stripe down"));
|
|
stripeMocks.client.invoices.createPreview.mockRejectedValueOnce(new Error("stripe down"));
|
|
stripeMocks.client.paymentMethods.list.mockRejectedValueOnce(new Error("stripe down"));
|
|
|
|
await expect(stripeServer.getStripeSubscription("sub_missing")).resolves.toBeNull();
|
|
await expect(stripeServer.getStripePaymentIntent("pi_missing")).resolves.toBeNull();
|
|
await expect(stripeServer.getStripeUpcomingInvoice(null)).resolves.toBeNull();
|
|
await expect(stripeServer.getStripeUpcomingInvoice("cus_missing")).resolves.toBeNull();
|
|
await expect(stripeServer.getStripePaymentMethods("cus_missing")).resolves.toEqual([]);
|
|
});
|
|
|
|
it("filters failed upcoming invoice previews out of batch results", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_upcoming");
|
|
stripeMocks.client.invoices.createPreview
|
|
.mockResolvedValueOnce({ id: "in_preview_1" })
|
|
.mockRejectedValueOnce(new Error("stripe down"))
|
|
.mockResolvedValueOnce({ id: "in_preview_2" });
|
|
|
|
await expect(stripeServer.getStripeUpcomingInvoices(["sub_1", "sub_2", "sub_3"])).resolves.toEqual([{ id: "in_preview_1" }, { id: "in_preview_2" }]);
|
|
expect(stripeMocks.logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), "Could not get upcoming invoice");
|
|
});
|
|
|
|
it("returns null or empty data for meter APIs when Stripe rejects", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_meters");
|
|
stripeMocks.client.billing.meterEvents.create.mockRejectedValue(new Error("stripe down"));
|
|
stripeMocks.client.billing.meters.listEventSummaries.mockRejectedValue(new Error("stripe down"));
|
|
|
|
await expect(
|
|
stripeServer.createMeterEvent({
|
|
eventName: "seats_used",
|
|
stripeCustomerId: "cus_1",
|
|
value: 3,
|
|
identifier: "event_1",
|
|
timestamp: 1_700_000_000,
|
|
})
|
|
).resolves.toBeNull();
|
|
await expect(
|
|
stripeServer.listMeterEventSummaries("meter_1", {
|
|
customer: "cus_1",
|
|
startTime: 1,
|
|
endTime: 2,
|
|
})
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("wraps setup, subscription, payment intent, payment method, and upcoming invoice reads", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_wrappers");
|
|
stripeMocks.client.paymentMethods.list.mockResolvedValue({ data: [{ id: "pm_1" }] });
|
|
|
|
await expect(stripeServer.createStripeSetupSession(new Request("https://app.example.test/settings/subscription"), "cus_1")).resolves.toEqual({
|
|
id: "cs_1",
|
|
});
|
|
await expect(stripeServer.cancelStripeSubscription("sub_1")).resolves.toEqual({ id: "sub_1", status: "canceled" });
|
|
await expect(stripeServer.reactivateStripeSubscription("sub_1")).resolves.toEqual({ id: "sub_1", cancel_at_period_end: false });
|
|
await expect(stripeServer.getStripeSubscription("sub_1")).resolves.toEqual({ id: "sub_1" });
|
|
await expect(stripeServer.createStripeSubscription("cus_1", "price_1", 1_700_000_000)).resolves.toEqual({ id: "sub_1" });
|
|
await expect(stripeServer.getStripePaymentIntent("pi_1")).resolves.toEqual({ id: "pi_1" });
|
|
await expect(stripeServer.getStripeUpcomingInvoice("cus_1")).resolves.toEqual({ id: "in_preview" });
|
|
await expect(stripeServer.getStripePaymentMethods("cus_1")).resolves.toEqual([{ id: "pm_1" }]);
|
|
await expect(stripeServer.getStripePaymentMethods(null)).resolves.toEqual([]);
|
|
|
|
expect(stripeMocks.client.checkout.sessions.create).toHaveBeenCalledWith({
|
|
customer: "cus_1",
|
|
success_url: "https://app.example.test/settings/subscription?session_id={CHECKOUT_SESSION_ID}",
|
|
cancel_url: "https://app.example.test/settings/subscription",
|
|
mode: "setup",
|
|
payment_method_types: ["card"],
|
|
});
|
|
});
|
|
|
|
it("wraps customer, product, coupon, promotion code, cache, and portal helpers", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_catalog");
|
|
stripeMocks.client.billingPortal.sessions.create.mockResolvedValue({ id: "bps_1" });
|
|
stripeMocks.client.coupons.retrieve.mockResolvedValue({ id: "coupon_1" });
|
|
stripeMocks.client.customers.create.mockResolvedValue({ id: "cus_1" });
|
|
stripeMocks.client.customers.del.mockResolvedValue({ id: "cus_1", deleted: true });
|
|
stripeMocks.client.customers.retrieve.mockResolvedValue({ id: "cus_1" });
|
|
stripeMocks.client.customers.update.mockResolvedValue({ id: "cus_1", invoice_settings: { default_payment_method: "pm_1" } });
|
|
stripeMocks.client.invoices.list.mockResolvedValue({ data: [{ id: "in_open" }] });
|
|
stripeMocks.client.promotionCodes.retrieve.mockResolvedValue({ id: "promo_1" });
|
|
|
|
await expect(stripeServer.createStripeCustomer("person@example.test", "Person Test")).resolves.toEqual({ id: "cus_1" });
|
|
await expect(stripeServer.deleteStripeCustomer("cus_1")).resolves.toEqual({ id: "cus_1", deleted: true });
|
|
await expect(stripeServer.updateStripeCustomerPaymentMethod("cus_1", "pm_1")).resolves.toEqual({
|
|
id: "cus_1",
|
|
invoice_settings: { default_payment_method: "pm_1" },
|
|
});
|
|
await expect(stripeServer.createStripeProduct({ title: "Pro" })).resolves.toEqual({ id: "prod_1" });
|
|
await expect(stripeServer.updateStripeProduct("prod_1", { title: "Pro Plus" })).resolves.toEqual({ id: "prod_1" });
|
|
await expect(stripeServer.deleteStripeProduct("prod_1")).resolves.toEqual({ id: "prod_1", deleted: true });
|
|
await expect(stripeServer.archiveStripeProduct("prod_1")).resolves.toEqual({ id: "prod_1" });
|
|
await expect(stripeServer.archiveStripePrice("price_1")).resolves.toEqual({ id: "price_1", active: false });
|
|
await expect(stripeServer.getStripeCoupon("coupon_1")).resolves.toEqual({ id: "coupon_1" });
|
|
await expect(stripeServer.getStripePromotionCode("promo_1")).resolves.toEqual({ id: "promo_1" });
|
|
await expect(stripeServer.getStripeCustomer("cus_1")).resolves.toEqual({ id: "cus_1" });
|
|
await expect(stripeServer.getStripeCustomer(null)).resolves.toBeNull();
|
|
await expect(stripeServer.getOpenInvoices("cus_1")).resolves.toEqual({ data: [{ id: "in_open" }] });
|
|
await expect(stripeServer.createCustomerPortalSession(new Request("https://app.example.test/settings"), "cus_1")).resolves.toEqual({
|
|
id: "bps_1",
|
|
});
|
|
});
|
|
|
|
it("paginates payments and builds invoice lookup maps for revenue aggregation", async () => {
|
|
const stripeServer = await importStripeServer("sk_test_stripe_unit_revenue");
|
|
const { getAllSubscriptionProducts } = await import("./db/subscriptionProducts.db.server");
|
|
vi.mocked(getAllSubscriptionProducts).mockResolvedValue([
|
|
{
|
|
stripeId: "prod_1",
|
|
title: "Pro",
|
|
prices: [{ stripeId: "price_1", billingPeriod: SubscriptionBillingPeriod.MONTHLY }],
|
|
},
|
|
] as never);
|
|
stripeMocks.client.paymentIntents.list.mockResolvedValueOnce({
|
|
data: [
|
|
{ id: "pi_1", amount: 1000, currency: "eur", status: "succeeded" },
|
|
{ id: "pi_ignored", amount: 5000, currency: "usd", status: "requires_payment_method" },
|
|
],
|
|
has_more: false,
|
|
});
|
|
stripeMocks.client.invoices.list.mockResolvedValueOnce({
|
|
data: [
|
|
{
|
|
id: "in_1",
|
|
lines: {
|
|
data: [
|
|
{
|
|
pricing: {
|
|
price_details: {
|
|
price: "price_1",
|
|
product: "prod_1",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
payments: {
|
|
data: [
|
|
{
|
|
payment: {
|
|
payment_intent: { id: "pi_1" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
has_more: false,
|
|
});
|
|
|
|
await expect(stripeServer.getAllStripePayments()).resolves.toEqual([
|
|
{ id: "pi_1", amount: 1000, currency: "eur", status: "succeeded" },
|
|
{ id: "pi_ignored", amount: 5000, currency: "usd", status: "requires_payment_method" },
|
|
]);
|
|
stripeMocks.client.paymentIntents.list.mockResolvedValueOnce({
|
|
data: [{ id: "pi_1", amount: 1000, currency: "eur", status: "succeeded" }],
|
|
has_more: false,
|
|
});
|
|
stripeMocks.client.invoices.list.mockResolvedValueOnce({
|
|
data: [
|
|
{
|
|
id: "in_1",
|
|
lines: {
|
|
data: [{ pricing: { price_details: { price: "price_1", product: "prod_1" } } }],
|
|
},
|
|
payments: { data: [{ payment: { payment_intent: "pi_1" } }] },
|
|
},
|
|
],
|
|
has_more: false,
|
|
});
|
|
|
|
await expect(stripeServer.getStripeRevenueByProductPriceCurrency()).resolves.toEqual([
|
|
{
|
|
product: "Pro",
|
|
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
|
|
currency: "eur",
|
|
revenueInCurrency: 10,
|
|
count: 1,
|
|
countPercentage: 1,
|
|
revenueUsd: 20,
|
|
revenuePercentageUsd: 1,
|
|
},
|
|
]);
|
|
});
|
|
});
|