diff --git a/src/client/index.test.ts b/src/client/index.test.ts
new file mode 100644
index 0000000..d6ebcb8
--- /dev/null
+++ b/src/client/index.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vitest";
+import { StripeSubscriptions, registerRoutes } from "./index.js";
+import { components } from "./setup.test.js";
+
+describe("StripeSubscriptions client", () => {
+ test("should create Stripe client with component", async () => {
+ const client = new StripeSubscriptions(components.stripe);
+ expect(client).toBeDefined();
+ expect(client.component).toBeDefined();
+ });
+
+ test("should accept STRIPE_SECRET_KEY option", async () => {
+ const client = new StripeSubscriptions(components.stripe, {
+ STRIPE_SECRET_KEY: "sk_test_123",
+ });
+ expect(client).toBeDefined();
+ // The apiKey getter should return the provided key
+ expect(client.apiKey).toBe("sk_test_123");
+ });
+
+ test("should throw error when accessing apiKey without key set", async () => {
+ // Clear the environment variable temporarily
+ const originalKey = process.env.STRIPE_SECRET_KEY;
+ delete process.env.STRIPE_SECRET_KEY;
+
+ const client = new StripeSubscriptions(components.stripe);
+
+ expect(() => client.apiKey).toThrow(
+ "STRIPE_SECRET_KEY environment variable is not set"
+ );
+
+ // Restore the environment variable
+ if (originalKey) {
+ process.env.STRIPE_SECRET_KEY = originalKey;
+ }
+ });
+});
+
+describe("registerRoutes", () => {
+ test("registerRoutes function should be exported", () => {
+ expect(typeof registerRoutes).toBe("function");
+ });
+});
diff --git a/src/client/setup.test.ts b/src/client/setup.test.ts
new file mode 100644
index 0000000..bdf5a20
--- /dev/null
+++ b/src/client/setup.test.ts
@@ -0,0 +1,25 @@
+///
+import { test } from "vitest";
+import { convexTest } from "convex-test";
+import {
+ componentsGeneric,
+ defineSchema,
+ type GenericSchema,
+ type SchemaDefinition,
+} from "convex/server";
+import type { ComponentApi } from "../component/_generated/component.js";
+
+const modules = import.meta.glob("./**/*.*s");
+
+export function initConvexTest<
+ Schema extends SchemaDefinition,
+>(schema?: Schema) {
+ const t = convexTest(schema ?? defineSchema({}), modules);
+ return t;
+}
+
+export const components = componentsGeneric() as unknown as {
+ stripe: ComponentApi;
+};
+
+test("setup", () => {});
diff --git a/src/component/public.test.ts b/src/component/public.test.ts
new file mode 100644
index 0000000..7f17cb6
--- /dev/null
+++ b/src/component/public.test.ts
@@ -0,0 +1,532 @@
+import { convexTest } from "convex-test";
+import { expect, test } from "vitest";
+import { api, internal } from "./_generated/api.js";
+import schema from "./schema.js";
+import { modules } from "./setup.test.js";
+
+test("customer creation and retrieval", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create a customer
+ const customerId = await t.mutation(api.public.createOrUpdateCustomer, {
+ stripeCustomerId: "cus_test123",
+ email: "test@example.com",
+ name: "Test User",
+ metadata: { userId: "user_123" },
+ });
+
+ expect(customerId).toBeDefined();
+
+ // Retrieve the customer
+ const customer = await t.query(api.public.getCustomer, {
+ stripeCustomerId: "cus_test123",
+ });
+
+ expect(customer).toBeDefined();
+ expect(customer?.email).toBe("test@example.com");
+ expect(customer?.name).toBe("Test User");
+ expect(customer?.metadata).toEqual({ userId: "user_123" });
+});
+
+test("customer update", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create initial customer
+ await t.mutation(api.public.createOrUpdateCustomer, {
+ stripeCustomerId: "cus_test456",
+ email: "old@example.com",
+ name: "Old Name",
+ });
+
+ // Update customer
+ await t.mutation(api.public.createOrUpdateCustomer, {
+ stripeCustomerId: "cus_test456",
+ email: "new@example.com",
+ name: "New Name",
+ metadata: { updated: true },
+ });
+
+ // Verify update
+ const customer = await t.query(api.public.getCustomer, {
+ stripeCustomerId: "cus_test456",
+ });
+
+ expect(customer?.email).toBe("new@example.com");
+ expect(customer?.name).toBe("New Name");
+ expect(customer?.metadata).toEqual({ updated: true });
+});
+
+test("subscription creation via webhook", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create customer first
+ await t.mutation(api.private.handleCustomerCreated, {
+ stripeCustomerId: "cus_test789",
+ email: "customer@example.com",
+ name: "Customer Name",
+ });
+
+ // Create subscription via webhook
+ await t.mutation(api.private.handleSubscriptionCreated, {
+ stripeSubscriptionId: "sub_test123",
+ stripeCustomerId: "cus_test789",
+ status: "active",
+ currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
+ cancelAtPeriodEnd: false,
+ quantity: 5,
+ priceId: "price_test",
+ metadata: { orgId: "org_123" },
+ });
+
+ // Retrieve subscription
+ const subscription = await t.query(api.public.getSubscription, {
+ stripeSubscriptionId: "sub_test123",
+ });
+
+ expect(subscription).toBeDefined();
+ expect(subscription?.status).toBe("active");
+ expect(subscription?.quantity).toBe(5);
+ expect(subscription?.metadata).toEqual({ orgId: "org_123" });
+});
+
+test("list subscriptions for customer", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create customer
+ await t.mutation(api.private.handleCustomerCreated, {
+ stripeCustomerId: "cus_multi",
+ email: "multi@example.com",
+ });
+
+ // Create multiple subscriptions
+ await t.mutation(api.private.handleSubscriptionCreated, {
+ stripeSubscriptionId: "sub_1",
+ stripeCustomerId: "cus_multi",
+ status: "active",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ priceId: "price_1",
+ });
+
+ await t.mutation(api.private.handleSubscriptionCreated, {
+ stripeSubscriptionId: "sub_2",
+ stripeCustomerId: "cus_multi",
+ status: "active",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ priceId: "price_2",
+ });
+
+ // List subscriptions
+ const subscriptions = await t.query(api.public.listSubscriptions, {
+ stripeCustomerId: "cus_multi",
+ });
+
+ expect(subscriptions).toHaveLength(2);
+ expect(subscriptions.map((s: any) => s.stripeSubscriptionId)).toContain("sub_1");
+ expect(subscriptions.map((s: any) => s.stripeSubscriptionId)).toContain("sub_2");
+});
+
+test("update subscription metadata for custom lookups", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create subscription
+ await t.mutation(api.private.handleSubscriptionCreated, {
+ stripeSubscriptionId: "sub_metadata",
+ stripeCustomerId: "cus_test",
+ status: "active",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ priceId: "price_test",
+ });
+
+ // Update metadata
+ await t.mutation(api.public.updateSubscriptionMetadata, {
+ stripeSubscriptionId: "sub_metadata",
+ metadata: {
+ orgId: "org_456",
+ userId: "user_789",
+ plan: "pro",
+ },
+ });
+
+ // Verify metadata
+ const subscription = await t.query(api.public.getSubscription, {
+ stripeSubscriptionId: "sub_metadata",
+ });
+
+ expect(subscription?.metadata).toEqual({
+ orgId: "org_456",
+ userId: "user_789",
+ plan: "pro",
+ });
+});
+
+test("subscription status update via webhook", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create initial subscription
+ await t.mutation(api.private.handleSubscriptionCreated, {
+ stripeSubscriptionId: "sub_status",
+ stripeCustomerId: "cus_test",
+ status: "active",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ priceId: "price_test",
+ });
+
+ // Update status to past_due
+ await t.mutation(api.private.handleSubscriptionUpdated, {
+ stripeSubscriptionId: "sub_status",
+ status: "past_due",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ });
+
+ // Verify status update
+ const subscription = await t.query(api.public.getSubscription, {
+ stripeSubscriptionId: "sub_status",
+ });
+
+ expect(subscription?.status).toBe("past_due");
+});
+
+test("seat quantity update", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create subscription with initial quantity
+ await t.mutation(api.private.handleSubscriptionCreated, {
+ stripeSubscriptionId: "sub_seats",
+ stripeCustomerId: "cus_test",
+ status: "active",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ quantity: 5,
+ priceId: "price_test",
+ });
+
+ // Update quantity
+ await t.mutation(api.private.handleSubscriptionUpdated, {
+ stripeSubscriptionId: "sub_seats",
+ status: "active",
+ currentPeriodEnd: Date.now(),
+ cancelAtPeriodEnd: false,
+ quantity: 10,
+ });
+
+ // Verify quantity update
+ const subscription = await t.query(api.public.getSubscription, {
+ stripeSubscriptionId: "sub_seats",
+ });
+
+ expect(subscription?.quantity).toBe(10);
+});
+
+// ============================================================================
+// PAYMENT TESTS
+// ============================================================================
+
+test("payment creation via payment_intent.succeeded webhook", async () => {
+ const t = convexTest(schema, modules);
+
+ // Simulate payment_intent.succeeded webhook
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_test123",
+ stripeCustomerId: "cus_payment_test",
+ amount: 1999, // $19.99
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { orderId: "order_123" },
+ });
+
+ // Retrieve the payment
+ const payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_test123",
+ });
+
+ expect(payment).toBeDefined();
+ expect(payment?.amount).toBe(1999);
+ expect(payment?.currency).toBe("usd");
+ expect(payment?.status).toBe("succeeded");
+ expect(payment?.stripeCustomerId).toBe("cus_payment_test");
+ expect(payment?.metadata).toEqual({ orderId: "order_123" });
+});
+
+test("payment without customer (guest checkout)", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create payment without customer ID (guest checkout)
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_guest123",
+ amount: 2500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ const payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_guest123",
+ });
+
+ expect(payment).toBeDefined();
+ expect(payment?.stripeCustomerId).toBeUndefined();
+});
+
+test("payment with orgId and userId extraction from metadata", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create payment with orgId and userId in metadata
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_org123",
+ stripeCustomerId: "cus_org_test",
+ amount: 5000,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {
+ orgId: "org_demo_123",
+ userId: "user_demo_456",
+ customField: "custom_value",
+ },
+ });
+
+ const payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_org123",
+ });
+
+ expect(payment?.orgId).toBe("org_demo_123");
+ expect(payment?.userId).toBe("user_demo_456");
+ expect(payment?.metadata).toEqual({
+ orgId: "org_demo_123",
+ userId: "user_demo_456",
+ customField: "custom_value",
+ });
+});
+
+test("list payments by customer ID", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create multiple payments for the same customer
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_cust1",
+ stripeCustomerId: "cus_multi_test",
+ amount: 1000,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_cust2",
+ stripeCustomerId: "cus_multi_test",
+ amount: 2000,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_other",
+ stripeCustomerId: "cus_other",
+ amount: 3000,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ // List payments for the specific customer
+ const payments = await t.query(api.public.listPayments, {
+ stripeCustomerId: "cus_multi_test",
+ });
+
+ expect(payments).toHaveLength(2);
+ expect(payments?.map((p: any) => p.stripePaymentIntentId)).toContain("pi_cust1");
+ expect(payments?.map((p: any) => p.stripePaymentIntentId)).toContain("pi_cust2");
+});
+
+test("list payments by user ID", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create payments with different user IDs
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_user1",
+ stripeCustomerId: "cus_test",
+ amount: 1500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { userId: "user_alice" },
+ });
+
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_user2",
+ stripeCustomerId: "cus_test",
+ amount: 2500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { userId: "user_alice" },
+ });
+
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_user3",
+ stripeCustomerId: "cus_test2",
+ amount: 3500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { userId: "user_bob" },
+ });
+
+ // List payments for user_alice
+ const alicePayments = await t.query(api.public.listPaymentsByUserId, {
+ userId: "user_alice",
+ });
+
+ expect(alicePayments).toHaveLength(2);
+ expect(alicePayments?.every((p: any) => p.userId === "user_alice")).toBe(true);
+});
+
+test("list payments by org ID", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create payments with different org IDs
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_org1",
+ stripeCustomerId: "cus_test",
+ amount: 1500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { orgId: "org_acme" },
+ });
+
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_org2",
+ stripeCustomerId: "cus_test",
+ amount: 2500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { orgId: "org_acme" },
+ });
+
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_org3",
+ stripeCustomerId: "cus_test2",
+ amount: 3500,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { orgId: "org_other" },
+ });
+
+ // List payments for org_acme
+ const acmePayments = await t.query(api.public.listPaymentsByOrgId, {
+ orgId: "org_acme",
+ });
+
+ expect(acmePayments).toHaveLength(2);
+ expect(acmePayments?.every((p: any) => p.orgId === "org_acme")).toBe(true);
+});
+
+test("automatic customer linking - webhook timing fix", async () => {
+ const t = convexTest(schema, modules);
+
+ // Step 1: payment_intent.succeeded fires first (without customer)
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_timing_test",
+ amount: 4999,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: { orderId: "order_timing" },
+ });
+
+ // Verify payment exists without customer
+ let payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_timing_test",
+ });
+
+ expect(payment).toBeDefined();
+ expect(payment?.stripeCustomerId).toBeUndefined();
+
+ // Step 2: checkout.session.completed fires later with customer ID
+ await t.mutation(api.private.updatePaymentCustomer, {
+ stripePaymentIntentId: "pi_timing_test",
+ stripeCustomerId: "cus_timing_test",
+ });
+
+ // Verify payment now has customer ID
+ payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_timing_test",
+ });
+
+ expect(payment?.stripeCustomerId).toBe("cus_timing_test");
+ expect(payment?.amount).toBe(4999); // Other fields unchanged
+});
+
+test("updatePaymentCustomer does not overwrite existing customer", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create payment with customer ID
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_no_overwrite",
+ stripeCustomerId: "cus_original",
+ amount: 3000,
+ currency: "usd",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ // Try to update with different customer ID (should not change)
+ await t.mutation(api.private.updatePaymentCustomer, {
+ stripePaymentIntentId: "pi_no_overwrite",
+ stripeCustomerId: "cus_different",
+ });
+
+ // Verify original customer ID is preserved
+ const payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_no_overwrite",
+ });
+
+ expect(payment?.stripeCustomerId).toBe("cus_original");
+});
+
+test("handlePaymentIntentSucceeded updates existing payment with customer", async () => {
+ const t = convexTest(schema, modules);
+
+ // Create payment without customer
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_update_test",
+ amount: 5500,
+ currency: "eur",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ // Same webhook fires again with customer (idempotency)
+ await t.mutation(api.private.handlePaymentIntentSucceeded, {
+ stripePaymentIntentId: "pi_update_test",
+ stripeCustomerId: "cus_idempotent",
+ amount: 5500,
+ currency: "eur",
+ status: "succeeded",
+ created: Date.now(),
+ metadata: {},
+ });
+
+ const payment = await t.query(api.public.getPayment, {
+ stripePaymentIntentId: "pi_update_test",
+ });
+
+ expect(payment?.stripeCustomerId).toBe("cus_idempotent");
+});
+