diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6fbcddb
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,80 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is `@convex-dev/stripe`, a Convex component for integrating Stripe payments, subscriptions, and billing. It's published to npm and used as a dependency in Convex applications.
+
+## Common Commands
+
+```bash
+# Development (runs backend, frontend example, and watch build concurrently)
+npm run dev
+
+# Build component
+npm run build
+
+# Run tests
+npm run test
+
+# Run single test file
+npx vitest run src/client/index.test.ts
+
+# Run tests in watch mode
+npm run test:watch
+
+# Type checking
+npm run typecheck
+
+# Linting
+npm run lint
+
+# Full validation before release
+npm run preversion # runs clean, ci, build, test, lint, typecheck
+```
+
+## Architecture
+
+### Directory Structure
+
+- `src/component/` - Convex component code that runs on Convex servers
+ - `schema.ts` - Database schema (customers, subscriptions, payments, invoices, checkout_sessions)
+ - `public.ts` - Public queries/mutations callable by app code
+ - `private.ts` - Internal mutations for webhook event processing
+ - `convex.config.ts` - Component configuration
+- `src/client/` - Client SDK used by consuming applications
+ - `index.ts` - `StripeSubscriptions` class and `registerRoutes` function
+ - `types.ts` - TypeScript type definitions
+- `src/react/` - React hooks (if any)
+- `example/` - Example Convex application demonstrating usage
+ - `convex/` - Example Convex functions using the component
+
+### How It Works
+
+1. **Component Pattern**: This is a Convex component - it exports a config (`convex.config.js`) that apps install via `app.use(stripe)` in their `convex.config.ts`
+2. **Client SDK**: The `StripeSubscriptions` class wraps Stripe API calls and syncs data to the component's database
+3. **Webhook Handler**: `registerRoutes()` sets up HTTP endpoint for Stripe webhooks, processes events, and stores data
+
+### Key Exports
+
+```typescript
+// Main client class
+import { StripeSubscriptions, registerRoutes } from "@convex-dev/stripe";
+
+// Component config for convex.config.ts
+import stripe from "@convex-dev/stripe/convex.config.js";
+```
+
+### Testing
+
+Tests use `convex-test` framework. Test files are co-located with source (`.test.ts` suffix).
+
+## Convex Guidelines
+
+- Always use the new function syntax with `args`, `returns`, and `handler`
+- Always include return validators (use `v.null()` for functions that return nothing)
+- Use `internalQuery`/`internalMutation`/`internalAction` for private functions
+- Never infer type "any" - use explicit types
+- Always commit the `_generated` folder when using Convex
+- Do NOT use `filter` in queries - use `withIndex` instead
diff --git a/example/convex/http.ts b/example/convex/http.ts
index 9ee9993..713cced 100644
--- a/example/convex/http.ts
+++ b/example/convex/http.ts
@@ -9,6 +9,50 @@ const http = httpRouter();
registerRoutes(http, components.stripe, {
webhookPath: "/stripe/webhook",
events: {
+ "product.created": async (ctx, event) => {
+ const product = event.data.object;
+ console.log("📦 Custom handler: Product created!", {
+ id: product.id,
+ name: product.name,
+ active: product.active,
+ });
+ },
+ "product.updated": async (ctx, event) => {
+ const product = event.data.object;
+ console.log("📦 Custom handler: Product updated!", {
+ id: product.id,
+ name: product.name,
+ active: product.active,
+ });
+ },
+ "product.deleted": async (ctx, event) => {
+ const product = event.data.object;
+ console.log("📦 Custom handler: Product deleted!", {
+ id: product.id,
+ });
+ },
+ "price.created": async (ctx, event) => {
+ const price = event.data.object;
+ console.log("💵 Custom handler: Price created!", {
+ id: price.id,
+ product: price.product,
+ unitAmount: price.unit_amount,
+ currency: price.currency,
+ });
+ },
+ "price.updated": async (ctx, event) => {
+ const price = event.data.object;
+ console.log("💵 Custom handler: Price updated!", {
+ id: price.id,
+ active: price.active,
+ });
+ },
+ "price.deleted": async (ctx, event) => {
+ const price = event.data.object;
+ console.log("💵 Custom handler: Price deleted!", {
+ id: price.id,
+ });
+ },
"customer.subscription.updated": async (ctx, event) => {
// Example custom handler: Log subscription updates
const subscription = event.data.object;
diff --git a/example/convex/stripe.ts b/example/convex/stripe.ts
index 168e81b..4641966 100644
--- a/example/convex/stripe.ts
+++ b/example/convex/stripe.ts
@@ -23,6 +23,218 @@ function getAppUrl(): string {
return url;
}
+// ============================================================================
+// PRODUCTS & PRICES
+// ============================================================================
+
+// Shared validators for products and prices
+const productFields = {
+ stripeProductId: v.string(),
+ name: v.string(),
+ description: v.optional(v.string()),
+ active: v.boolean(),
+ type: v.optional(v.string()),
+ defaultPriceId: v.optional(v.string()),
+ metadata: v.optional(v.any()),
+ images: v.optional(v.array(v.string())),
+};
+
+const priceFields = {
+ stripePriceId: v.string(),
+ stripeProductId: v.string(),
+ active: v.boolean(),
+ currency: v.string(),
+ type: v.string(),
+ unitAmount: v.optional(v.number()),
+ description: v.optional(v.string()),
+ lookupKey: v.optional(v.string()),
+ recurringInterval: v.optional(v.string()),
+ recurringIntervalCount: v.optional(v.number()),
+ trialPeriodDays: v.optional(v.number()),
+ usageType: v.optional(v.string()),
+ billingScheme: v.optional(v.string()),
+ tiersMode: v.optional(v.string()),
+ tiers: v.optional(
+ v.array(
+ v.object({
+ upTo: v.union(v.number(), v.null()),
+ flatAmount: v.optional(v.number()),
+ unitAmount: v.optional(v.number()),
+ }),
+ ),
+ ),
+ metadata: v.optional(v.any()),
+};
+
+/**
+ * Get a product by its Stripe product ID.
+ */
+export const getProduct = query({
+ args: { stripeProductId: v.string() },
+ returns: v.union(v.object(productFields), v.null()),
+ handler: async (ctx, args) => {
+ return await ctx.runQuery(components.stripe.public.getProduct, {
+ stripeProductId: args.stripeProductId,
+ });
+ },
+});
+
+/**
+ * List all products.
+ */
+export const listProducts = query({
+ args: {},
+ returns: v.array(v.object(productFields)),
+ handler: async (ctx) => {
+ return await ctx.runQuery(components.stripe.public.listProducts, {});
+ },
+});
+
+/**
+ * List all active products.
+ */
+export const listActiveProducts = query({
+ args: {},
+ returns: v.array(v.object(productFields)),
+ handler: async (ctx) => {
+ return await ctx.runQuery(components.stripe.public.listActiveProducts, {});
+ },
+});
+
+/**
+ * Get a price by its Stripe price ID.
+ */
+export const getPrice = query({
+ args: { stripePriceId: v.string() },
+ returns: v.union(v.object(priceFields), v.null()),
+ handler: async (ctx, args) => {
+ return await ctx.runQuery(components.stripe.public.getPrice, {
+ stripePriceId: args.stripePriceId,
+ });
+ },
+});
+
+/**
+ * Get a price by its lookup key.
+ */
+export const getPriceByLookupKey = query({
+ args: { lookupKey: v.string() },
+ returns: v.union(v.object(priceFields), v.null()),
+ handler: async (ctx, args) => {
+ return await ctx.runQuery(components.stripe.public.getPriceByLookupKey, {
+ lookupKey: args.lookupKey,
+ });
+ },
+});
+
+/**
+ * List all prices.
+ */
+export const listPrices = query({
+ args: {},
+ returns: v.array(v.object(priceFields)),
+ handler: async (ctx) => {
+ return await ctx.runQuery(components.stripe.public.listPrices, {});
+ },
+});
+
+/**
+ * List all active prices.
+ */
+export const listActivePrices = query({
+ args: {},
+ returns: v.array(v.object(priceFields)),
+ handler: async (ctx) => {
+ return await ctx.runQuery(components.stripe.public.listActivePrices, {});
+ },
+});
+
+/**
+ * List all prices for a specific product.
+ */
+export const listPricesByProduct = query({
+ args: { stripeProductId: v.string() },
+ returns: v.array(v.object(priceFields)),
+ handler: async (ctx, args) => {
+ return await ctx.runQuery(components.stripe.public.listPricesByProduct, {
+ stripeProductId: args.stripeProductId,
+ });
+ },
+});
+
+/**
+ * List all active prices for a specific product.
+ */
+export const listActivePricesByProduct = query({
+ args: { stripeProductId: v.string() },
+ returns: v.array(v.object(priceFields)),
+ handler: async (ctx, args) => {
+ return await ctx.runQuery(
+ components.stripe.public.listActivePricesByProduct,
+ { stripeProductId: args.stripeProductId },
+ );
+ },
+});
+
+/**
+ * Get a product with all its prices.
+ */
+export const getProductWithPrices = query({
+ args: { stripeProductId: v.string() },
+ returns: v.union(
+ v.object({
+ product: v.object(productFields),
+ prices: v.array(v.object(priceFields)),
+ }),
+ v.null(),
+ ),
+ handler: async (ctx, args) => {
+ return await ctx.runQuery(components.stripe.public.getProductWithPrices, {
+ stripeProductId: args.stripeProductId,
+ });
+ },
+});
+
+// ============================================================================
+// SYNC ACTIONS
+// ============================================================================
+
+/**
+ * Sync all products from Stripe to the local database.
+ * Useful for initial setup or populating existing products.
+ */
+export const syncProducts = action({
+ args: {},
+ returns: v.object({ synced: v.number() }),
+ handler: async (ctx) => {
+ return await stripeClient.syncProducts(ctx);
+ },
+});
+
+/**
+ * Sync all prices from Stripe to the local database.
+ * Useful for initial setup or populating existing prices.
+ */
+export const syncPrices = action({
+ args: {},
+ returns: v.object({ synced: v.number() }),
+ handler: async (ctx) => {
+ return await stripeClient.syncPrices(ctx);
+ },
+});
+
+/**
+ * Sync all products and prices from Stripe to the local database.
+ * Products are synced first, then prices.
+ */
+export const syncProductsAndPrices = action({
+ args: {},
+ returns: v.object({ products: v.number(), prices: v.number() }),
+ handler: async (ctx) => {
+ return await stripeClient.syncProductsAndPrices(ctx);
+ },
+});
+
// ============================================================================
// CUSTOMER MANAGEMENT (Customer Creation)
// ============================================================================
@@ -452,7 +664,7 @@ export const getCustomerPortalUrl = action({
}),
v.null(),
),
- handler: async (ctx, args) => {
+ handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
@@ -577,7 +789,7 @@ export const getUserSubscriptions = query({
orgId: v.optional(v.string()),
}),
),
- handler: async (ctx, args) => {
+ handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
@@ -606,7 +818,7 @@ export const getUserPayments = query({
orgId: v.optional(v.string()),
}),
),
- handler: async (ctx, args) => {
+ handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
@@ -629,7 +841,7 @@ export const getFailedPaymentSubscriptions = query({
currentPeriodEnd: v.number(),
}),
),
- handler: async (ctx, args) => {
+ handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 9dca762..2aab4b5 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -891,12 +891,8 @@ function ProfilePage({
// TEAM BILLING PAGE (#4 - Organization-Based Lookups)
// ============================================================================
-function TeamBillingPage({
- setCurrentPage,
-}: {
- setCurrentPage: (page: Page) => void;
-}) {
- const { isSignedIn, user } = useUser();
+function TeamBillingPage() {
+ const { isSignedIn } = useUser();
const [orgId, setOrgId] = useState("demo-org-123");
// Using the org-based queries
@@ -1309,7 +1305,7 @@ function App() {
)}
{currentPage === "team" && (
-
+
)}
>
);
diff --git a/package-lock.json b/package-lock.json
index 857e1c6..2400dc5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,11 +9,10 @@
"version": "0.1.1",
"license": "Apache-2.0",
"dependencies": {
- "@clerk/clerk-react": "^5.57.0",
- "@vercel/analytics": "^1.5.0",
"stripe": "^20.0.0"
},
"devDependencies": {
+ "@clerk/clerk-react": "^5.57.0",
"@convex-dev/eslint-plugin": "^1.0.0",
"@edge-runtime/vm": "^5.0.0",
"@eslint/eslintrc": "^3.3.1",
@@ -21,6 +20,7 @@
"@types/node": "^20.19.25",
"@types/react": "^18.3.27",
"@types/react-dom": "^18.3.7",
+ "@vercel/analytics": "^1.5.0",
"@vitejs/plugin-react": "^5.1.1",
"chokidar-cli": "3.0.0",
"convex": "1.29.3",
@@ -117,6 +117,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -372,7 +373,9 @@
"version": "5.57.0",
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.57.0.tgz",
"integrity": "sha512-GCBFF03HjEWvx58myjauJ7NrwTqhxHdetjWWxVM3YJGPOsAVXg4WuquL/hyn8KDuduCYSkRin4Hg6+QVP1NXAg==",
+ "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@clerk/shared": "^3.36.0",
"tslib": "2.8.1"
@@ -389,6 +392,7 @@
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.36.0.tgz",
"integrity": "sha512-Yp4tL/x/iVft40DnxBjT/g/kQilZ+i9mYrqC1Lk6fUnfZV8t7E54GX19JtJSSONzjHsH6sCv3BmJaF1f7Eomkw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -419,6 +423,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@convex-dev/eslint-plugin": {
@@ -643,6 +648,7 @@
"integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@edge-runtime/primitives": "6.0.0"
},
@@ -1473,6 +1479,7 @@
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@@ -2098,6 +2105,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2122,6 +2130,7 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2183,6 +2192,7 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@@ -2412,6 +2422,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz",
"integrity": "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==",
+ "dev": true,
"license": "MPL-2.0",
"peerDependencies": {
"@remix-run/react": "^2",
@@ -2588,6 +2599,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2934,6 +2946,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -3246,6 +3259,7 @@
"integrity": "sha512-tg5TXzMjpNk9m50YRtdp6US+t7ckxE4E+7DNKUCjJ2MupQs2RBSPF/z5SNN4GUmQLSfg0eMILDySzdAvjTrhnw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"esbuild": "0.25.4",
"prettier": "^3.0.0"
@@ -3547,6 +3561,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3854,6 +3869,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4389,6 +4405,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true,
"license": "BSD-2-Clause"
},
"node_modules/globals": {
@@ -5159,6 +5176,7 @@
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -5168,6 +5186,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5351,6 +5370,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -6333,7 +6353,9 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -6345,7 +6367,9 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6677,6 +6701,7 @@
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
@@ -6946,6 +6971,7 @@
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
@@ -7194,6 +7220,7 @@
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
@@ -7258,6 +7285,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7338,6 +7366,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
"license": "0BSD"
},
"node_modules/tunnel": {
@@ -7470,6 +7499,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7623,6 +7653,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -7655,6 +7686,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7771,6 +7803,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/src/client/index.ts b/src/client/index.ts
index 5dedd24..31accdf 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -313,8 +313,101 @@ export class StripeSubscriptions {
}
// ============================================================================
- // WEBHOOK REGISTRATION
+ // SYNC METHODS
// ============================================================================
+
+ /**
+ * Sync all products from Stripe to the local database.
+ * Useful for initial setup or recovery after missed webhooks.
+ *
+ * @returns The number of products synced
+ */
+ async syncProducts(ctx: ActionCtx): Promise<{ synced: number }> {
+ const stripe = new StripeSDK(this.apiKey);
+ let synced = 0;
+
+ // Fetch all products from Stripe (handles pagination automatically)
+ for await (const product of stripe.products.list({ limit: 100 })) {
+ await ctx.runMutation(this.component.private.handleProductUpdated, {
+ stripeProductId: product.id,
+ name: product.name,
+ description: product.description || undefined,
+ active: product.active,
+ type: product.type,
+ defaultPriceId:
+ typeof product.default_price === "string"
+ ? product.default_price
+ : product.default_price?.id,
+ metadata: product.metadata,
+ images: product.images,
+ });
+ synced++;
+ }
+
+ return { synced };
+ }
+
+ /**
+ * Sync all prices from Stripe to the local database.
+ * Useful for initial setup or recovery after missed webhooks.
+ *
+ * @returns The number of prices synced
+ */
+ async syncPrices(ctx: ActionCtx): Promise<{ synced: number }> {
+ const stripe = new StripeSDK(this.apiKey);
+ let synced = 0;
+
+ // Fetch all prices from Stripe with tiers expanded (handles pagination automatically)
+ for await (const price of stripe.prices.list({
+ limit: 100,
+ expand: ["data.tiers"],
+ })) {
+ await ctx.runMutation(this.component.private.handlePriceUpdated, {
+ stripePriceId: price.id,
+ stripeProductId:
+ typeof price.product === "string" ? price.product : price.product.id,
+ active: price.active,
+ currency: price.currency,
+ type: price.type,
+ unitAmount: price.unit_amount ?? undefined,
+ description: price.nickname || undefined,
+ lookupKey: price.lookup_key || undefined,
+ recurringInterval: price.recurring?.interval,
+ recurringIntervalCount: price.recurring?.interval_count,
+ trialPeriodDays: price.recurring?.trial_period_days ?? undefined,
+ usageType: price.recurring?.usage_type,
+ billingScheme: price.billing_scheme,
+ tiersMode: price.tiers_mode ?? undefined,
+ tiers: price.tiers?.map((tier) => ({
+ upTo: tier.up_to,
+ flatAmount: tier.flat_amount ?? undefined,
+ unitAmount: tier.unit_amount ?? undefined,
+ })),
+ metadata: price.metadata,
+ });
+ synced++;
+ }
+
+ return { synced };
+ }
+
+ /**
+ * Sync all products and prices from Stripe to the local database.
+ * Products are synced first, then prices.
+ *
+ * @returns The number of products and prices synced
+ */
+ async syncProductsAndPrices(
+ ctx: ActionCtx,
+ ): Promise<{ products: number; prices: number }> {
+ const productsResult = await this.syncProducts(ctx);
+ const pricesResult = await this.syncPrices(ctx);
+
+ return {
+ products: productsResult.synced,
+ prices: pricesResult.synced,
+ };
+ }
}
/**
* Register webhook routes with the HTTP router.
@@ -440,6 +533,123 @@ async function processEvent(
stripe: StripeSDK,
): Promise {
switch (event.type) {
+ // ========================================================================
+ // PRODUCTS
+ // ========================================================================
+ case "product.created": {
+ const product = event.data.object as StripeSDK.Product;
+ await ctx.runMutation(component.private.handleProductCreated, {
+ stripeProductId: product.id,
+ name: product.name,
+ description: product.description || undefined,
+ active: product.active,
+ type: product.type,
+ defaultPriceId:
+ typeof product.default_price === "string"
+ ? product.default_price
+ : product.default_price?.id,
+ metadata: product.metadata,
+ images: product.images,
+ });
+ break;
+ }
+
+ case "product.updated": {
+ const product = event.data.object as StripeSDK.Product;
+ await ctx.runMutation(component.private.handleProductUpdated, {
+ stripeProductId: product.id,
+ name: product.name,
+ description: product.description || undefined,
+ active: product.active,
+ type: product.type,
+ defaultPriceId:
+ typeof product.default_price === "string"
+ ? product.default_price
+ : product.default_price?.id,
+ metadata: product.metadata,
+ images: product.images,
+ });
+ break;
+ }
+
+ case "product.deleted": {
+ const product = event.data.object as StripeSDK.Product;
+ await ctx.runMutation(component.private.handleProductDeleted, {
+ stripeProductId: product.id,
+ });
+ break;
+ }
+
+ // ========================================================================
+ // PRICES
+ // ========================================================================
+ case "price.created": {
+ const price = event.data.object as StripeSDK.Price;
+ await ctx.runMutation(component.private.handlePriceCreated, {
+ stripePriceId: price.id,
+ stripeProductId:
+ typeof price.product === "string" ? price.product : price.product.id,
+ active: price.active,
+ currency: price.currency,
+ type: price.type,
+ unitAmount: price.unit_amount ?? undefined,
+ description: price.nickname || undefined,
+ lookupKey: price.lookup_key || undefined,
+ recurringInterval: price.recurring?.interval,
+ recurringIntervalCount: price.recurring?.interval_count,
+ trialPeriodDays: price.recurring?.trial_period_days ?? undefined,
+ usageType: price.recurring?.usage_type,
+ billingScheme: price.billing_scheme,
+ tiersMode: price.tiers_mode ?? undefined,
+ tiers: price.tiers?.map((tier) => ({
+ upTo: tier.up_to,
+ flatAmount: tier.flat_amount ?? undefined,
+ unitAmount: tier.unit_amount ?? undefined,
+ })),
+ metadata: price.metadata,
+ });
+ break;
+ }
+
+ case "price.updated": {
+ const price = event.data.object as StripeSDK.Price;
+ await ctx.runMutation(component.private.handlePriceUpdated, {
+ stripePriceId: price.id,
+ stripeProductId:
+ typeof price.product === "string" ? price.product : price.product.id,
+ active: price.active,
+ currency: price.currency,
+ type: price.type,
+ unitAmount: price.unit_amount ?? undefined,
+ description: price.nickname || undefined,
+ lookupKey: price.lookup_key || undefined,
+ recurringInterval: price.recurring?.interval,
+ recurringIntervalCount: price.recurring?.interval_count,
+ trialPeriodDays: price.recurring?.trial_period_days ?? undefined,
+ usageType: price.recurring?.usage_type,
+ billingScheme: price.billing_scheme,
+ tiersMode: price.tiers_mode ?? undefined,
+ tiers: price.tiers?.map((tier) => ({
+ upTo: tier.up_to,
+ flatAmount: tier.flat_amount ?? undefined,
+ unitAmount: tier.unit_amount ?? undefined,
+ })),
+ metadata: price.metadata,
+ });
+ break;
+ }
+
+ case "price.deleted": {
+ const price = event.data.object as StripeSDK.Price;
+ await ctx.runMutation(component.private.handlePriceDeleted, {
+ stripePriceId: price.id,
+ });
+ break;
+ }
+
+ // ========================================================================
+ // CUSTOMERS
+ // ========================================================================
case "customer.created":
case "customer.updated": {
const customer = event.data.object as StripeSDK.Customer;
diff --git a/src/component/private.ts b/src/component/private.ts
index d9cb395..bb8ee26 100644
--- a/src/component/private.ts
+++ b/src/component/private.ts
@@ -1,10 +1,281 @@
import { v } from "convex/values";
import { mutation } from "./_generated/server.js";
+// Validator for price tiers
+const tierValidator = v.object({
+ upTo: v.union(v.number(), v.null()),
+ flatAmount: v.optional(v.number()),
+ unitAmount: v.optional(v.number()),
+});
+
// ============================================================================
// INTERNAL MUTATIONS (for webhooks and internal use)
// ============================================================================
+// ============================================================================
+// PRODUCTS
+// ============================================================================
+
+export const handleProductCreated = mutation({
+ args: {
+ stripeProductId: v.string(),
+ name: v.string(),
+ description: v.optional(v.string()),
+ active: v.boolean(),
+ type: v.optional(v.string()),
+ defaultPriceId: v.optional(v.string()),
+ metadata: v.optional(v.any()),
+ images: v.optional(v.array(v.string())),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("products")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .unique();
+
+ if (!existing) {
+ await ctx.db.insert("products", {
+ stripeProductId: args.stripeProductId,
+ name: args.name,
+ description: args.description,
+ active: args.active,
+ type: args.type,
+ defaultPriceId: args.defaultPriceId,
+ metadata: args.metadata || {},
+ images: args.images,
+ });
+ }
+
+ return null;
+ },
+});
+
+export const handleProductUpdated = mutation({
+ args: {
+ stripeProductId: v.string(),
+ name: v.string(),
+ description: v.optional(v.string()),
+ active: v.boolean(),
+ type: v.optional(v.string()),
+ defaultPriceId: v.optional(v.string()),
+ metadata: v.optional(v.any()),
+ images: v.optional(v.array(v.string())),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const product = await ctx.db
+ .query("products")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .unique();
+
+ if (product) {
+ await ctx.db.patch(product._id, {
+ name: args.name,
+ description: args.description,
+ active: args.active,
+ type: args.type,
+ defaultPriceId: args.defaultPriceId,
+ metadata: args.metadata,
+ images: args.images,
+ });
+ } else {
+ // Product doesn't exist yet, create it
+ await ctx.db.insert("products", {
+ stripeProductId: args.stripeProductId,
+ name: args.name,
+ description: args.description,
+ active: args.active,
+ type: args.type,
+ defaultPriceId: args.defaultPriceId,
+ metadata: args.metadata || {},
+ images: args.images,
+ });
+ }
+
+ return null;
+ },
+});
+
+export const handleProductDeleted = mutation({
+ args: {
+ stripeProductId: v.string(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const product = await ctx.db
+ .query("products")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .unique();
+
+ if (product) {
+ await ctx.db.patch(product._id, {
+ active: false,
+ });
+ }
+
+ return null;
+ },
+});
+
+// ============================================================================
+// PRICES
+// ============================================================================
+
+export const handlePriceCreated = mutation({
+ args: {
+ stripePriceId: v.string(),
+ stripeProductId: v.string(),
+ active: v.boolean(),
+ currency: v.string(),
+ type: v.string(),
+ unitAmount: v.optional(v.number()),
+ description: v.optional(v.string()),
+ lookupKey: v.optional(v.string()),
+ recurringInterval: v.optional(v.string()),
+ recurringIntervalCount: v.optional(v.number()),
+ trialPeriodDays: v.optional(v.number()),
+ usageType: v.optional(v.string()),
+ billingScheme: v.optional(v.string()),
+ tiersMode: v.optional(v.string()),
+ tiers: v.optional(v.array(tierValidator)),
+ metadata: v.optional(v.any()),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_price_id", (q) =>
+ q.eq("stripePriceId", args.stripePriceId),
+ )
+ .unique();
+
+ if (!existing) {
+ await ctx.db.insert("prices", {
+ stripePriceId: args.stripePriceId,
+ stripeProductId: args.stripeProductId,
+ active: args.active,
+ currency: args.currency,
+ type: args.type,
+ unitAmount: args.unitAmount,
+ description: args.description,
+ lookupKey: args.lookupKey,
+ recurringInterval: args.recurringInterval,
+ recurringIntervalCount: args.recurringIntervalCount,
+ trialPeriodDays: args.trialPeriodDays,
+ usageType: args.usageType,
+ billingScheme: args.billingScheme,
+ tiersMode: args.tiersMode,
+ tiers: args.tiers,
+ metadata: args.metadata || {},
+ });
+ }
+
+ return null;
+ },
+});
+
+export const handlePriceUpdated = mutation({
+ args: {
+ stripePriceId: v.string(),
+ stripeProductId: v.string(),
+ active: v.boolean(),
+ currency: v.string(),
+ type: v.string(),
+ unitAmount: v.optional(v.number()),
+ description: v.optional(v.string()),
+ lookupKey: v.optional(v.string()),
+ recurringInterval: v.optional(v.string()),
+ recurringIntervalCount: v.optional(v.number()),
+ trialPeriodDays: v.optional(v.number()),
+ usageType: v.optional(v.string()),
+ billingScheme: v.optional(v.string()),
+ tiersMode: v.optional(v.string()),
+ tiers: v.optional(v.array(tierValidator)),
+ metadata: v.optional(v.any()),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const price = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_price_id", (q) =>
+ q.eq("stripePriceId", args.stripePriceId),
+ )
+ .unique();
+
+ if (price) {
+ await ctx.db.patch(price._id, {
+ stripeProductId: args.stripeProductId,
+ active: args.active,
+ currency: args.currency,
+ type: args.type,
+ unitAmount: args.unitAmount,
+ description: args.description,
+ lookupKey: args.lookupKey,
+ recurringInterval: args.recurringInterval,
+ recurringIntervalCount: args.recurringIntervalCount,
+ trialPeriodDays: args.trialPeriodDays,
+ usageType: args.usageType,
+ billingScheme: args.billingScheme,
+ tiersMode: args.tiersMode,
+ tiers: args.tiers,
+ metadata: args.metadata,
+ });
+ } else {
+ // Price doesn't exist yet, create it
+ await ctx.db.insert("prices", {
+ stripePriceId: args.stripePriceId,
+ stripeProductId: args.stripeProductId,
+ active: args.active,
+ currency: args.currency,
+ type: args.type,
+ unitAmount: args.unitAmount,
+ description: args.description,
+ lookupKey: args.lookupKey,
+ recurringInterval: args.recurringInterval,
+ recurringIntervalCount: args.recurringIntervalCount,
+ trialPeriodDays: args.trialPeriodDays,
+ usageType: args.usageType,
+ billingScheme: args.billingScheme,
+ tiersMode: args.tiersMode,
+ tiers: args.tiers,
+ metadata: args.metadata || {},
+ });
+ }
+
+ return null;
+ },
+});
+
+export const handlePriceDeleted = mutation({
+ args: {
+ stripePriceId: v.string(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const price = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_price_id", (q) =>
+ q.eq("stripePriceId", args.stripePriceId),
+ )
+ .unique();
+
+ if (price) {
+ await ctx.db.patch(price._id, {
+ active: false,
+ });
+ }
+
+ return null;
+ },
+});
+
export const updateSubscriptionQuantityInternal = mutation({
args: {
stripeSubscriptionId: v.string(),
diff --git a/src/component/public.ts b/src/component/public.ts
index 2df9331..14b2df1 100644
--- a/src/component/public.ts
+++ b/src/component/public.ts
@@ -9,6 +9,8 @@ import StripeSDK from "stripe";
// ============================================================================
// Reusable validators that omit system fields (_id, _creationTime)
+const productValidator = schema.tables.products.validator;
+const priceValidator = schema.tables.prices.validator;
const customerValidator = schema.tables.customers.validator;
const subscriptionValidator = schema.tables.subscriptions.validator;
const paymentValidator = schema.tables.payments.validator;
@@ -18,6 +20,199 @@ const invoiceValidator = schema.tables.invoices.validator;
// PUBLIC QUERIES
// ============================================================================
+// ============================================================================
+// PRODUCTS
+// ============================================================================
+
+/**
+ * Get a product by its Stripe product ID.
+ */
+export const getProduct = query({
+ args: { stripeProductId: v.string() },
+ returns: v.union(productValidator, v.null()),
+ handler: async (ctx, args) => {
+ const product = await ctx.db
+ .query("products")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .unique();
+ if (!product) return null;
+ const { _id, _creationTime, ...data } = product;
+ return data;
+ },
+});
+
+/**
+ * List all products.
+ */
+export const listProducts = query({
+ args: {},
+ returns: v.array(productValidator),
+ handler: async (ctx) => {
+ const products = await ctx.db.query("products").collect();
+ return products.map(({ _id, _creationTime, ...data }) => data);
+ },
+});
+
+/**
+ * List all active products.
+ */
+export const listActiveProducts = query({
+ args: {},
+ returns: v.array(productValidator),
+ handler: async (ctx) => {
+ const products = await ctx.db
+ .query("products")
+ .withIndex("by_active", (q) => q.eq("active", true))
+ .collect();
+ return products.map(({ _id, _creationTime, ...data }) => data);
+ },
+});
+
+// ============================================================================
+// PRICES
+// ============================================================================
+
+/**
+ * Get a price by its Stripe price ID.
+ */
+export const getPrice = query({
+ args: { stripePriceId: v.string() },
+ returns: v.union(priceValidator, v.null()),
+ handler: async (ctx, args) => {
+ const price = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_price_id", (q) =>
+ q.eq("stripePriceId", args.stripePriceId),
+ )
+ .unique();
+ if (!price) return null;
+ const { _id, _creationTime, ...data } = price;
+ return data;
+ },
+});
+
+/**
+ * List all prices.
+ */
+export const listPrices = query({
+ args: {},
+ returns: v.array(priceValidator),
+ handler: async (ctx) => {
+ const prices = await ctx.db.query("prices").collect();
+ return prices.map(({ _id, _creationTime, ...data }) => data);
+ },
+});
+
+/**
+ * List all active prices.
+ */
+export const listActivePrices = query({
+ args: {},
+ returns: v.array(priceValidator),
+ handler: async (ctx) => {
+ const prices = await ctx.db
+ .query("prices")
+ .withIndex("by_active", (q) => q.eq("active", true))
+ .collect();
+ return prices.map(({ _id, _creationTime, ...data }) => data);
+ },
+});
+
+/**
+ * List all prices for a product.
+ */
+export const listPricesByProduct = query({
+ args: { stripeProductId: v.string() },
+ returns: v.array(priceValidator),
+ handler: async (ctx, args) => {
+ const prices = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .collect();
+ return prices.map(({ _id, _creationTime, ...data }) => data);
+ },
+});
+
+/**
+ * List all active prices for a product.
+ */
+export const listActivePricesByProduct = query({
+ args: { stripeProductId: v.string() },
+ returns: v.array(priceValidator),
+ handler: async (ctx, args) => {
+ const prices = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .collect();
+ return prices
+ .filter((price) => price.active)
+ .map(({ _id, _creationTime, ...data }) => data);
+ },
+});
+
+/**
+ * Get a price by its lookup key.
+ */
+export const getPriceByLookupKey = query({
+ args: { lookupKey: v.string() },
+ returns: v.union(priceValidator, v.null()),
+ handler: async (ctx, args) => {
+ const price = await ctx.db
+ .query("prices")
+ .withIndex("by_lookup_key", (q) => q.eq("lookupKey", args.lookupKey))
+ .unique();
+ if (!price) return null;
+ const { _id, _creationTime, ...data } = price;
+ return data;
+ },
+});
+
+/**
+ * Get a product with all its prices.
+ */
+export const getProductWithPrices = query({
+ args: { stripeProductId: v.string() },
+ returns: v.union(
+ v.object({
+ product: productValidator,
+ prices: v.array(priceValidator),
+ }),
+ v.null(),
+ ),
+ handler: async (ctx, args) => {
+ const product = await ctx.db
+ .query("products")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .unique();
+ if (!product) return null;
+
+ const prices = await ctx.db
+ .query("prices")
+ .withIndex("by_stripe_product_id", (q) =>
+ q.eq("stripeProductId", args.stripeProductId),
+ )
+ .collect();
+
+ const { _id, _creationTime, ...productData } = product;
+ return {
+ product: productData,
+ prices: prices.map(({ _id, _creationTime, ...data }) => data),
+ };
+ },
+});
+
+// ============================================================================
+// CUSTOMERS
+// ============================================================================
+
/**
* Get a customer by their Stripe customer ID.
*/
diff --git a/src/component/schema.ts b/src/component/schema.ts
index cf62912..bf3ce8d 100644
--- a/src/component/schema.ts
+++ b/src/component/schema.ts
@@ -2,6 +2,52 @@ import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
+ products: defineTable({
+ stripeProductId: v.string(),
+ name: v.string(),
+ description: v.optional(v.string()),
+ active: v.boolean(),
+ type: v.optional(v.string()), // "service" | "good"
+ defaultPriceId: v.optional(v.string()),
+ metadata: v.optional(v.any()),
+ images: v.optional(v.array(v.string())),
+ })
+ .index("by_stripe_product_id", ["stripeProductId"])
+ .index("by_active", ["active"]),
+
+ prices: defineTable({
+ stripePriceId: v.string(),
+ stripeProductId: v.string(),
+ active: v.boolean(),
+ currency: v.string(),
+ type: v.string(), // "one_time" | "recurring"
+ unitAmount: v.optional(v.number()), // in cents
+ description: v.optional(v.string()),
+ lookupKey: v.optional(v.string()),
+ // Recurring-specific fields
+ recurringInterval: v.optional(v.string()), // "day" | "week" | "month" | "year"
+ recurringIntervalCount: v.optional(v.number()),
+ trialPeriodDays: v.optional(v.number()),
+ usageType: v.optional(v.string()), // "licensed" | "metered"
+ // Tiered pricing fields
+ billingScheme: v.optional(v.string()), // "per_unit" | "tiered"
+ tiersMode: v.optional(v.string()), // "graduated" | "volume"
+ tiers: v.optional(
+ v.array(
+ v.object({
+ upTo: v.union(v.number(), v.null()), // null means infinity (last tier)
+ flatAmount: v.optional(v.number()),
+ unitAmount: v.optional(v.number()),
+ }),
+ ),
+ ),
+ metadata: v.optional(v.any()),
+ })
+ .index("by_stripe_price_id", ["stripePriceId"])
+ .index("by_stripe_product_id", ["stripeProductId"])
+ .index("by_active", ["active"])
+ .index("by_lookup_key", ["lookupKey"]),
+
customers: defineTable({
stripeCustomerId: v.string(),
email: v.optional(v.string()),