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()),