├── example ├── src │ ├── vite-env.d.ts │ ├── App.css │ └── main.tsx ├── convex │ ├── tsconfig.json │ ├── schema.ts │ ├── auth.config.ts │ ├── convex.config.ts │ ├── _generated │ │ ├── api.js │ │ ├── api.d.ts │ │ ├── dataModel.d.ts │ │ ├── server.js │ │ └── server.d.ts │ ├── http.ts │ ├── README.md │ └── stripe.ts ├── vite.config.ts ├── tsconfig.json ├── index.html └── README.md ├── .prettierrc.json ├── src ├── client │ ├── _generated │ │ └── _ignore.ts │ ├── setup.test.ts │ ├── index.test.ts │ ├── types.ts │ └── index.ts ├── component │ ├── convex.config.ts │ ├── setup.test.ts │ ├── _generated │ │ ├── api.ts │ │ ├── dataModel.ts │ │ ├── server.ts │ │ └── component.ts │ ├── schema.ts │ ├── public.ts │ ├── private.ts │ └── public.test.ts └── react │ └── index.ts ├── convex.json ├── .gitignore ├── CONTRIBUTING.md ├── vitest.config.js ├── tsconfig.build.json ├── tsconfig.test.json ├── CHANGELOG.md ├── renovate.json ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── eslint.config.js ├── package.json ├── LICENSE ├── README.md └── .cursor └── rules └── convex_rules.mdc /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "proseWrap": "always" 4 | } 5 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | /* App-specific styles - kept minimal, main styles in index.css */ 2 | -------------------------------------------------------------------------------- /src/client/_generated/_ignore.ts: -------------------------------------------------------------------------------- 1 | // This is only here so convex-test can detect a _generated folder 2 | -------------------------------------------------------------------------------- /example/convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."], 4 | "exclude": ["_generated"] 5 | } 6 | -------------------------------------------------------------------------------- /src/component/convex.config.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "convex/server"; 2 | 3 | export default defineComponent("stripe"); 4 | -------------------------------------------------------------------------------- /src/react/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This is where React components / hooks go. 4 | 5 | export const useMyComponent = () => { 6 | return {}; 7 | }; 8 | -------------------------------------------------------------------------------- /example/convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema } from "convex/server"; 2 | 3 | export default defineSchema({ 4 | // Any tables used by the example app go here. 5 | }); 6 | -------------------------------------------------------------------------------- /convex.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": "example/convex", 3 | "codegen": { 4 | "legacyComponentApi": false 5 | }, 6 | "$schema": "./node_modules/convex/schemas/convex.schema.json" 7 | } 8 | -------------------------------------------------------------------------------- /example/convex/auth.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: "https://knowing-sponge-7.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /example/convex/convex.config.ts: -------------------------------------------------------------------------------- 1 | import { defineApp } from "convex/server"; 2 | import stripe from "@convex-dev/stripe/convex.config.js"; 3 | 4 | const app = defineApp(); 5 | app.use(stripe); 6 | 7 | export default app; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.local 4 | *.log 5 | /.vscode/ 6 | /docs/.vitepress/cache 7 | dist 8 | dist-ssr 9 | explorations 10 | node_modules 11 | .eslintcache 12 | 13 | # npm pack output 14 | *.tgz 15 | *.tsbuildinfo 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Developing guide 2 | 3 | ## Running locally 4 | 5 | ```sh 6 | npm i 7 | npm run dev 8 | ``` 9 | 10 | ## Testing 11 | 12 | ```sh 13 | npm ci 14 | npm run clean 15 | npm run typecheck 16 | npm run lint 17 | npm run test 18 | ``` 19 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "edge-runtime", 6 | typecheck: { 7 | tsconfig: "./tsconfig.test.json", 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["src/**/*.test.*", "./src/test.ts"], 5 | "compilerOptions": { 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals"], 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext" 7 | }, 8 | "include": ["src/**/*", "example/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.2 4 | 5 | - Fix updateSubscriptionQuantity: we now pass in STRIPE_SECRET_KEY explicitly. 6 | component context did not have access to process.env 7 | 8 | ## 0.1.1 9 | 10 | - Update docs 11 | 12 | ## 0.1.0 13 | 14 | - Initial release. 15 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | envDir: "../", 7 | plugins: [react()], 8 | resolve: { 9 | conditions: ["@convex-dev/component-source"], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/component/setup.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { test } from "vitest"; 3 | import schema from "./schema.js"; 4 | import { convexTest } from "convex-test"; 5 | export const modules = import.meta.glob("./**/*.*s"); 6 | 7 | export function initConvexTest() { 8 | const t = convexTest(schema, modules); 9 | return t; 10 | } 11 | test("setup", () => {}); 12 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "skipLibCheck": true, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "react-jsx", 14 | "noEmit": true 15 | }, 16 | "include": ["./src", "vite.config.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | Benji's Store 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /example/convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi, componentsGeneric } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | export const components = componentsGeneric(); 24 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:best-practices"], 4 | "schedule": ["* 0-4 * * 1"], 5 | "timezone": "America/Los_Angeles", 6 | "packageRules": [ 7 | { 8 | "groupName": "Routine updates", 9 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 10 | "prConcurrentLimit": 1, 11 | "automerge": true 12 | }, 13 | { 14 | "groupName": "Major updates", 15 | "matchUpdateTypes": ["major"], 16 | "automerge": false 17 | }, 18 | { 19 | "matchDepTypes": ["devDependencies"], 20 | "automerge": true 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/client/setup.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { test } from "vitest"; 3 | import { convexTest } from "convex-test"; 4 | import { 5 | componentsGeneric, 6 | defineSchema, 7 | type GenericSchema, 8 | type SchemaDefinition, 9 | } from "convex/server"; 10 | import type { ComponentApi } from "../component/_generated/component.js"; 11 | 12 | const modules = import.meta.glob("./**/*.*s"); 13 | 14 | export function initConvexTest< 15 | Schema extends SchemaDefinition, 16 | >(schema?: Schema) { 17 | const t = convexTest(schema ?? defineSchema({}), modules); 18 | return t; 19 | } 20 | 21 | export const components = componentsGeneric() as unknown as { 22 | stripe: ComponentApi; 23 | }; 24 | 25 | test("setup", () => {}); 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "strict": true, 6 | 7 | "target": "ESNext", 8 | "lib": ["ES2021", "dom", "DOM.Iterable"], 9 | "jsx": "react-jsx", 10 | "forceConsistentCasingInFileNames": true, 11 | "allowSyntheticDefaultImports": true, 12 | "noErrorTruncation": true, 13 | // We enforce stricter module resolution for Node16 compatibility 14 | // But when building we use Bundler & ESNext for ESM 15 | "module": "Node16", 16 | "moduleResolution": "NodeNext", 17 | 18 | "composite": true, 19 | "isolatedModules": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "sourceMap": true, 23 | "rootDir": "./src", 24 | "outDir": "./dist", 25 | "verbatimModuleSyntax": true, 26 | "skipLibCheck": true 27 | }, 28 | "include": ["./src/**/*"] 29 | } 30 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ClerkProvider, useAuth } from "@clerk/clerk-react"; 2 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 3 | import { ConvexReactClient } from "convex/react"; 4 | import { StrictMode } from "react"; 5 | import { createRoot } from "react-dom/client"; 6 | import App from "./App.jsx"; 7 | import "./index.css"; 8 | import { Analytics } from "@vercel/analytics/react"; 9 | 10 | const address = import.meta.env.VITE_CONVEX_URL; 11 | 12 | const convex = new ConvexReactClient(address); 13 | 14 | // Import your Publishable Key 15 | const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; 16 | 17 | if (!PUBLISHABLE_KEY) { 18 | throw new Error("Missing Publishable Key"); 19 | } 20 | 21 | createRoot(document.getElementById("root")!).render( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | ); 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and lint 2 | concurrency: 3 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 4 | cancel-in-progress: true 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: ["**"] 11 | 12 | jobs: 13 | check: 14 | name: Test and lint 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | 18 | steps: 19 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 20 | 21 | - name: Node setup 22 | uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 23 | with: 24 | cache-dependency-path: package.json 25 | node-version: "20.x" 26 | cache: "npm" 27 | 28 | - name: Install and build 29 | run: | 30 | npm i 31 | npm run build 32 | - name: Publish package for testing branch 33 | run: npx pkg-pr-new publish || echo "Have you set up pkg-pr-new for this repo?" 34 | - name: Test 35 | run: | 36 | npm run test 37 | npm run typecheck 38 | npm run lint 39 | -------------------------------------------------------------------------------- /example/convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type * as http from "../http.js"; 12 | import type * as stripe from "../stripe.js"; 13 | 14 | import type { 15 | ApiFromModules, 16 | FilterApi, 17 | FunctionReference, 18 | } from "convex/server"; 19 | 20 | declare const fullApi: ApiFromModules<{ 21 | http: typeof http; 22 | stripe: typeof stripe; 23 | }>; 24 | 25 | /** 26 | * A utility for referencing Convex functions in your app's public API. 27 | * 28 | * Usage: 29 | * ```js 30 | * const myFunctionReference = api.myModule.myFunction; 31 | * ``` 32 | */ 33 | export declare const api: FilterApi< 34 | typeof fullApi, 35 | FunctionReference 36 | >; 37 | 38 | /** 39 | * A utility for referencing Convex functions in your app's internal API. 40 | * 41 | * Usage: 42 | * ```js 43 | * const myFunctionReference = internal.myModule.myFunction; 44 | * ``` 45 | */ 46 | export declare const internal: FilterApi< 47 | typeof fullApi, 48 | FunctionReference 49 | >; 50 | 51 | export declare const components: { 52 | stripe: import("@convex-dev/stripe/_generated/component.js").ComponentApi<"stripe">; 53 | }; 54 | -------------------------------------------------------------------------------- /src/component/_generated/api.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type * as private_ from "../private.js"; 12 | import type * as public_ from "../public.js"; 13 | 14 | import type { 15 | ApiFromModules, 16 | FilterApi, 17 | FunctionReference, 18 | } from "convex/server"; 19 | import { anyApi, componentsGeneric } from "convex/server"; 20 | 21 | const fullApi: ApiFromModules<{ 22 | private: typeof private_; 23 | public: typeof public_; 24 | }> = anyApi as any; 25 | 26 | /** 27 | * A utility for referencing Convex functions in your app's public API. 28 | * 29 | * Usage: 30 | * ```js 31 | * const myFunctionReference = api.myModule.myFunction; 32 | * ``` 33 | */ 34 | export const api: FilterApi< 35 | typeof fullApi, 36 | FunctionReference 37 | > = anyApi as any; 38 | 39 | /** 40 | * A utility for referencing Convex functions in your app's internal API. 41 | * 42 | * Usage: 43 | * ```js 44 | * const myFunctionReference = internal.myModule.myFunction; 45 | * ``` 46 | */ 47 | export const internal: FilterApi< 48 | typeof fullApi, 49 | FunctionReference 50 | > = anyApi as any; 51 | 52 | export const components = componentsGeneric() as unknown as {}; 53 | -------------------------------------------------------------------------------- /src/client/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { StripeSubscriptions, registerRoutes } from "./index.js"; 3 | import { components } from "./setup.test.js"; 4 | 5 | describe("StripeSubscriptions client", () => { 6 | test("should create Stripe client with component", async () => { 7 | const client = new StripeSubscriptions(components.stripe); 8 | expect(client).toBeDefined(); 9 | expect(client.component).toBeDefined(); 10 | }); 11 | 12 | test("should accept STRIPE_SECRET_KEY option", async () => { 13 | const client = new StripeSubscriptions(components.stripe, { 14 | STRIPE_SECRET_KEY: "sk_test_123", 15 | }); 16 | expect(client).toBeDefined(); 17 | // The apiKey getter should return the provided key 18 | expect(client.apiKey).toBe("sk_test_123"); 19 | }); 20 | 21 | test("should throw error when accessing apiKey without key set", async () => { 22 | // Clear the environment variable temporarily 23 | const originalKey = process.env.STRIPE_SECRET_KEY; 24 | delete process.env.STRIPE_SECRET_KEY; 25 | 26 | const client = new StripeSubscriptions(components.stripe); 27 | 28 | expect(() => client.apiKey).toThrow( 29 | "STRIPE_SECRET_KEY environment variable is not set" 30 | ); 31 | 32 | // Restore the environment variable 33 | if (originalKey) { 34 | process.env.STRIPE_SECRET_KEY = originalKey; 35 | } 36 | }); 37 | }); 38 | 39 | describe("registerRoutes", () => { 40 | test("registerRoutes function should be exported", () => { 41 | expect(typeof registerRoutes).toBe("function"); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /example/convex/http.ts: -------------------------------------------------------------------------------- 1 | import { httpRouter } from "convex/server"; 2 | import { components } from "./_generated/api"; 3 | import { registerRoutes } from "@convex-dev/stripe"; 4 | 5 | const http = httpRouter(); 6 | 7 | // Register Stripe webhooks with custom event handlers 8 | // Webhook URL: https://.convex.site/stripe/webhook 9 | registerRoutes(http, components.stripe, { 10 | webhookPath: "/stripe/webhook", 11 | events: { 12 | "customer.subscription.updated": async (ctx, event) => { 13 | // Example custom handler: Log subscription updates 14 | const subscription = event.data.object; 15 | console.log("🔔 Custom handler: Subscription updated!", { 16 | id: subscription.id, 17 | status: subscription.status, 18 | }); 19 | 20 | // You can run additional logic here after the default database sync 21 | // For example, send a notification, update other tables, etc. 22 | }, 23 | "payment_intent.succeeded": async (ctx, event) => { 24 | // Example custom handler: Log successful one-time payments 25 | const paymentIntent = event.data.object; 26 | console.log("💰 Custom handler: Payment succeeded!", { 27 | id: paymentIntent.id, 28 | amount: paymentIntent.amount, 29 | }); 30 | }, 31 | }, 32 | onEvent: async (ctx, event) => { 33 | // Log all events for monitoring/debugging 34 | console.log(`📊 Event received: ${event.type}`, { 35 | id: event.id, 36 | created: new Date(event.created * 1000).toISOString(), 37 | }); 38 | 39 | // Example: Send to analytics service 40 | // await ctx.runMutation(internal.analytics.trackEvent, { 41 | // eventType: event.type, 42 | // eventId: event.id, 43 | // }); 44 | 45 | // Example: Update audit log 46 | // await ctx.runMutation(internal.audit.logWebhookEvent, { 47 | // eventType: event.type, 48 | // eventData: event.data, 49 | // }); 50 | }, 51 | }); 52 | 53 | export default http; 54 | -------------------------------------------------------------------------------- /src/component/_generated/dataModel.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /example/convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /src/client/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | HttpRouter, 3 | GenericActionCtx, 4 | GenericMutationCtx, 5 | GenericDataModel, 6 | GenericQueryCtx, 7 | } from "convex/server"; 8 | import type Stripe from "stripe"; 9 | 10 | // Type utils follow 11 | 12 | export type QueryCtx = Pick, "runQuery">; 13 | export type MutationCtx = Pick< 14 | GenericMutationCtx, 15 | "runQuery" | "runMutation" 16 | >; 17 | export type ActionCtx = Pick< 18 | GenericActionCtx, 19 | "runQuery" | "runMutation" | "runAction" 20 | >; 21 | 22 | // Webhook Event Handler Types 23 | 24 | /** 25 | * Handler function for a specific Stripe webhook event. 26 | * Receives the action context and the full Stripe event object. 27 | */ 28 | export type StripeEventHandler< 29 | T extends Stripe.Event.Type = Stripe.Event.Type, 30 | > = ( 31 | ctx: GenericActionCtx, 32 | event: Stripe.Event & { type: T }, 33 | ) => Promise; 34 | 35 | /** 36 | * Map of event types to their handlers. 37 | * Users can provide handlers for any Stripe webhook event type. 38 | */ 39 | export type StripeEventHandlers = { 40 | [K in Stripe.Event.Type]?: StripeEventHandler; 41 | }; 42 | 43 | /** 44 | * Configuration for webhook registration. 45 | */ 46 | export type RegisterRoutesConfig = { 47 | /** 48 | * Optional webhook path. Defaults to "/stripe/webhook" 49 | */ 50 | webhookPath?: string; 51 | 52 | /** 53 | * Optional event handlers that run after default processing. 54 | * The component will handle database syncing automatically, 55 | * and then call your custom handlers. 56 | */ 57 | events?: StripeEventHandlers; 58 | 59 | /** 60 | * Optional generic event handler that runs for all events. 61 | * This runs after default processing and before specific event handlers. 62 | */ 63 | onEvent?: StripeEventHandler; 64 | /** 65 | * Stripe webhook secret for signature verification. 66 | * Defaults to process.env.STRIPE_WEBHOOK_SECRET 67 | */ 68 | STRIPE_WEBHOOK_SECRET?: string; 69 | 70 | /** 71 | * Stripe secret key for API calls. 72 | * Defaults to process.env.STRIPE_SECRET_KEY 73 | */ 74 | STRIPE_SECRET_KEY?: string; 75 | }; 76 | 77 | /** 78 | * Type for the HttpRouter to be used in registerRoutes 79 | */ 80 | export type { HttpRouter }; 81 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import reactHooks from "eslint-plugin-react-hooks"; 5 | import reactRefresh from "eslint-plugin-react-refresh"; 6 | 7 | export default [ 8 | { 9 | ignores: [ 10 | "dist/**", 11 | "example/dist/**", 12 | "*.config.{js,mjs,cjs,ts,tsx}", 13 | "example/**/*.config.{js,mjs,cjs,ts,tsx}", 14 | "**/_generated/", 15 | "initTemplate.mjs", 16 | ], 17 | }, 18 | { 19 | files: ["src/**/*.{js,mjs,cjs,ts,tsx}", "example/**/*.{js,mjs,cjs,ts,tsx}"], 20 | languageOptions: { 21 | parser: tseslint.parser, 22 | parserOptions: { 23 | project: [ 24 | "./tsconfig.json", 25 | "./example/tsconfig.json", 26 | "./example/convex/tsconfig.json", 27 | ], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }, 32 | pluginJs.configs.recommended, 33 | ...tseslint.configs.recommended, 34 | // Convex code - Worker environment 35 | { 36 | files: ["src/**/*.{ts,tsx}", "example/convex/**/*.{ts,tsx}"], 37 | ignores: ["src/react/**"], 38 | languageOptions: { 39 | globals: globals.worker, 40 | }, 41 | rules: { 42 | "@typescript-eslint/no-floating-promises": "error", 43 | "@typescript-eslint/no-explicit-any": "off", 44 | "no-unused-vars": "off", 45 | "@typescript-eslint/no-unused-vars": [ 46 | "warn", 47 | { 48 | argsIgnorePattern: "^_", 49 | varsIgnorePattern: "^_", 50 | }, 51 | ], 52 | "@typescript-eslint/no-unused-expressions": [ 53 | "error", 54 | { 55 | allowShortCircuit: true, 56 | allowTernary: true, 57 | allowTaggedTemplates: true, 58 | }, 59 | ], 60 | }, 61 | }, 62 | // React app code - Browser environment 63 | { 64 | files: ["src/react/**/*.{ts,tsx}", "example/src/**/*.{ts,tsx}"], 65 | languageOptions: { 66 | ecmaVersion: 2020, 67 | globals: globals.browser, 68 | }, 69 | plugins: { 70 | "react-hooks": reactHooks, 71 | "react-refresh": reactRefresh, 72 | }, 73 | rules: { 74 | ...reactHooks.configs.recommended.rules, 75 | "react-refresh/only-export-components": [ 76 | "warn", 77 | { allowConstantExport: true }, 78 | ], 79 | "@typescript-eslint/no-explicit-any": "off", 80 | "no-unused-vars": "off", 81 | "@typescript-eslint/no-unused-vars": [ 82 | "warn", 83 | { 84 | argsIgnorePattern: "^_", 85 | varsIgnorePattern: "^_", 86 | }, 87 | ], 88 | }, 89 | }, 90 | ]; 91 | -------------------------------------------------------------------------------- /src/component/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | export default defineSchema({ 5 | customers: defineTable({ 6 | stripeCustomerId: v.string(), 7 | email: v.optional(v.string()), 8 | name: v.optional(v.string()), 9 | metadata: v.optional(v.any()), 10 | }) 11 | .index("by_stripe_customer_id", ["stripeCustomerId"]) 12 | .index("by_email", ["email"]), 13 | subscriptions: defineTable({ 14 | stripeSubscriptionId: v.string(), 15 | stripeCustomerId: v.string(), 16 | status: v.string(), 17 | currentPeriodEnd: v.number(), 18 | cancelAtPeriodEnd: v.boolean(), 19 | quantity: v.optional(v.number()), 20 | priceId: v.string(), 21 | metadata: v.optional(v.any()), 22 | // Custom lookup fields for efficient querying 23 | orgId: v.optional(v.string()), 24 | userId: v.optional(v.string()), 25 | }) 26 | .index("by_stripe_subscription_id", ["stripeSubscriptionId"]) 27 | .index("by_stripe_customer_id", ["stripeCustomerId"]) 28 | .index("by_org_id", ["orgId"]) 29 | .index("by_user_id", ["userId"]), 30 | checkout_sessions: defineTable({ 31 | stripeCheckoutSessionId: v.string(), 32 | stripeCustomerId: v.optional(v.string()), 33 | status: v.string(), 34 | mode: v.string(), 35 | metadata: v.optional(v.any()), 36 | }).index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"]), 37 | payments: defineTable({ 38 | stripePaymentIntentId: v.string(), 39 | stripeCustomerId: v.optional(v.string()), 40 | amount: v.number(), 41 | currency: v.string(), 42 | status: v.string(), 43 | created: v.number(), 44 | metadata: v.optional(v.any()), 45 | // Custom lookup fields for efficient querying 46 | orgId: v.optional(v.string()), 47 | userId: v.optional(v.string()), 48 | }) 49 | .index("by_stripe_payment_intent_id", ["stripePaymentIntentId"]) 50 | .index("by_stripe_customer_id", ["stripeCustomerId"]) 51 | .index("by_org_id", ["orgId"]) 52 | .index("by_user_id", ["userId"]), 53 | invoices: defineTable({ 54 | stripeInvoiceId: v.string(), 55 | stripeCustomerId: v.string(), 56 | stripeSubscriptionId: v.optional(v.string()), 57 | status: v.string(), 58 | amountDue: v.number(), 59 | amountPaid: v.number(), 60 | created: v.number(), 61 | // Custom lookup fields for efficient querying 62 | orgId: v.optional(v.string()), 63 | userId: v.optional(v.string()), 64 | }) 65 | .index("by_stripe_invoice_id", ["stripeInvoiceId"]) 66 | .index("by_stripe_customer_id", ["stripeCustomerId"]) 67 | .index("by_stripe_subscription_id", ["stripeSubscriptionId"]) 68 | .index("by_org_id", ["orgId"]) 69 | .index("by_user_id", ["userId"]), 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convex-dev/stripe", 3 | "description": "A stripe component for Convex.", 4 | "repository": "github:get-convex/stripe", 5 | "homepage": "https://github.com/get-convex/stripe#readme", 6 | "bugs": { 7 | "url": "https://github.com/get-convex/stripe/issues" 8 | }, 9 | "version": "0.1.2", 10 | "license": "Apache-2.0", 11 | "keywords": [ 12 | "convex", 13 | "component" 14 | ], 15 | "type": "module", 16 | "scripts": { 17 | "dev": "run-p -r 'dev:*'", 18 | "dev:backend": "convex dev --typecheck-components", 19 | "dev:frontend": "cd example && vite --clearScreen false", 20 | "dev:build": "chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.ts' -c 'npx convex codegen --component-dir ./src/component && npm run build' --initial", 21 | "predev": "path-exists .env.local || (npm run build && convex dev --skip-push)", 22 | "clean": "rm -rf dist *.tsbuildinfo", 23 | "build": "tsc --project ./tsconfig.build.json", 24 | "typecheck": "tsc --noEmit && tsc -p example && tsc -p example/convex", 25 | "lint": "eslint .", 26 | "all": "run-p -r 'dev:*' 'test:watch'", 27 | "test": "vitest run --typecheck", 28 | "test:watch": "vitest --typecheck --clearScreen false", 29 | "test:debug": "vitest --inspect-brk --no-file-parallelism", 30 | "test:coverage": "vitest run --coverage --coverage.reporter=text", 31 | "prepublishOnly": "npm run clean && npm run build", 32 | "preversion": "npm run clean && npm ci && npm run build && run-p test lint typecheck", 33 | "alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags", 34 | "release": "npm version patch && npm publish && git push --follow-tags", 35 | "version": "vim -c 'normal o' -c 'normal o## '$npm_package_version CHANGELOG.md && prettier -w CHANGELOG.md && git add CHANGELOG.md" 36 | }, 37 | "files": [ 38 | "dist", 39 | "src" 40 | ], 41 | "exports": { 42 | "./package.json": "./package.json", 43 | ".": { 44 | "types": "./dist/client/index.d.ts", 45 | "default": "./dist/client/index.js" 46 | }, 47 | "./react": { 48 | "types": "./dist/react/index.d.ts", 49 | "default": "./dist/react/index.js" 50 | }, 51 | "./test": "./src/test.ts", 52 | "./_generated/component.js": { 53 | "types": "./dist/component/_generated/component.d.ts" 54 | }, 55 | "./convex.config.js": { 56 | "types": "./dist/component/convex.config.d.ts", 57 | "default": "./dist/component/convex.config.js" 58 | } 59 | }, 60 | "peerDependencies": { 61 | "convex": "^1.29.3", 62 | "react": "^18.3.1 || ^19.0.0" 63 | }, 64 | "devDependencies": { 65 | "@clerk/clerk-react": "^5.57.0", 66 | "@convex-dev/eslint-plugin": "^1.0.0", 67 | "@edge-runtime/vm": "^5.0.0", 68 | "@eslint/eslintrc": "^3.3.1", 69 | "@eslint/js": "9.39.1", 70 | "@types/node": "^20.19.25", 71 | "@types/react": "^18.3.27", 72 | "@types/react-dom": "^18.3.7", 73 | "@vercel/analytics": "^1.5.0", 74 | "@vitejs/plugin-react": "^5.1.1", 75 | "chokidar-cli": "3.0.0", 76 | "convex": "1.29.3", 77 | "convex-test": "0.0.40", 78 | "cpy-cli": "^6.0.0", 79 | "eslint": "9.39.1", 80 | "eslint-plugin-react": "^7.37.5", 81 | "eslint-plugin-react-hooks": "^5.2.0", 82 | "eslint-plugin-react-refresh": "^0.4.24", 83 | "globals": "^16.5.0", 84 | "npm-run-all2": "8.0.4", 85 | "path-exists-cli": "2.0.0", 86 | "pkg-pr-new": "^0.0.60", 87 | "prettier": "3.6.2", 88 | "react": "^18.3.1", 89 | "react-dom": "^18.3.1", 90 | "typescript": "5.9.3", 91 | "typescript-eslint": "8.47.0", 92 | "vite": "6.4.1", 93 | "vitest": "3.2.4" 94 | }, 95 | "types": "./dist/client/index.d.ts", 96 | "module": "./dist/client/index.js", 97 | "dependencies": { 98 | "stripe": "^20.0.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /example/convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | } from "convex/server"; 20 | 21 | /** 22 | * Define a query in this Convex app's public API. 23 | * 24 | * This function will be allowed to read your Convex database and will be accessible from the client. 25 | * 26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 28 | */ 29 | export const query = queryGeneric; 30 | 31 | /** 32 | * Define a query that is only accessible from other Convex functions (but not from the client). 33 | * 34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export const internalQuery = internalQueryGeneric; 40 | 41 | /** 42 | * Define a mutation in this Convex app's public API. 43 | * 44 | * This function will be allowed to modify your Convex database and will be accessible from the client. 45 | * 46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export const mutation = mutationGeneric; 50 | 51 | /** 52 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 53 | * 54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export const internalMutation = internalMutationGeneric; 60 | 61 | /** 62 | * Define an action in this Convex app's public API. 63 | * 64 | * An action is a function which can execute any JavaScript code, including non-deterministic 65 | * code and code with side-effects, like calling third-party services. 66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 68 | * 69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const action = actionGeneric; 73 | 74 | /** 75 | * Define an action that is only accessible from other Convex functions (but not from the client). 76 | * 77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 79 | */ 80 | export const internalAction = internalActionGeneric; 81 | 82 | /** 83 | * Define an HTTP action. 84 | * 85 | * The wrapped function will be used to respond to HTTP requests received 86 | * by a Convex deployment if the requests matches the path and method where 87 | * this action is routed. Be sure to route your httpAction in `convex/http.js`. 88 | * 89 | * @param func - The function. It receives an {@link ActionCtx} as its first argument 90 | * and a Fetch API `Request` object as its second. 91 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 92 | */ 93 | export const httpAction = httpActionGeneric; 94 | -------------------------------------------------------------------------------- /example/convex/README.md: -------------------------------------------------------------------------------- 1 | # Convex Functions - Stripe Integration 2 | 3 | This directory contains the Convex backend functions for Benji's Store, 4 | demonstrating the `@convex-dev/stripe` component. 5 | 6 | ## Files 7 | 8 | | File | Purpose | 9 | | ------------------ | ----------------------------------------- | 10 | | `convex.config.ts` | Installs the @convex-dev/stripe component | 11 | | `auth.config.ts` | Configures Clerk authentication | 12 | | `http.ts` | Registers Stripe webhook routes | 13 | | `schema.ts` | Database schema (app-specific tables) | 14 | | `stripe.ts` | Stripe actions and queries | 15 | 16 | ## Quick Reference 17 | 18 | ### Setting Up the Component 19 | 20 | ```typescript 21 | // convex/convex.config.ts 22 | import { defineApp } from "convex/server"; 23 | import stripe from "@convex-dev/stripe/convex.config.js"; 24 | 25 | const app = defineApp(); 26 | app.use(stripe); 27 | 28 | export default app; 29 | ``` 30 | 31 | ### Creating the Client 32 | 33 | ```typescript 34 | // convex/stripe.ts 35 | import { components } from "./_generated/api"; 36 | import { StripeSubscriptions } from "@convex-dev/stripe"; 37 | 38 | const stripeClient = new StripeSubscriptions(components.stripe, {}); 39 | ``` 40 | 41 | ### Checkout Sessions 42 | 43 | ```typescript 44 | // Subscription checkout 45 | await stripeClient.createCheckoutSession(ctx, { 46 | priceId: "price_...", 47 | customerId: "cus_...", 48 | mode: "subscription", 49 | successUrl: "https://example.com/success", 50 | cancelUrl: "https://example.com/cancel", 51 | subscriptionMetadata: { userId: "user_123" }, 52 | }); 53 | 54 | // One-time payment checkout 55 | await stripeClient.createCheckoutSession(ctx, { 56 | priceId: "price_...", 57 | customerId: "cus_...", 58 | mode: "payment", 59 | successUrl: "https://example.com/success", 60 | cancelUrl: "https://example.com/cancel", 61 | paymentIntentMetadata: { userId: "user_123" }, 62 | }); 63 | ``` 64 | 65 | ### Customer Management 66 | 67 | ```typescript 68 | // Get or create a customer 69 | const customer = await stripeClient.getOrCreateCustomer(ctx, { 70 | userId: identity.subject, 71 | email: identity.email, 72 | name: identity.name, 73 | }); 74 | 75 | // Create customer portal session 76 | const portal = await stripeClient.createCustomerPortalSession(ctx, { 77 | customerId: "cus_...", 78 | returnUrl: "https://example.com/profile", 79 | }); 80 | ``` 81 | 82 | ### Subscription Management 83 | 84 | ```typescript 85 | // Cancel subscription (at period end) 86 | await stripeClient.cancelSubscription(ctx, { 87 | stripeSubscriptionId: "sub_...", 88 | cancelAtPeriodEnd: true, // false = cancel immediately 89 | }); 90 | 91 | // Reactivate a subscription set to cancel 92 | await stripeClient.reactivateSubscription(ctx, { 93 | stripeSubscriptionId: "sub_...", 94 | }); 95 | 96 | // Update seat count 97 | await stripeClient.updateSubscriptionQuantity(ctx, { 98 | stripeSubscriptionId: "sub_...", 99 | quantity: 5, 100 | }); 101 | ``` 102 | 103 | ### Querying Data 104 | 105 | ```typescript 106 | // List subscriptions for a user 107 | const subs = await ctx.runQuery( 108 | components.stripe.public.listSubscriptionsByUserId, 109 | { userId: "user_123" }, 110 | ); 111 | 112 | // List payments for a user 113 | const payments = await ctx.runQuery( 114 | components.stripe.public.listPaymentsByUserId, 115 | { userId: "user_123" }, 116 | ); 117 | 118 | // Get subscription by ID 119 | const sub = await ctx.runQuery(components.stripe.public.getSubscription, { 120 | stripeSubscriptionId: "sub_...", 121 | }); 122 | 123 | // List invoices for a customer 124 | const invoices = await ctx.runQuery(components.stripe.public.listInvoices, { 125 | stripeCustomerId: "cus_...", 126 | }); 127 | 128 | // List invoices for an organization 129 | const orgInvoices = await ctx.runQuery( 130 | components.stripe.public.listInvoicesByOrgId, 131 | { orgId: "org_..." }, 132 | ); 133 | ``` 134 | 135 | ### Webhook Handling 136 | 137 | ```typescript 138 | // convex/http.ts 139 | import { httpRouter } from "convex/server"; 140 | import { components } from "./_generated/api"; 141 | import { registerRoutes } from "@convex-dev/stripe"; 142 | 143 | const http = httpRouter(); 144 | 145 | // Webhook URL will be: https://.convex.site/stripe/webhook 146 | registerRoutes(http, components.stripe, { 147 | webhookPath: "/stripe/webhook", 148 | // Custom handlers for specific events 149 | events: { 150 | "customer.subscription.updated": async (ctx, event) => { 151 | console.log("Subscription updated:", event.data.object.id); 152 | }, 153 | }, 154 | // Called for ALL events 155 | onEvent: async (ctx, event) => { 156 | console.log("Event received:", event.type); 157 | }, 158 | }); 159 | 160 | export default http; 161 | ``` 162 | 163 | ## Environment Variables 164 | 165 | Set these in [Convex Dashboard](https://dashboard.convex.dev) → Settings → 166 | Environment Variables: 167 | 168 | ``` 169 | STRIPE_SECRET_KEY=sk_test_... 170 | STRIPE_WEBHOOK_SECRET=whsec_... 171 | APP_URL=http://localhost:5173 172 | ``` 173 | 174 | The `APP_URL` is used for Stripe checkout success/cancel redirects. 175 | 176 | ## Component Tables 177 | 178 | The `@convex-dev/stripe` component manages these tables: 179 | 180 | - `stripe.customers` - Stripe customer records 181 | - `stripe.subscriptions` - Subscription records with user/org linking 182 | - `stripe.payments` - One-time payment records 183 | - `stripe.invoices` - Invoice records 184 | 185 | Access via `components.stripe.public.*` queries. 186 | 187 | ## Learn More 188 | 189 | - [Component README](../../README.md) 190 | - [Example App README](../README.md) 191 | - [Convex Docs](https://docs.convex.dev) 192 | - [Stripe Docs](https://stripe.com/docs) 193 | -------------------------------------------------------------------------------- /example/convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | ActionBuilder, 13 | HttpActionBuilder, 14 | MutationBuilder, 15 | QueryBuilder, 16 | GenericActionCtx, 17 | GenericMutationCtx, 18 | GenericQueryCtx, 19 | GenericDatabaseReader, 20 | GenericDatabaseWriter, 21 | } from "convex/server"; 22 | import type { DataModel } from "./dataModel.js"; 23 | 24 | /** 25 | * Define a query in this Convex app's public API. 26 | * 27 | * This function will be allowed to read your Convex database and will be accessible from the client. 28 | * 29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 31 | */ 32 | export declare const query: QueryBuilder; 33 | 34 | /** 35 | * Define a query that is only accessible from other Convex functions (but not from the client). 36 | * 37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 38 | * 39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 41 | */ 42 | export declare const internalQuery: QueryBuilder; 43 | 44 | /** 45 | * Define a mutation in this Convex app's public API. 46 | * 47 | * This function will be allowed to modify your Convex database and will be accessible from the client. 48 | * 49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 51 | */ 52 | export declare const mutation: MutationBuilder; 53 | 54 | /** 55 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 56 | * 57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 58 | * 59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 61 | */ 62 | export declare const internalMutation: MutationBuilder; 63 | 64 | /** 65 | * Define an action in this Convex app's public API. 66 | * 67 | * An action is a function which can execute any JavaScript code, including non-deterministic 68 | * code and code with side-effects, like calling third-party services. 69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 71 | * 72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 74 | */ 75 | export declare const action: ActionBuilder; 76 | 77 | /** 78 | * Define an action that is only accessible from other Convex functions (but not from the client). 79 | * 80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 82 | */ 83 | export declare const internalAction: ActionBuilder; 84 | 85 | /** 86 | * Define an HTTP action. 87 | * 88 | * The wrapped function will be used to respond to HTTP requests received 89 | * by a Convex deployment if the requests matches the path and method where 90 | * this action is routed. Be sure to route your httpAction in `convex/http.js`. 91 | * 92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument 93 | * and a Fetch API `Request` object as its second. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /src/component/_generated/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | ActionBuilder, 13 | HttpActionBuilder, 14 | MutationBuilder, 15 | QueryBuilder, 16 | GenericActionCtx, 17 | GenericMutationCtx, 18 | GenericQueryCtx, 19 | GenericDatabaseReader, 20 | GenericDatabaseWriter, 21 | } from "convex/server"; 22 | import { 23 | actionGeneric, 24 | httpActionGeneric, 25 | queryGeneric, 26 | mutationGeneric, 27 | internalActionGeneric, 28 | internalMutationGeneric, 29 | internalQueryGeneric, 30 | } from "convex/server"; 31 | import type { DataModel } from "./dataModel.js"; 32 | 33 | /** 34 | * Define a query in this Convex app's public API. 35 | * 36 | * This function will be allowed to read your Convex database and will be accessible from the client. 37 | * 38 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 39 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 40 | */ 41 | export const query: QueryBuilder = queryGeneric; 42 | 43 | /** 44 | * Define a query that is only accessible from other Convex functions (but not from the client). 45 | * 46 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 47 | * 48 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 49 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 50 | */ 51 | export const internalQuery: QueryBuilder = 52 | internalQueryGeneric; 53 | 54 | /** 55 | * Define a mutation in this Convex app's public API. 56 | * 57 | * This function will be allowed to modify your Convex database and will be accessible from the client. 58 | * 59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 61 | */ 62 | export const mutation: MutationBuilder = mutationGeneric; 63 | 64 | /** 65 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 66 | * 67 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 68 | * 69 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 70 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const internalMutation: MutationBuilder = 73 | internalMutationGeneric; 74 | 75 | /** 76 | * Define an action in this Convex app's public API. 77 | * 78 | * An action is a function which can execute any JavaScript code, including non-deterministic 79 | * code and code with side-effects, like calling third-party services. 80 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 81 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 82 | * 83 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 84 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 85 | */ 86 | export const action: ActionBuilder = actionGeneric; 87 | 88 | /** 89 | * Define an action that is only accessible from other Convex functions (but not from the client). 90 | * 91 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 92 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 93 | */ 94 | export const internalAction: ActionBuilder = 95 | internalActionGeneric; 96 | 97 | /** 98 | * Define an HTTP action. 99 | * 100 | * The wrapped function will be used to respond to HTTP requests received 101 | * by a Convex deployment if the requests matches the path and method where 102 | * this action is routed. Be sure to route your httpAction in `convex/http.js`. 103 | * 104 | * @param func - The function. It receives an {@link ActionCtx} as its first argument 105 | * and a Fetch API `Request` object as its second. 106 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 107 | */ 108 | export const httpAction: HttpActionBuilder = httpActionGeneric; 109 | 110 | type GenericCtx = 111 | | GenericActionCtx 112 | | GenericMutationCtx 113 | | GenericQueryCtx; 114 | 115 | /** 116 | * A set of services for use within Convex query functions. 117 | * 118 | * The query context is passed as the first argument to any Convex query 119 | * function run on the server. 120 | * 121 | * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead. 122 | */ 123 | export type QueryCtx = GenericQueryCtx; 124 | 125 | /** 126 | * A set of services for use within Convex mutation functions. 127 | * 128 | * The mutation context is passed as the first argument to any Convex mutation 129 | * function run on the server. 130 | * 131 | * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead. 132 | */ 133 | export type MutationCtx = GenericMutationCtx; 134 | 135 | /** 136 | * A set of services for use within Convex action functions. 137 | * 138 | * The action context is passed as the first argument to any Convex action 139 | * function run on the server. 140 | */ 141 | export type ActionCtx = GenericActionCtx; 142 | 143 | /** 144 | * An interface to read from the database within Convex query functions. 145 | * 146 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 147 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 148 | * building a query. 149 | */ 150 | export type DatabaseReader = GenericDatabaseReader; 151 | 152 | /** 153 | * An interface to read from and write to the database within Convex mutation 154 | * functions. 155 | * 156 | * Convex guarantees that all writes within a single mutation are 157 | * executed atomically, so you never have to worry about partial writes leaving 158 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 159 | * for the guarantees Convex provides your functions. 160 | */ 161 | export type DatabaseWriter = GenericDatabaseWriter; 162 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Benji's Store - Example App 2 | 3 | A complete example app demonstrating the `@convex-dev/stripe` component with Clerk 4 | authentication. 5 | 6 | ![Benji's Store Screenshot](https://via.placeholder.com/800x400?text=Benji%27s+Store) 7 | 8 | ## Features Demonstrated 9 | 10 | - ✅ One-time payments (Buy a Hat) 11 | - ✅ Subscriptions (Hat of the Month Club) 12 | - ✅ User profile with order history 13 | - ✅ Subscription management (cancel, update seats) 14 | - ✅ Customer portal integration 15 | - ✅ Team/organization billing 16 | - ✅ Failed payment handling 17 | - ✅ Real-time data sync via webhooks 18 | 19 | ## Prerequisites 20 | 21 | - Node.js 18+ 22 | - A [Convex](https://convex.dev) account 23 | - A [Stripe](https://stripe.com) account (test mode) 24 | - A [Clerk](https://clerk.com) account 25 | 26 | ## Setup 27 | 28 | ### 1. Clone and Install 29 | 30 | ```bash 31 | git clone https://github.com/get-convex/convex-stripe 32 | cd convex-stripe 33 | npm install 34 | ``` 35 | 36 | ### 2. Configure Clerk 37 | 38 | 1. Create a new application at [clerk.com](https://dashboard.clerk.com) 39 | 2. Copy your **Publishable Key** from the Clerk dashboard 40 | 3. Note your Clerk domain (e.g., `your-app-name.clerk.accounts.dev`) 41 | 42 | ### 3. Configure Stripe 43 | 44 | 1. Go to 45 | [Stripe Dashboard → Developers → API Keys](https://dashboard.stripe.com/test/apikeys) 46 | 2. Copy your **Secret Key** (`sk_test_...`) 47 | 48 | 3. Create two products in 49 | [Stripe Dashboard → Products](https://dashboard.stripe.com/test/products): 50 | 51 | **Product 1: Single Hat** 52 | - Name: "Premium Hat" 53 | - One-time payment: $49.00 54 | - Copy the Price ID (`price_...`) 55 | 56 | **Product 2: Hat Subscription** 57 | - Name: "Hat of the Month Club" 58 | - Recurring: $29.00/month 59 | - Copy the Price ID (`price_...`) 60 | 61 | 4. Create `.env.local` in the project root with all frontend variables: 62 | 63 | ```env 64 | # Clerk 65 | VITE_CLERK_PUBLISHABLE_KEY=pk_test_... 66 | 67 | # Stripe Price IDs (from Stripe Dashboard → Products) 68 | VITE_STRIPE_ONE_TIME_PRICE_ID=price_... 69 | VITE_STRIPE_SUBSCRIPTION_PRICE_ID=price_... 70 | ``` 71 | 72 | ### 4. Configure Convex 73 | 74 | 1. Start the development server (this will prompt you to set up Convex): 75 | 76 | ```bash 77 | npm run dev 78 | ``` 79 | 80 | 2. Add environment variables in [Convex Dashboard](https://dashboard.convex.dev) 81 | → Settings → Environment Variables: 82 | 83 | | Variable | Value | 84 | | ----------------------- | ------------------------------------------------ | 85 | | `STRIPE_SECRET_KEY` | `sk_test_...` (from Stripe) | 86 | | `STRIPE_WEBHOOK_SECRET` | `whsec_...` (from Step 5) | 87 | | `APP_URL` | `http://localhost:5173` (or your production URL) | 88 | 89 | 3. Create `example/convex/auth.config.ts`: 90 | 91 | ```typescript 92 | export default { 93 | providers: [ 94 | { 95 | domain: "https://your-app-name.clerk.accounts.dev", 96 | applicationID: "convex", 97 | }, 98 | ], 99 | }; 100 | ``` 101 | 102 | 4. Push the auth config: 103 | 104 | ```bash 105 | npx convex dev --once 106 | ``` 107 | 108 | ### 5. Configure Stripe Webhooks 109 | 110 | 1. Go to 111 | [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/test/webhooks) 112 | 113 | 2. Click **"Add endpoint"** 114 | 115 | 3. Enter your Convex webhook URL: 116 | 117 | ``` 118 | https://YOUR_CONVEX_DEPLOYMENT.convex.site/stripe/webhook 119 | ``` 120 | 121 | Find your deployment name in the Convex dashboard. It's the part before 122 | `.convex.cloud` in your URL: 123 | 124 | ``` 125 | VITE_CONVEX_URL=https://YOUR_CONVEX_DEPLOYMENT.convex.cloud 126 | # Webhook URL uses .convex.site instead: YOUR_CONVEX_DEPLOYMENT.convex.site/stripe/webhook 127 | ``` 128 | 129 | 4. Select these events: 130 | - `customer.created` 131 | - `customer.updated` 132 | - `customer.subscription.created` 133 | - `customer.subscription.updated` 134 | - `customer.subscription.deleted` 135 | - `payment_intent.succeeded` 136 | - `payment_intent.payment_failed` 137 | - `invoice.created` 138 | - `invoice.finalized` 139 | - `invoice.paid` 140 | - `invoice.payment_failed` 141 | - `checkout.session.completed` 142 | 143 | 5. Click **"Add endpoint"** 144 | 145 | 6. Click on your endpoint and copy the **Signing secret** (`whsec_...`) 146 | 147 | 7. Add it to Convex environment variables as `STRIPE_WEBHOOK_SECRET` 148 | 149 | ### 6. Run the App 150 | 151 | ```bash 152 | npm run dev 153 | ``` 154 | 155 | Open [http://localhost:5173](http://localhost:5173) 156 | 157 | ## Testing Payments 158 | 159 | Use these [Stripe test cards](https://stripe.com/docs/testing): 160 | 161 | | Scenario | Card Number | 162 | | ----------------------- | --------------------- | 163 | | Successful payment | `4242 4242 4242 4242` | 164 | | Declined | `4000 0000 0000 0002` | 165 | | Requires authentication | `4000 0025 0000 3155` | 166 | | Insufficient funds | `4000 0000 0000 9995` | 167 | 168 | Use any future expiration date and any 3-digit CVC. 169 | 170 | ## Project Structure 171 | 172 | ``` 173 | example/ 174 | ├── src/ 175 | │ ├── App.tsx # Main React app with all pages 176 | │ ├── main.tsx # Entry point with Clerk/Convex providers 177 | │ └── index.css # Styling 178 | └── convex/ 179 | ├── auth.config.ts # Clerk authentication config 180 | ├── convex.config.ts # Component installation 181 | ├── http.ts # Webhook route registration 182 | ├── schema.ts # App schema (extends component) 183 | └── stripe.ts # Stripe actions and queries 184 | ``` 185 | 186 | ## Key Files 187 | 188 | ### `convex/stripe.ts` 189 | 190 | Contains all the Stripe integration logic: 191 | 192 | - `createSubscriptionCheckout` - Create subscription checkout 193 | - `createPaymentCheckout` - Create one-time payment checkout 194 | - `createTeamSubscriptionCheckout` - Create team/org subscription checkout 195 | - `cancelSubscription` - Cancel a subscription 196 | - `reactivateSubscription` - Reactivate a canceled subscription 197 | - `updateSeats` - Update subscription quantity 198 | - `getCustomerPortalUrl` - Get customer portal URL 199 | - `getUserSubscriptions` - List user's subscriptions 200 | - `getUserPayments` - List user's payments 201 | - `getOrgSubscription` - Get org's subscription 202 | - `getOrgInvoices` - List org's invoices 203 | 204 | ### `convex/http.ts` 205 | 206 | Registers the Stripe webhook handler with optional custom event handlers. 207 | 208 | ### `src/App.tsx` 209 | 210 | React app with four pages: 211 | 212 | - **Home** - Landing page with product showcase 213 | - **Store** - Product cards with purchase buttons (single-user subscriptions) 214 | - **Profile** - Order history and subscription management 215 | - **Team** - Team/organization billing with seat-based subscriptions 216 | 217 | ## Troubleshooting 218 | 219 | ### "Not authenticated" error 220 | 221 | 1. Make sure Clerk is configured in `.env.local` 222 | 2. Create `convex/auth.config.ts` with your Clerk domain 223 | 3. Run `npx convex dev --once` to push the config 224 | 225 | ### Webhooks not working 226 | 227 | 1. Check the webhook URL matches your Convex deployment 228 | 2. Verify all required events are selected 229 | 3. Check `STRIPE_WEBHOOK_SECRET` is set correctly 230 | 4. Look at Stripe webhook logs for delivery status 231 | 232 | ### Tables empty after purchase 233 | 234 | 1. Ensure `invoice.created` and `invoice.finalized` events are enabled 235 | 2. Check Convex logs for webhook processing errors 236 | 3. Verify `STRIPE_SECRET_KEY` is set 237 | 238 | ### Build errors 239 | 240 | ```bash 241 | # Rebuild the component 242 | npm run build 243 | 244 | # Re-sync Convex 245 | npx convex dev --once 246 | ``` 247 | 248 | ## Learn More 249 | 250 | - [Convex Documentation](https://docs.convex.dev) 251 | - [Stripe Documentation](https://stripe.com/docs) 252 | - [Clerk Documentation](https://clerk.com/docs) 253 | - [@convex-dev/stripe Component](../README.md) 254 | -------------------------------------------------------------------------------- /src/component/public.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { action, mutation, query } from "./_generated/server.js"; 3 | import { api } from "./_generated/api.js"; 4 | import schema from "./schema.js"; 5 | import StripeSDK from "stripe"; 6 | 7 | // ============================================================================ 8 | // VALIDATOR HELPERS 9 | // ============================================================================ 10 | 11 | // Reusable validators that omit system fields (_id, _creationTime) 12 | const customerValidator = schema.tables.customers.validator; 13 | const subscriptionValidator = schema.tables.subscriptions.validator; 14 | const paymentValidator = schema.tables.payments.validator; 15 | const invoiceValidator = schema.tables.invoices.validator; 16 | 17 | // ============================================================================ 18 | // PUBLIC QUERIES 19 | // ============================================================================ 20 | 21 | /** 22 | * Get a customer by their Stripe customer ID. 23 | */ 24 | export const getCustomer = query({ 25 | args: { stripeCustomerId: v.string() }, 26 | returns: v.union(customerValidator, v.null()), 27 | handler: async (ctx, args) => { 28 | const customer = await ctx.db 29 | .query("customers") 30 | .withIndex("by_stripe_customer_id", (q) => 31 | q.eq("stripeCustomerId", args.stripeCustomerId), 32 | ) 33 | .unique(); 34 | if (!customer) return null; 35 | const { _id, _creationTime, ...data } = customer; 36 | return data; 37 | }, 38 | }); 39 | 40 | /** 41 | * Get a subscription by its Stripe subscription ID. 42 | */ 43 | export const getSubscription = query({ 44 | args: { stripeSubscriptionId: v.string() }, 45 | returns: v.union(subscriptionValidator, v.null()), 46 | handler: async (ctx, args) => { 47 | const subscription = await ctx.db 48 | .query("subscriptions") 49 | .withIndex("by_stripe_subscription_id", (q) => 50 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 51 | ) 52 | .unique(); 53 | if (!subscription) return null; 54 | const { _id, _creationTime, ...data } = subscription; 55 | return data; 56 | }, 57 | }); 58 | 59 | /** 60 | * List all subscriptions for a customer. 61 | */ 62 | export const listSubscriptions = query({ 63 | args: { stripeCustomerId: v.string() }, 64 | returns: v.array(subscriptionValidator), 65 | handler: async (ctx, args) => { 66 | const subscriptions = await ctx.db 67 | .query("subscriptions") 68 | .withIndex("by_stripe_customer_id", (q) => 69 | q.eq("stripeCustomerId", args.stripeCustomerId), 70 | ) 71 | .collect(); 72 | return subscriptions.map(({ _id, _creationTime, ...data }) => data); 73 | }, 74 | }); 75 | 76 | /** 77 | * Get a subscription by organization ID. 78 | * Useful for looking up subscriptions by custom orgId. 79 | */ 80 | export const getSubscriptionByOrgId = query({ 81 | args: { orgId: v.string() }, 82 | returns: v.union(subscriptionValidator, v.null()), 83 | handler: async (ctx, args) => { 84 | const subscription = await ctx.db 85 | .query("subscriptions") 86 | .withIndex("by_org_id", (q) => q.eq("orgId", args.orgId)) 87 | .first(); 88 | if (!subscription) return null; 89 | const { _id, _creationTime, ...data } = subscription; 90 | return data; 91 | }, 92 | }); 93 | 94 | /** 95 | * List all subscriptions for a user ID. 96 | * Useful for looking up subscriptions by custom userId. 97 | */ 98 | export const listSubscriptionsByUserId = query({ 99 | args: { userId: v.string() }, 100 | returns: v.array(subscriptionValidator), 101 | handler: async (ctx, args) => { 102 | const subscriptions = await ctx.db 103 | .query("subscriptions") 104 | .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) 105 | .collect(); 106 | return subscriptions.map(({ _id, _creationTime, ...data }) => data); 107 | }, 108 | }); 109 | 110 | /** 111 | * Get a payment by its Stripe payment intent ID. 112 | */ 113 | export const getPayment = query({ 114 | args: { stripePaymentIntentId: v.string() }, 115 | returns: v.union(paymentValidator, v.null()), 116 | handler: async (ctx, args) => { 117 | const payment = await ctx.db 118 | .query("payments") 119 | .withIndex("by_stripe_payment_intent_id", (q) => 120 | q.eq("stripePaymentIntentId", args.stripePaymentIntentId), 121 | ) 122 | .unique(); 123 | if (!payment) return null; 124 | const { _id, _creationTime, ...data } = payment; 125 | return data; 126 | }, 127 | }); 128 | 129 | /** 130 | * List payments for a customer. 131 | */ 132 | export const listPayments = query({ 133 | args: { stripeCustomerId: v.string() }, 134 | returns: v.array(paymentValidator), 135 | handler: async (ctx, args) => { 136 | const payments = await ctx.db 137 | .query("payments") 138 | .withIndex("by_stripe_customer_id", (q) => 139 | q.eq("stripeCustomerId", args.stripeCustomerId), 140 | ) 141 | .collect(); 142 | return payments.map(({ _id, _creationTime, ...data }) => data); 143 | }, 144 | }); 145 | 146 | /** 147 | * List payments for a user ID. 148 | */ 149 | export const listPaymentsByUserId = query({ 150 | args: { userId: v.string() }, 151 | returns: v.array(paymentValidator), 152 | handler: async (ctx, args) => { 153 | const payments = await ctx.db 154 | .query("payments") 155 | .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) 156 | .collect(); 157 | return payments.map(({ _id, _creationTime, ...data }) => data); 158 | }, 159 | }); 160 | 161 | /** 162 | * List payments for an organization ID. 163 | */ 164 | export const listPaymentsByOrgId = query({ 165 | args: { orgId: v.string() }, 166 | returns: v.array(paymentValidator), 167 | handler: async (ctx, args) => { 168 | const payments = await ctx.db 169 | .query("payments") 170 | .withIndex("by_org_id", (q) => q.eq("orgId", args.orgId)) 171 | .collect(); 172 | return payments.map(({ _id, _creationTime, ...data }) => data); 173 | }, 174 | }); 175 | 176 | /** 177 | * List invoices for a customer. 178 | */ 179 | export const listInvoices = query({ 180 | args: { stripeCustomerId: v.string() }, 181 | returns: v.array(invoiceValidator), 182 | handler: async (ctx, args) => { 183 | const invoices = await ctx.db 184 | .query("invoices") 185 | .withIndex("by_stripe_customer_id", (q) => 186 | q.eq("stripeCustomerId", args.stripeCustomerId), 187 | ) 188 | .collect(); 189 | return invoices.map(({ _id, _creationTime, ...data }) => data); 190 | }, 191 | }); 192 | 193 | /** 194 | * List invoices for an organization ID. 195 | */ 196 | export const listInvoicesByOrgId = query({ 197 | args: { orgId: v.string() }, 198 | returns: v.array(invoiceValidator), 199 | handler: async (ctx, args) => { 200 | const invoices = await ctx.db 201 | .query("invoices") 202 | .withIndex("by_org_id", (q) => q.eq("orgId", args.orgId)) 203 | .collect(); 204 | return invoices.map(({ _id, _creationTime, ...data }) => data); 205 | }, 206 | }); 207 | 208 | /** 209 | * List invoices for a user ID. 210 | */ 211 | export const listInvoicesByUserId = query({ 212 | args: { userId: v.string() }, 213 | returns: v.array(invoiceValidator), 214 | handler: async (ctx, args) => { 215 | const invoices = await ctx.db 216 | .query("invoices") 217 | .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) 218 | .collect(); 219 | return invoices.map(({ _id, _creationTime, ...data }) => data); 220 | }, 221 | }); 222 | 223 | // ============================================================================ 224 | // PUBLIC MUTATIONS 225 | // ============================================================================ 226 | 227 | /** 228 | * Create or update a customer with metadata. 229 | * Returns the stripeCustomerId for consistency with the API. 230 | */ 231 | export const createOrUpdateCustomer = mutation({ 232 | args: { 233 | stripeCustomerId: v.string(), 234 | email: v.optional(v.string()), 235 | name: v.optional(v.string()), 236 | metadata: v.optional(v.any()), 237 | }, 238 | returns: v.string(), 239 | handler: async (ctx, args) => { 240 | const existing = await ctx.db 241 | .query("customers") 242 | .withIndex("by_stripe_customer_id", (q) => 243 | q.eq("stripeCustomerId", args.stripeCustomerId), 244 | ) 245 | .unique(); 246 | 247 | if (existing) { 248 | await ctx.db.patch(existing._id, { 249 | email: args.email, 250 | name: args.name, 251 | metadata: args.metadata, 252 | }); 253 | } else { 254 | await ctx.db.insert("customers", { 255 | stripeCustomerId: args.stripeCustomerId, 256 | email: args.email, 257 | name: args.name, 258 | metadata: args.metadata, 259 | }); 260 | } 261 | return args.stripeCustomerId; 262 | }, 263 | }); 264 | 265 | /** 266 | * Update subscription metadata for custom lookups. 267 | * You can provide orgId and userId for efficient indexed lookups, 268 | * and additional data in the metadata field. 269 | */ 270 | export const updateSubscriptionMetadata = mutation({ 271 | args: { 272 | stripeSubscriptionId: v.string(), 273 | metadata: v.any(), 274 | orgId: v.optional(v.string()), 275 | userId: v.optional(v.string()), 276 | }, 277 | returns: v.null(), 278 | handler: async (ctx, args) => { 279 | const subscription = await ctx.db 280 | .query("subscriptions") 281 | .withIndex("by_stripe_subscription_id", (q) => 282 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 283 | ) 284 | .unique(); 285 | 286 | if (!subscription) { 287 | throw new Error( 288 | `Subscription ${args.stripeSubscriptionId} not found in database`, 289 | ); 290 | } 291 | 292 | await ctx.db.patch(subscription._id, { 293 | metadata: args.metadata, 294 | orgId: args.orgId, 295 | userId: args.userId, 296 | }); 297 | 298 | return null; 299 | }, 300 | }); 301 | 302 | /** 303 | * Update subscription quantity (for seat-based pricing). 304 | * This will update both Stripe and the local database. 305 | * STRIPE_SECRET_KEY must be provided as a parameter. 306 | */ 307 | export const updateSubscriptionQuantity = action({ 308 | args: { 309 | stripeSubscriptionId: v.string(), 310 | quantity: v.number(), 311 | apiKey: v.string(), 312 | }, 313 | returns: v.null(), 314 | handler: async (ctx, args) => { 315 | const stripe = new StripeSDK(args.apiKey); 316 | 317 | // Get the subscription from Stripe to find the subscription item ID 318 | const subscription = await stripe.subscriptions.retrieve( 319 | args.stripeSubscriptionId, 320 | ); 321 | 322 | if (!subscription.items.data[0]) { 323 | throw new Error("Subscription has no items"); 324 | } 325 | 326 | // Update the subscription item quantity in Stripe 327 | await stripe.subscriptionItems.update(subscription.items.data[0].id, { 328 | quantity: args.quantity, 329 | }); 330 | 331 | // Update our local database via mutation 332 | await ctx.runMutation(api.private.updateSubscriptionQuantityInternal, { 333 | stripeSubscriptionId: args.stripeSubscriptionId, 334 | quantity: args.quantity, 335 | }); 336 | 337 | return null; 338 | }, 339 | }); 340 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/component/_generated/component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `ComponentApi` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { FunctionReference } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing a Convex component's exposed API. 15 | * 16 | * Useful when expecting a parameter like `components.myComponent`. 17 | * Usage: 18 | * ```ts 19 | * async function myFunction(ctx: QueryCtx, component: ComponentApi) { 20 | * return ctx.runQuery(component.someFile.someQuery, { ...args }); 21 | * } 22 | * ``` 23 | */ 24 | export type ComponentApi = 25 | { 26 | private: { 27 | handleCheckoutSessionCompleted: FunctionReference< 28 | "mutation", 29 | "internal", 30 | { 31 | metadata?: any; 32 | mode: string; 33 | stripeCheckoutSessionId: string; 34 | stripeCustomerId?: string; 35 | }, 36 | null, 37 | Name 38 | >; 39 | handleCustomerCreated: FunctionReference< 40 | "mutation", 41 | "internal", 42 | { 43 | email?: string; 44 | metadata?: any; 45 | name?: string; 46 | stripeCustomerId: string; 47 | }, 48 | null, 49 | Name 50 | >; 51 | handleCustomerUpdated: FunctionReference< 52 | "mutation", 53 | "internal", 54 | { 55 | email?: string; 56 | metadata?: any; 57 | name?: string; 58 | stripeCustomerId: string; 59 | }, 60 | null, 61 | Name 62 | >; 63 | handleInvoiceCreated: FunctionReference< 64 | "mutation", 65 | "internal", 66 | { 67 | amountDue: number; 68 | amountPaid: number; 69 | created: number; 70 | status: string; 71 | stripeCustomerId: string; 72 | stripeInvoiceId: string; 73 | stripeSubscriptionId?: string; 74 | }, 75 | null, 76 | Name 77 | >; 78 | handleInvoicePaid: FunctionReference< 79 | "mutation", 80 | "internal", 81 | { amountPaid: number; stripeInvoiceId: string }, 82 | null, 83 | Name 84 | >; 85 | handleInvoicePaymentFailed: FunctionReference< 86 | "mutation", 87 | "internal", 88 | { stripeInvoiceId: string }, 89 | null, 90 | Name 91 | >; 92 | handlePaymentIntentSucceeded: FunctionReference< 93 | "mutation", 94 | "internal", 95 | { 96 | amount: number; 97 | created: number; 98 | currency: string; 99 | metadata?: any; 100 | status: string; 101 | stripeCustomerId?: string; 102 | stripePaymentIntentId: string; 103 | }, 104 | null, 105 | Name 106 | >; 107 | handleSubscriptionCreated: FunctionReference< 108 | "mutation", 109 | "internal", 110 | { 111 | cancelAtPeriodEnd: boolean; 112 | currentPeriodEnd: number; 113 | metadata?: any; 114 | priceId: string; 115 | quantity?: number; 116 | status: string; 117 | stripeCustomerId: string; 118 | stripeSubscriptionId: string; 119 | }, 120 | null, 121 | Name 122 | >; 123 | handleSubscriptionDeleted: FunctionReference< 124 | "mutation", 125 | "internal", 126 | { stripeSubscriptionId: string }, 127 | null, 128 | Name 129 | >; 130 | handleSubscriptionUpdated: FunctionReference< 131 | "mutation", 132 | "internal", 133 | { 134 | cancelAtPeriodEnd: boolean; 135 | currentPeriodEnd: number; 136 | metadata?: any; 137 | quantity?: number; 138 | status: string; 139 | stripeSubscriptionId: string; 140 | }, 141 | null, 142 | Name 143 | >; 144 | updatePaymentCustomer: FunctionReference< 145 | "mutation", 146 | "internal", 147 | { stripeCustomerId: string; stripePaymentIntentId: string }, 148 | null, 149 | Name 150 | >; 151 | updateSubscriptionQuantityInternal: FunctionReference< 152 | "mutation", 153 | "internal", 154 | { quantity: number; stripeSubscriptionId: string }, 155 | null, 156 | Name 157 | >; 158 | }; 159 | public: { 160 | createOrUpdateCustomer: FunctionReference< 161 | "mutation", 162 | "internal", 163 | { 164 | email?: string; 165 | metadata?: any; 166 | name?: string; 167 | stripeCustomerId: string; 168 | }, 169 | string, 170 | Name 171 | >; 172 | getCustomer: FunctionReference< 173 | "query", 174 | "internal", 175 | { stripeCustomerId: string }, 176 | { 177 | email?: string; 178 | metadata?: any; 179 | name?: string; 180 | stripeCustomerId: string; 181 | } | null, 182 | Name 183 | >; 184 | getPayment: FunctionReference< 185 | "query", 186 | "internal", 187 | { stripePaymentIntentId: string }, 188 | { 189 | amount: number; 190 | created: number; 191 | currency: string; 192 | metadata?: any; 193 | orgId?: string; 194 | status: string; 195 | stripeCustomerId?: string; 196 | stripePaymentIntentId: string; 197 | userId?: string; 198 | } | null, 199 | Name 200 | >; 201 | getSubscription: FunctionReference< 202 | "query", 203 | "internal", 204 | { stripeSubscriptionId: string }, 205 | { 206 | cancelAtPeriodEnd: boolean; 207 | currentPeriodEnd: number; 208 | metadata?: any; 209 | orgId?: string; 210 | priceId: string; 211 | quantity?: number; 212 | status: string; 213 | stripeCustomerId: string; 214 | stripeSubscriptionId: string; 215 | userId?: string; 216 | } | null, 217 | Name 218 | >; 219 | getSubscriptionByOrgId: FunctionReference< 220 | "query", 221 | "internal", 222 | { orgId: string }, 223 | { 224 | cancelAtPeriodEnd: boolean; 225 | currentPeriodEnd: number; 226 | metadata?: any; 227 | orgId?: string; 228 | priceId: string; 229 | quantity?: number; 230 | status: string; 231 | stripeCustomerId: string; 232 | stripeSubscriptionId: string; 233 | userId?: string; 234 | } | null, 235 | Name 236 | >; 237 | listInvoices: FunctionReference< 238 | "query", 239 | "internal", 240 | { stripeCustomerId: string }, 241 | Array<{ 242 | amountDue: number; 243 | amountPaid: number; 244 | created: number; 245 | orgId?: string; 246 | status: string; 247 | stripeCustomerId: string; 248 | stripeInvoiceId: string; 249 | stripeSubscriptionId?: string; 250 | userId?: string; 251 | }>, 252 | Name 253 | >; 254 | listInvoicesByOrgId: FunctionReference< 255 | "query", 256 | "internal", 257 | { orgId: string }, 258 | Array<{ 259 | amountDue: number; 260 | amountPaid: number; 261 | created: number; 262 | orgId?: string; 263 | status: string; 264 | stripeCustomerId: string; 265 | stripeInvoiceId: string; 266 | stripeSubscriptionId?: string; 267 | userId?: string; 268 | }>, 269 | Name 270 | >; 271 | listInvoicesByUserId: FunctionReference< 272 | "query", 273 | "internal", 274 | { userId: string }, 275 | Array<{ 276 | amountDue: number; 277 | amountPaid: number; 278 | created: number; 279 | orgId?: string; 280 | status: string; 281 | stripeCustomerId: string; 282 | stripeInvoiceId: string; 283 | stripeSubscriptionId?: string; 284 | userId?: string; 285 | }>, 286 | Name 287 | >; 288 | listPayments: FunctionReference< 289 | "query", 290 | "internal", 291 | { stripeCustomerId: string }, 292 | Array<{ 293 | amount: number; 294 | created: number; 295 | currency: string; 296 | metadata?: any; 297 | orgId?: string; 298 | status: string; 299 | stripeCustomerId?: string; 300 | stripePaymentIntentId: string; 301 | userId?: string; 302 | }>, 303 | Name 304 | >; 305 | listPaymentsByOrgId: FunctionReference< 306 | "query", 307 | "internal", 308 | { orgId: string }, 309 | Array<{ 310 | amount: number; 311 | created: number; 312 | currency: string; 313 | metadata?: any; 314 | orgId?: string; 315 | status: string; 316 | stripeCustomerId?: string; 317 | stripePaymentIntentId: string; 318 | userId?: string; 319 | }>, 320 | Name 321 | >; 322 | listPaymentsByUserId: FunctionReference< 323 | "query", 324 | "internal", 325 | { userId: string }, 326 | Array<{ 327 | amount: number; 328 | created: number; 329 | currency: string; 330 | metadata?: any; 331 | orgId?: string; 332 | status: string; 333 | stripeCustomerId?: string; 334 | stripePaymentIntentId: string; 335 | userId?: string; 336 | }>, 337 | Name 338 | >; 339 | listSubscriptions: FunctionReference< 340 | "query", 341 | "internal", 342 | { stripeCustomerId: string }, 343 | Array<{ 344 | cancelAtPeriodEnd: boolean; 345 | currentPeriodEnd: number; 346 | metadata?: any; 347 | orgId?: string; 348 | priceId: string; 349 | quantity?: number; 350 | status: string; 351 | stripeCustomerId: string; 352 | stripeSubscriptionId: string; 353 | userId?: string; 354 | }>, 355 | Name 356 | >; 357 | listSubscriptionsByUserId: FunctionReference< 358 | "query", 359 | "internal", 360 | { userId: string }, 361 | Array<{ 362 | cancelAtPeriodEnd: boolean; 363 | currentPeriodEnd: number; 364 | metadata?: any; 365 | orgId?: string; 366 | priceId: string; 367 | quantity?: number; 368 | status: string; 369 | stripeCustomerId: string; 370 | stripeSubscriptionId: string; 371 | userId?: string; 372 | }>, 373 | Name 374 | >; 375 | updateSubscriptionMetadata: FunctionReference< 376 | "mutation", 377 | "internal", 378 | { 379 | metadata: any; 380 | orgId?: string; 381 | stripeSubscriptionId: string; 382 | userId?: string; 383 | }, 384 | null, 385 | Name 386 | >; 387 | updateSubscriptionQuantity: FunctionReference< 388 | "action", 389 | "internal", 390 | { apiKey: string; quantity: number; stripeSubscriptionId: string }, 391 | null, 392 | Name 393 | >; 394 | }; 395 | }; 396 | -------------------------------------------------------------------------------- /src/component/private.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation } from "./_generated/server.js"; 3 | 4 | // ============================================================================ 5 | // INTERNAL MUTATIONS (for webhooks and internal use) 6 | // ============================================================================ 7 | 8 | export const updateSubscriptionQuantityInternal = mutation({ 9 | args: { 10 | stripeSubscriptionId: v.string(), 11 | quantity: v.number(), 12 | }, 13 | returns: v.null(), 14 | handler: async (ctx, args) => { 15 | const subscription = await ctx.db 16 | .query("subscriptions") 17 | .withIndex("by_stripe_subscription_id", (q) => 18 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 19 | ) 20 | .unique(); 21 | 22 | if (subscription) { 23 | await ctx.db.patch(subscription._id, { 24 | quantity: args.quantity, 25 | }); 26 | } 27 | 28 | return null; 29 | }, 30 | }); 31 | 32 | export const handleCustomerCreated = mutation({ 33 | args: { 34 | stripeCustomerId: v.string(), 35 | email: v.optional(v.string()), 36 | name: v.optional(v.string()), 37 | metadata: v.optional(v.any()), 38 | }, 39 | returns: v.null(), 40 | handler: async (ctx, args) => { 41 | const existing = await ctx.db 42 | .query("customers") 43 | .withIndex("by_stripe_customer_id", (q) => 44 | q.eq("stripeCustomerId", args.stripeCustomerId), 45 | ) 46 | .unique(); 47 | 48 | if (!existing) { 49 | await ctx.db.insert("customers", { 50 | stripeCustomerId: args.stripeCustomerId, 51 | email: args.email, 52 | name: args.name, 53 | metadata: args.metadata || {}, 54 | }); 55 | } 56 | 57 | return null; 58 | }, 59 | }); 60 | 61 | export const handleCustomerUpdated = mutation({ 62 | args: { 63 | stripeCustomerId: v.string(), 64 | email: v.optional(v.string()), 65 | name: v.optional(v.string()), 66 | metadata: v.optional(v.any()), 67 | }, 68 | returns: v.null(), 69 | handler: async (ctx, args) => { 70 | const customer = await ctx.db 71 | .query("customers") 72 | .withIndex("by_stripe_customer_id", (q) => 73 | q.eq("stripeCustomerId", args.stripeCustomerId), 74 | ) 75 | .unique(); 76 | 77 | if (customer) { 78 | await ctx.db.patch(customer._id, { 79 | email: args.email, 80 | name: args.name, 81 | metadata: args.metadata, 82 | }); 83 | } 84 | 85 | return null; 86 | }, 87 | }); 88 | 89 | export const handleSubscriptionCreated = mutation({ 90 | args: { 91 | stripeSubscriptionId: v.string(), 92 | stripeCustomerId: v.string(), 93 | status: v.string(), 94 | currentPeriodEnd: v.number(), 95 | cancelAtPeriodEnd: v.boolean(), 96 | quantity: v.optional(v.number()), 97 | priceId: v.string(), 98 | metadata: v.optional(v.any()), 99 | }, 100 | returns: v.null(), 101 | handler: async (ctx, args) => { 102 | const existing = await ctx.db 103 | .query("subscriptions") 104 | .withIndex("by_stripe_subscription_id", (q) => 105 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 106 | ) 107 | .unique(); 108 | 109 | // Extract orgId and userId from metadata if present 110 | const metadata = args.metadata || {}; 111 | const orgId = metadata.orgId as string | undefined; 112 | const userId = metadata.userId as string | undefined; 113 | 114 | if (!existing) { 115 | await ctx.db.insert("subscriptions", { 116 | stripeSubscriptionId: args.stripeSubscriptionId, 117 | stripeCustomerId: args.stripeCustomerId, 118 | status: args.status, 119 | currentPeriodEnd: args.currentPeriodEnd, 120 | cancelAtPeriodEnd: args.cancelAtPeriodEnd, 121 | quantity: args.quantity, 122 | priceId: args.priceId, 123 | metadata: metadata, 124 | orgId: orgId, 125 | userId: userId, 126 | }); 127 | } 128 | 129 | // Backfill any invoices that were created before this subscription 130 | // (fixes webhook timing issues where invoice arrives before subscription) 131 | if (orgId || userId) { 132 | const invoices = await ctx.db 133 | .query("invoices") 134 | .withIndex("by_stripe_subscription_id", (q) => 135 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 136 | ) 137 | .collect(); 138 | 139 | for (const invoice of invoices) { 140 | if (!invoice.orgId || !invoice.userId) { 141 | await ctx.db.patch(invoice._id, { 142 | ...(orgId && !invoice.orgId && { orgId }), 143 | ...(userId && !invoice.userId && { userId }), 144 | }); 145 | } 146 | } 147 | } 148 | 149 | return null; 150 | }, 151 | }); 152 | 153 | export const handleSubscriptionUpdated = mutation({ 154 | args: { 155 | stripeSubscriptionId: v.string(), 156 | status: v.string(), 157 | currentPeriodEnd: v.number(), 158 | cancelAtPeriodEnd: v.boolean(), 159 | quantity: v.optional(v.number()), 160 | metadata: v.optional(v.any()), 161 | }, 162 | returns: v.null(), 163 | handler: async (ctx, args) => { 164 | const subscription = await ctx.db 165 | .query("subscriptions") 166 | .withIndex("by_stripe_subscription_id", (q) => 167 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 168 | ) 169 | .unique(); 170 | 171 | if (subscription) { 172 | // Extract orgId and userId from metadata if present 173 | const metadata = args.metadata || {}; 174 | const orgId = metadata.orgId as string | undefined; 175 | const userId = metadata.userId as string | undefined; 176 | 177 | await ctx.db.patch(subscription._id, { 178 | status: args.status, 179 | currentPeriodEnd: args.currentPeriodEnd, 180 | cancelAtPeriodEnd: args.cancelAtPeriodEnd, 181 | quantity: args.quantity, 182 | // Only update metadata fields if provided 183 | ...(args.metadata !== undefined && { metadata }), 184 | ...(orgId !== undefined && { orgId }), 185 | ...(userId !== undefined && { userId }), 186 | }); 187 | } 188 | 189 | return null; 190 | }, 191 | }); 192 | 193 | export const handleSubscriptionDeleted = mutation({ 194 | args: { 195 | stripeSubscriptionId: v.string(), 196 | }, 197 | returns: v.null(), 198 | handler: async (ctx, args) => { 199 | const subscription = await ctx.db 200 | .query("subscriptions") 201 | .withIndex("by_stripe_subscription_id", (q) => 202 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId), 203 | ) 204 | .unique(); 205 | 206 | if (subscription) { 207 | await ctx.db.patch(subscription._id, { 208 | status: "canceled", 209 | }); 210 | } 211 | 212 | return null; 213 | }, 214 | }); 215 | 216 | export const handleCheckoutSessionCompleted = mutation({ 217 | args: { 218 | stripeCheckoutSessionId: v.string(), 219 | stripeCustomerId: v.optional(v.string()), 220 | mode: v.string(), 221 | metadata: v.optional(v.any()), 222 | }, 223 | returns: v.null(), 224 | handler: async (ctx, args) => { 225 | const existing = await ctx.db 226 | .query("checkout_sessions") 227 | .withIndex("by_stripe_checkout_session_id", (q) => 228 | q.eq("stripeCheckoutSessionId", args.stripeCheckoutSessionId), 229 | ) 230 | .unique(); 231 | 232 | if (existing) { 233 | await ctx.db.patch(existing._id, { 234 | status: "complete", 235 | stripeCustomerId: args.stripeCustomerId, 236 | }); 237 | } else { 238 | await ctx.db.insert("checkout_sessions", { 239 | stripeCheckoutSessionId: args.stripeCheckoutSessionId, 240 | stripeCustomerId: args.stripeCustomerId, 241 | status: "complete", 242 | mode: args.mode, 243 | metadata: args.metadata || {}, 244 | }); 245 | } 246 | 247 | return null; 248 | }, 249 | }); 250 | 251 | export const handleInvoiceCreated = mutation({ 252 | args: { 253 | stripeInvoiceId: v.string(), 254 | stripeCustomerId: v.string(), 255 | stripeSubscriptionId: v.optional(v.string()), 256 | status: v.string(), 257 | amountDue: v.number(), 258 | amountPaid: v.number(), 259 | created: v.number(), 260 | }, 261 | returns: v.null(), 262 | handler: async (ctx, args) => { 263 | const existing = await ctx.db 264 | .query("invoices") 265 | .withIndex("by_stripe_invoice_id", (q) => 266 | q.eq("stripeInvoiceId", args.stripeInvoiceId), 267 | ) 268 | .unique(); 269 | 270 | if (!existing) { 271 | // Look up orgId/userId from the subscription if available 272 | let orgId: string | undefined; 273 | let userId: string | undefined; 274 | 275 | if (args.stripeSubscriptionId) { 276 | const subscription = await ctx.db 277 | .query("subscriptions") 278 | .withIndex("by_stripe_subscription_id", (q) => 279 | q.eq("stripeSubscriptionId", args.stripeSubscriptionId!), 280 | ) 281 | .unique(); 282 | 283 | if (subscription) { 284 | orgId = subscription.orgId; 285 | userId = subscription.userId; 286 | } 287 | } 288 | 289 | await ctx.db.insert("invoices", { 290 | stripeInvoiceId: args.stripeInvoiceId, 291 | stripeCustomerId: args.stripeCustomerId, 292 | stripeSubscriptionId: args.stripeSubscriptionId, 293 | status: args.status, 294 | amountDue: args.amountDue, 295 | amountPaid: args.amountPaid, 296 | created: args.created, 297 | orgId, 298 | userId, 299 | }); 300 | } 301 | 302 | return null; 303 | }, 304 | }); 305 | 306 | export const handleInvoicePaid = mutation({ 307 | args: { 308 | stripeInvoiceId: v.string(), 309 | amountPaid: v.number(), 310 | }, 311 | returns: v.null(), 312 | handler: async (ctx, args) => { 313 | const invoice = await ctx.db 314 | .query("invoices") 315 | .withIndex("by_stripe_invoice_id", (q) => 316 | q.eq("stripeInvoiceId", args.stripeInvoiceId), 317 | ) 318 | .unique(); 319 | 320 | if (invoice) { 321 | await ctx.db.patch(invoice._id, { 322 | status: "paid", 323 | amountPaid: args.amountPaid, 324 | }); 325 | } 326 | 327 | return null; 328 | }, 329 | }); 330 | 331 | export const handleInvoicePaymentFailed = mutation({ 332 | args: { 333 | stripeInvoiceId: v.string(), 334 | }, 335 | returns: v.null(), 336 | handler: async (ctx, args) => { 337 | const invoice = await ctx.db 338 | .query("invoices") 339 | .withIndex("by_stripe_invoice_id", (q) => 340 | q.eq("stripeInvoiceId", args.stripeInvoiceId), 341 | ) 342 | .unique(); 343 | 344 | if (invoice) { 345 | await ctx.db.patch(invoice._id, { 346 | status: "open", 347 | }); 348 | } 349 | 350 | return null; 351 | }, 352 | }); 353 | 354 | export const handlePaymentIntentSucceeded = mutation({ 355 | args: { 356 | stripePaymentIntentId: v.string(), 357 | stripeCustomerId: v.optional(v.string()), 358 | amount: v.number(), 359 | currency: v.string(), 360 | status: v.string(), 361 | created: v.number(), 362 | metadata: v.optional(v.any()), 363 | }, 364 | returns: v.null(), 365 | handler: async (ctx, args) => { 366 | const existing = await ctx.db 367 | .query("payments") 368 | .withIndex("by_stripe_payment_intent_id", (q) => 369 | q.eq("stripePaymentIntentId", args.stripePaymentIntentId), 370 | ) 371 | .unique(); 372 | 373 | if (!existing) { 374 | // Extract orgId and userId from metadata if present 375 | const metadata = args.metadata || {}; 376 | const orgId = metadata.orgId as string | undefined; 377 | const userId = metadata.userId as string | undefined; 378 | 379 | await ctx.db.insert("payments", { 380 | stripePaymentIntentId: args.stripePaymentIntentId, 381 | stripeCustomerId: args.stripeCustomerId, 382 | amount: args.amount, 383 | currency: args.currency, 384 | status: args.status, 385 | created: args.created, 386 | metadata: metadata, 387 | orgId: orgId, 388 | userId: userId, 389 | }); 390 | } else if (args.stripeCustomerId && !existing.stripeCustomerId) { 391 | // Update customer ID if it wasn't set initially (webhook timing issue) 392 | await ctx.db.patch(existing._id, { 393 | stripeCustomerId: args.stripeCustomerId, 394 | }); 395 | } 396 | 397 | return null; 398 | }, 399 | }); 400 | 401 | export const updatePaymentCustomer = mutation({ 402 | args: { 403 | stripePaymentIntentId: v.string(), 404 | stripeCustomerId: v.string(), 405 | }, 406 | returns: v.null(), 407 | handler: async (ctx, args) => { 408 | const payment = await ctx.db 409 | .query("payments") 410 | .withIndex("by_stripe_payment_intent_id", (q) => 411 | q.eq("stripePaymentIntentId", args.stripePaymentIntentId), 412 | ) 413 | .unique(); 414 | 415 | if (payment && !payment.stripeCustomerId) { 416 | await ctx.db.patch(payment._id, { 417 | stripeCustomerId: args.stripeCustomerId, 418 | }); 419 | } 420 | 421 | return null; 422 | }, 423 | }); 424 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @convex-dev/stripe 2 | 3 | A Convex component for integrating Stripe payments, subscriptions, and billing 4 | into your Convex application. 5 | 6 | [![npm version](https://badge.fury.io/js/@convex-dev%2Fstripe.svg)](https://badge.fury.io/js/@convex-dev%2Fstripe) 7 | 8 | ## Features 9 | 10 | - 🛒 **Checkout Sessions** - Create one-time payment and subscription checkouts 11 | - 📦 **Subscription Management** - Create, update, cancel subscriptions 12 | - 👥 **Customer Management** - Automatic customer creation and linking 13 | - 💳 **Customer Portal** - Let users manage their billing 14 | - 🪑 **Seat-Based Pricing** - Update subscription quantities for team billing 15 | - 🔗 **User/Org Linking** - Link payments and subscriptions to users or 16 | organizations 17 | - 🔔 **Webhook Handling** - Automatic sync of Stripe data to your Convex 18 | database 19 | - 📊 **Real-time Data** - Query payments, subscriptions, invoices in real-time 20 | 21 | ## Quick Start 22 | 23 | ### 1. Install the Component 24 | 25 | ```bash 26 | npm install @convex-dev/stripe 27 | ``` 28 | 29 | ### 2. Add to Your Convex App 30 | 31 | Create or update `convex/convex.config.ts`: 32 | 33 | ```typescript 34 | import { defineApp } from "convex/server"; 35 | import stripe from "@convex-dev/stripe/convex.config.js"; 36 | 37 | const app = defineApp(); 38 | app.use(stripe); 39 | 40 | export default app; 41 | ``` 42 | 43 | ### 3. Set Up Environment Variables 44 | 45 | Add these to your [Convex Dashboard](https://dashboard.convex.dev) → Settings → Environment Variables: 46 | 47 | | Variable | Description | 48 | | ----------------------- | ------------------------------------------------------- | 49 | | `STRIPE_SECRET_KEY` | Your Stripe secret key (`sk_test_...` or `sk_live_...`) | 50 | | `STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) - see Step 4 | 51 | 52 | ### 4. Configure Stripe Webhooks 53 | 54 | 1. Go to [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/test/webhooks) 55 | 2. Click **"Add endpoint"** 56 | 3. Enter your webhook URL: 57 | ``` 58 | https://.convex.site/stripe/webhook 59 | ``` 60 | (Find your deployment name in the Convex dashboard - it's the part before `.convex.cloud` in your URL) 61 | 4. Select these events: 62 | - `checkout.session.completed` 63 | - `customer.created` 64 | - `customer.updated` 65 | - `customer.subscription.created` 66 | - `customer.subscription.updated` 67 | - `customer.subscription.deleted` 68 | - `invoice.created` 69 | - `invoice.finalized` 70 | - `invoice.paid` 71 | - `invoice.payment_failed` 72 | - `payment_intent.succeeded` 73 | - `payment_intent.payment_failed` 74 | 5. Click **"Add endpoint"** 75 | 6. Copy the **Signing secret** and add it as `STRIPE_WEBHOOK_SECRET` in Convex 76 | 77 | ### 5. Register Webhook Routes 78 | 79 | Create `convex/http.ts`: 80 | 81 | ```typescript 82 | import { httpRouter } from "convex/server"; 83 | import { components } from "./_generated/api"; 84 | import { registerRoutes } from "@convex-dev/stripe"; 85 | 86 | const http = httpRouter(); 87 | 88 | // Register Stripe webhook handler at /stripe/webhook 89 | registerRoutes(http, components.stripe, { 90 | webhookPath: "/stripe/webhook", 91 | }); 92 | 93 | export default http; 94 | ``` 95 | 96 | ### 6. Use the Component 97 | 98 | Create `convex/stripe.ts`: 99 | 100 | ```typescript 101 | import { action } from "./_generated/server"; 102 | import { components } from "./_generated/api"; 103 | import { StripeSubscriptions } from "@convex-dev/stripe"; 104 | import { v } from "convex/values"; 105 | 106 | const stripeClient = new StripeSubscriptions(components.stripe, {}); 107 | 108 | // Create a checkout session for a subscription 109 | export const createSubscriptionCheckout = action({ 110 | args: { priceId: v.string() }, 111 | returns: v.object({ 112 | sessionId: v.string(), 113 | url: v.union(v.string(), v.null()), 114 | }), 115 | handler: async (ctx, args) => { 116 | const identity = await ctx.auth.getUserIdentity(); 117 | if (!identity) throw new Error("Not authenticated"); 118 | 119 | // Get or create a Stripe customer 120 | const customer = await stripeClient.getOrCreateCustomer(ctx, { 121 | userId: identity.subject, 122 | email: identity.email, 123 | name: identity.name, 124 | }); 125 | 126 | // Create checkout session 127 | return await stripeClient.createCheckoutSession(ctx, { 128 | priceId: args.priceId, 129 | customerId: customer.customerId, 130 | mode: "subscription", 131 | successUrl: "http://localhost:5173/?success=true", 132 | cancelUrl: "http://localhost:5173/?canceled=true", 133 | subscriptionMetadata: { userId: identity.subject }, 134 | }); 135 | }, 136 | }); 137 | 138 | // Create a checkout session for a one-time payment 139 | export const createPaymentCheckout = action({ 140 | args: { priceId: v.string() }, 141 | returns: v.object({ 142 | sessionId: v.string(), 143 | url: v.union(v.string(), v.null()), 144 | }), 145 | handler: async (ctx, args) => { 146 | const identity = await ctx.auth.getUserIdentity(); 147 | if (!identity) throw new Error("Not authenticated"); 148 | 149 | const customer = await stripeClient.getOrCreateCustomer(ctx, { 150 | userId: identity.subject, 151 | email: identity.email, 152 | name: identity.name, 153 | }); 154 | 155 | return await stripeClient.createCheckoutSession(ctx, { 156 | priceId: args.priceId, 157 | customerId: customer.customerId, 158 | mode: "payment", 159 | successUrl: "http://localhost:5173/?success=true", 160 | cancelUrl: "http://localhost:5173/?canceled=true", 161 | paymentIntentMetadata: { userId: identity.subject }, 162 | }); 163 | }, 164 | }); 165 | ``` 166 | 167 | ## API Reference 168 | 169 | ### StripeSubscriptions Client 170 | 171 | ```typescript 172 | import { StripeSubscriptions } from "@convex-dev/stripe"; 173 | 174 | const stripeClient = new StripeSubscriptions(components.stripe, { 175 | STRIPE_SECRET_KEY: "sk_...", // Optional, defaults to process.env.STRIPE_SECRET_KEY 176 | }); 177 | ``` 178 | 179 | #### Methods 180 | 181 | | Method | Description | 182 | |--------|-------------| 183 | | `createCheckoutSession()` | Create a Stripe Checkout session | 184 | | `createCustomerPortalSession()` | Generate a Customer Portal URL | 185 | | `createCustomer()` | Create a new Stripe customer | 186 | | `getOrCreateCustomer()` | Get existing or create new customer | 187 | | `cancelSubscription()` | Cancel a subscription | 188 | | `reactivateSubscription()` | Reactivate a subscription set to cancel | 189 | | `updateSubscriptionQuantity()` | Update seat count | 190 | 191 | ### createCheckoutSession 192 | 193 | ```typescript 194 | await stripeClient.createCheckoutSession(ctx, { 195 | priceId: "price_...", 196 | customerId: "cus_...", // Optional 197 | mode: "subscription", // "subscription" | "payment" | "setup" 198 | successUrl: "https://...", 199 | cancelUrl: "https://...", 200 | quantity: 1, // Optional, default 1 201 | metadata: {}, // Optional, session metadata 202 | subscriptionMetadata: {}, // Optional, attached to subscription 203 | paymentIntentMetadata: {}, // Optional, attached to payment intent 204 | }); 205 | ``` 206 | 207 | ### Component Queries 208 | 209 | Access data directly via the component's public queries: 210 | 211 | ```typescript 212 | import { query } from "./_generated/server"; 213 | import { components } from "./_generated/api"; 214 | 215 | // List subscriptions for a user 216 | export const getUserSubscriptions = query({ 217 | args: {}, 218 | handler: async (ctx) => { 219 | const identity = await ctx.auth.getUserIdentity(); 220 | if (!identity) return []; 221 | 222 | return await ctx.runQuery( 223 | components.stripe.public.listSubscriptionsByUserId, 224 | { userId: identity.subject }, 225 | ); 226 | }, 227 | }); 228 | 229 | // List payments for a user 230 | export const getUserPayments = query({ 231 | args: {}, 232 | handler: async (ctx) => { 233 | const identity = await ctx.auth.getUserIdentity(); 234 | if (!identity) return []; 235 | 236 | return await ctx.runQuery(components.stripe.public.listPaymentsByUserId, { 237 | userId: identity.subject, 238 | }); 239 | }, 240 | }); 241 | ``` 242 | 243 | ### Available Public Queries 244 | 245 | | Query | Arguments | Description | 246 | | --------------------------- | ----------------------- | --------------------------------- | 247 | | `getCustomer` | `stripeCustomerId` | Get a customer by Stripe ID | 248 | | `listSubscriptions` | `stripeCustomerId` | List subscriptions for a customer | 249 | | `listSubscriptionsByUserId` | `userId` | List subscriptions for a user | 250 | | `getSubscription` | `stripeSubscriptionId` | Get a subscription by ID | 251 | | `getSubscriptionByOrgId` | `orgId` | Get subscription for an org | 252 | | `getPayment` | `stripePaymentIntentId` | Get a payment by ID | 253 | | `listPayments` | `stripeCustomerId` | List payments for a customer | 254 | | `listPaymentsByUserId` | `userId` | List payments for a user | 255 | | `listPaymentsByOrgId` | `orgId` | List payments for an org | 256 | | `listInvoices` | `stripeCustomerId` | List invoices for a customer | 257 | | `listInvoicesByUserId` | `userId` | List invoices for a user | 258 | | `listInvoicesByOrgId` | `orgId` | List invoices for an org | 259 | 260 | ## Webhook Events 261 | 262 | The component automatically handles these Stripe webhook events: 263 | 264 | | Event | Action | 265 | | ------------------------------- | ----------------------------------- | 266 | | `customer.created` | Creates customer record | 267 | | `customer.updated` | Updates customer record | 268 | | `customer.subscription.created` | Creates subscription record | 269 | | `customer.subscription.updated` | Updates subscription record | 270 | | `customer.subscription.deleted` | Marks subscription as canceled | 271 | | `payment_intent.succeeded` | Creates payment record | 272 | | `payment_intent.payment_failed` | Updates payment status | 273 | | `invoice.created` | Creates invoice record | 274 | | `invoice.paid` | Updates invoice to paid | 275 | | `invoice.payment_failed` | Marks invoice as failed | 276 | | `checkout.session.completed` | Handles completed checkout sessions | 277 | 278 | ### Custom Webhook Handlers 279 | 280 | Add custom logic to webhook events: 281 | 282 | ```typescript 283 | import { httpRouter } from "convex/server"; 284 | import { components } from "./_generated/api"; 285 | import { registerRoutes } from "@convex-dev/stripe"; 286 | import type Stripe from "stripe"; 287 | 288 | const http = httpRouter(); 289 | 290 | registerRoutes(http, components.stripe, { 291 | events: { 292 | "customer.subscription.updated": async (ctx, event: Stripe.CustomerSubscriptionUpdatedEvent) => { 293 | const subscription = event.data.object; 294 | console.log("Subscription updated:", subscription.id, subscription.status); 295 | // Add custom logic here 296 | }, 297 | }, 298 | onEvent: async (ctx, event: Stripe.Event) => { 299 | // Called for ALL events - useful for logging/analytics 300 | console.log("Stripe event:", event.type); 301 | }, 302 | }); 303 | 304 | export default http; 305 | ``` 306 | 307 | ## Database Schema 308 | 309 | The component creates these tables in its namespace: 310 | 311 | ### customers 312 | 313 | | Field | Type | Description | 314 | | ------------------ | ------- | ------------------ | 315 | | `stripeCustomerId` | string | Stripe customer ID | 316 | | `email` | string? | Customer email | 317 | | `name` | string? | Customer name | 318 | | `metadata` | object? | Custom metadata | 319 | 320 | ### subscriptions 321 | 322 | | Field | Type | Description | 323 | | ---------------------- | ------- | ------------------------- | 324 | | `stripeSubscriptionId` | string | Stripe subscription ID | 325 | | `stripeCustomerId` | string | Customer ID | 326 | | `status` | string | Subscription status | 327 | | `priceId` | string | Price ID | 328 | | `quantity` | number? | Seat count | 329 | | `currentPeriodEnd` | number | Period end timestamp | 330 | | `cancelAtPeriodEnd` | boolean | Will cancel at period end | 331 | | `userId` | string? | Linked user ID | 332 | | `orgId` | string? | Linked org ID | 333 | | `metadata` | object? | Custom metadata | 334 | 335 | ### checkout_sessions 336 | 337 | | Field | Type | Description | 338 | | ------------------------- | ------- | ----------------------------------------- | 339 | | `stripeCheckoutSessionId` | string | Checkout session ID | 340 | | `stripeCustomerId` | string? | Customer ID | 341 | | `status` | string | Session status | 342 | | `mode` | string | Session mode (payment/subscription/setup) | 343 | | `metadata` | object? | Custom metadata | 344 | 345 | ### payments 346 | 347 | | Field | Type | Description | 348 | | ----------------------- | ------- | ----------------- | 349 | | `stripePaymentIntentId` | string | Payment intent ID | 350 | | `stripeCustomerId` | string? | Customer ID | 351 | | `amount` | number | Amount in cents | 352 | | `currency` | string | Currency code | 353 | | `status` | string | Payment status | 354 | | `created` | number | Created timestamp | 355 | | `userId` | string? | Linked user ID | 356 | | `orgId` | string? | Linked org ID | 357 | | `metadata` | object? | Custom metadata | 358 | 359 | ### invoices 360 | 361 | | Field | Type | Description | 362 | | ---------------------- | ------- | ----------------- | 363 | | `stripeInvoiceId` | string | Invoice ID | 364 | | `stripeCustomerId` | string | Customer ID | 365 | | `stripeSubscriptionId` | string? | Subscription ID | 366 | | `status` | string | Invoice status | 367 | | `amountDue` | number | Amount due | 368 | | `amountPaid` | number | Amount paid | 369 | | `created` | number | Created timestamp | 370 | | `userId` | string? | Linked user ID | 371 | | `orgId` | string? | Linked org ID | 372 | 373 | ## Example App 374 | 375 | Check out the full example app in the [`example/`](./example) directory: 376 | 377 | ```bash 378 | git clone https://github.com/get-convex/convex-stripe 379 | cd convex-stripe 380 | npm install 381 | npm run dev 382 | ``` 383 | 384 | The example includes: 385 | 386 | - Landing page with product showcase 387 | - One-time payments and subscriptions 388 | - User profile with order history 389 | - Subscription management (cancel, update seats) 390 | - Customer portal integration 391 | - Team/organization billing 392 | 393 | ## Authentication 394 | 395 | This component works with any Convex authentication provider. The example uses 396 | [Clerk](https://clerk.com): 397 | 398 | 1. Create a Clerk application at [clerk.com](https://clerk.com) 399 | 2. Add `VITE_CLERK_PUBLISHABLE_KEY` to your `.env.local` 400 | 3. Create `convex/auth.config.ts`: 401 | 402 | ```typescript 403 | export default { 404 | providers: [ 405 | { 406 | domain: "https://your-clerk-domain.clerk.accounts.dev", 407 | applicationID: "convex", 408 | }, 409 | ], 410 | }; 411 | ``` 412 | 413 | ## Troubleshooting 414 | 415 | ### Tables are empty after checkout 416 | 417 | Make sure you've: 418 | 419 | 1. Set `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` in Convex environment 420 | variables 421 | 2. Configured the webhook endpoint in Stripe with the correct events 422 | 3. Added `invoice.created` and `invoice.finalized` events (not just 423 | `invoice.paid`) 424 | 425 | ### "Not authenticated" errors 426 | 427 | Ensure your auth provider is configured: 428 | 429 | 1. Create `convex/auth.config.ts` with your provider 430 | 2. Run `npx convex dev` to push the config 431 | 3. Verify the user is signed in before calling actions 432 | 433 | ### Webhooks returning 400/500 434 | 435 | Check the Convex logs in your dashboard for errors. Common issues: 436 | 437 | - Missing `STRIPE_WEBHOOK_SECRET` 438 | - Wrong webhook URL (should be 439 | `https://.convex.site/stripe/webhook`) 440 | - Missing events in webhook configuration 441 | 442 | ## License 443 | 444 | Apache-2.0 445 | -------------------------------------------------------------------------------- /src/component/public.test.ts: -------------------------------------------------------------------------------- 1 | import { convexTest } from "convex-test"; 2 | import { expect, test } from "vitest"; 3 | import { api, internal } from "./_generated/api.js"; 4 | import schema from "./schema.js"; 5 | import { modules } from "./setup.test.js"; 6 | 7 | test("customer creation and retrieval", async () => { 8 | const t = convexTest(schema, modules); 9 | 10 | // Create a customer 11 | const customerId = await t.mutation(api.public.createOrUpdateCustomer, { 12 | stripeCustomerId: "cus_test123", 13 | email: "test@example.com", 14 | name: "Test User", 15 | metadata: { userId: "user_123" }, 16 | }); 17 | 18 | expect(customerId).toBeDefined(); 19 | 20 | // Retrieve the customer 21 | const customer = await t.query(api.public.getCustomer, { 22 | stripeCustomerId: "cus_test123", 23 | }); 24 | 25 | expect(customer).toBeDefined(); 26 | expect(customer?.email).toBe("test@example.com"); 27 | expect(customer?.name).toBe("Test User"); 28 | expect(customer?.metadata).toEqual({ userId: "user_123" }); 29 | }); 30 | 31 | test("customer update", async () => { 32 | const t = convexTest(schema, modules); 33 | 34 | // Create initial customer 35 | await t.mutation(api.public.createOrUpdateCustomer, { 36 | stripeCustomerId: "cus_test456", 37 | email: "old@example.com", 38 | name: "Old Name", 39 | }); 40 | 41 | // Update customer 42 | await t.mutation(api.public.createOrUpdateCustomer, { 43 | stripeCustomerId: "cus_test456", 44 | email: "new@example.com", 45 | name: "New Name", 46 | metadata: { updated: true }, 47 | }); 48 | 49 | // Verify update 50 | const customer = await t.query(api.public.getCustomer, { 51 | stripeCustomerId: "cus_test456", 52 | }); 53 | 54 | expect(customer?.email).toBe("new@example.com"); 55 | expect(customer?.name).toBe("New Name"); 56 | expect(customer?.metadata).toEqual({ updated: true }); 57 | }); 58 | 59 | test("subscription creation via webhook", async () => { 60 | const t = convexTest(schema, modules); 61 | 62 | // Create customer first 63 | await t.mutation(api.private.handleCustomerCreated, { 64 | stripeCustomerId: "cus_test789", 65 | email: "customer@example.com", 66 | name: "Customer Name", 67 | }); 68 | 69 | // Create subscription via webhook 70 | await t.mutation(api.private.handleSubscriptionCreated, { 71 | stripeSubscriptionId: "sub_test123", 72 | stripeCustomerId: "cus_test789", 73 | status: "active", 74 | currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days 75 | cancelAtPeriodEnd: false, 76 | quantity: 5, 77 | priceId: "price_test", 78 | metadata: { orgId: "org_123" }, 79 | }); 80 | 81 | // Retrieve subscription 82 | const subscription = await t.query(api.public.getSubscription, { 83 | stripeSubscriptionId: "sub_test123", 84 | }); 85 | 86 | expect(subscription).toBeDefined(); 87 | expect(subscription?.status).toBe("active"); 88 | expect(subscription?.quantity).toBe(5); 89 | expect(subscription?.metadata).toEqual({ orgId: "org_123" }); 90 | }); 91 | 92 | test("list subscriptions for customer", async () => { 93 | const t = convexTest(schema, modules); 94 | 95 | // Create customer 96 | await t.mutation(api.private.handleCustomerCreated, { 97 | stripeCustomerId: "cus_multi", 98 | email: "multi@example.com", 99 | }); 100 | 101 | // Create multiple subscriptions 102 | await t.mutation(api.private.handleSubscriptionCreated, { 103 | stripeSubscriptionId: "sub_1", 104 | stripeCustomerId: "cus_multi", 105 | status: "active", 106 | currentPeriodEnd: Date.now(), 107 | cancelAtPeriodEnd: false, 108 | priceId: "price_1", 109 | }); 110 | 111 | await t.mutation(api.private.handleSubscriptionCreated, { 112 | stripeSubscriptionId: "sub_2", 113 | stripeCustomerId: "cus_multi", 114 | status: "active", 115 | currentPeriodEnd: Date.now(), 116 | cancelAtPeriodEnd: false, 117 | priceId: "price_2", 118 | }); 119 | 120 | // List subscriptions 121 | const subscriptions = await t.query(api.public.listSubscriptions, { 122 | stripeCustomerId: "cus_multi", 123 | }); 124 | 125 | expect(subscriptions).toHaveLength(2); 126 | expect(subscriptions.map((s: any) => s.stripeSubscriptionId)).toContain("sub_1"); 127 | expect(subscriptions.map((s: any) => s.stripeSubscriptionId)).toContain("sub_2"); 128 | }); 129 | 130 | test("update subscription metadata for custom lookups", async () => { 131 | const t = convexTest(schema, modules); 132 | 133 | // Create subscription 134 | await t.mutation(api.private.handleSubscriptionCreated, { 135 | stripeSubscriptionId: "sub_metadata", 136 | stripeCustomerId: "cus_test", 137 | status: "active", 138 | currentPeriodEnd: Date.now(), 139 | cancelAtPeriodEnd: false, 140 | priceId: "price_test", 141 | }); 142 | 143 | // Update metadata 144 | await t.mutation(api.public.updateSubscriptionMetadata, { 145 | stripeSubscriptionId: "sub_metadata", 146 | metadata: { 147 | orgId: "org_456", 148 | userId: "user_789", 149 | plan: "pro", 150 | }, 151 | }); 152 | 153 | // Verify metadata 154 | const subscription = await t.query(api.public.getSubscription, { 155 | stripeSubscriptionId: "sub_metadata", 156 | }); 157 | 158 | expect(subscription?.metadata).toEqual({ 159 | orgId: "org_456", 160 | userId: "user_789", 161 | plan: "pro", 162 | }); 163 | }); 164 | 165 | test("subscription status update via webhook", async () => { 166 | const t = convexTest(schema, modules); 167 | 168 | // Create initial subscription 169 | await t.mutation(api.private.handleSubscriptionCreated, { 170 | stripeSubscriptionId: "sub_status", 171 | stripeCustomerId: "cus_test", 172 | status: "active", 173 | currentPeriodEnd: Date.now(), 174 | cancelAtPeriodEnd: false, 175 | priceId: "price_test", 176 | }); 177 | 178 | // Update status to past_due 179 | await t.mutation(api.private.handleSubscriptionUpdated, { 180 | stripeSubscriptionId: "sub_status", 181 | status: "past_due", 182 | currentPeriodEnd: Date.now(), 183 | cancelAtPeriodEnd: false, 184 | }); 185 | 186 | // Verify status update 187 | const subscription = await t.query(api.public.getSubscription, { 188 | stripeSubscriptionId: "sub_status", 189 | }); 190 | 191 | expect(subscription?.status).toBe("past_due"); 192 | }); 193 | 194 | test("seat quantity update", async () => { 195 | const t = convexTest(schema, modules); 196 | 197 | // Create subscription with initial quantity 198 | await t.mutation(api.private.handleSubscriptionCreated, { 199 | stripeSubscriptionId: "sub_seats", 200 | stripeCustomerId: "cus_test", 201 | status: "active", 202 | currentPeriodEnd: Date.now(), 203 | cancelAtPeriodEnd: false, 204 | quantity: 5, 205 | priceId: "price_test", 206 | }); 207 | 208 | // Update quantity 209 | await t.mutation(api.private.handleSubscriptionUpdated, { 210 | stripeSubscriptionId: "sub_seats", 211 | status: "active", 212 | currentPeriodEnd: Date.now(), 213 | cancelAtPeriodEnd: false, 214 | quantity: 10, 215 | }); 216 | 217 | // Verify quantity update 218 | const subscription = await t.query(api.public.getSubscription, { 219 | stripeSubscriptionId: "sub_seats", 220 | }); 221 | 222 | expect(subscription?.quantity).toBe(10); 223 | }); 224 | 225 | // ============================================================================ 226 | // PAYMENT TESTS 227 | // ============================================================================ 228 | 229 | test("payment creation via payment_intent.succeeded webhook", async () => { 230 | const t = convexTest(schema, modules); 231 | 232 | // Simulate payment_intent.succeeded webhook 233 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 234 | stripePaymentIntentId: "pi_test123", 235 | stripeCustomerId: "cus_payment_test", 236 | amount: 1999, // $19.99 237 | currency: "usd", 238 | status: "succeeded", 239 | created: Date.now(), 240 | metadata: { orderId: "order_123" }, 241 | }); 242 | 243 | // Retrieve the payment 244 | const payment = await t.query(api.public.getPayment, { 245 | stripePaymentIntentId: "pi_test123", 246 | }); 247 | 248 | expect(payment).toBeDefined(); 249 | expect(payment?.amount).toBe(1999); 250 | expect(payment?.currency).toBe("usd"); 251 | expect(payment?.status).toBe("succeeded"); 252 | expect(payment?.stripeCustomerId).toBe("cus_payment_test"); 253 | expect(payment?.metadata).toEqual({ orderId: "order_123" }); 254 | }); 255 | 256 | test("payment without customer (guest checkout)", async () => { 257 | const t = convexTest(schema, modules); 258 | 259 | // Create payment without customer ID (guest checkout) 260 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 261 | stripePaymentIntentId: "pi_guest123", 262 | amount: 2500, 263 | currency: "usd", 264 | status: "succeeded", 265 | created: Date.now(), 266 | metadata: {}, 267 | }); 268 | 269 | const payment = await t.query(api.public.getPayment, { 270 | stripePaymentIntentId: "pi_guest123", 271 | }); 272 | 273 | expect(payment).toBeDefined(); 274 | expect(payment?.stripeCustomerId).toBeUndefined(); 275 | }); 276 | 277 | test("payment with orgId and userId extraction from metadata", async () => { 278 | const t = convexTest(schema, modules); 279 | 280 | // Create payment with orgId and userId in metadata 281 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 282 | stripePaymentIntentId: "pi_org123", 283 | stripeCustomerId: "cus_org_test", 284 | amount: 5000, 285 | currency: "usd", 286 | status: "succeeded", 287 | created: Date.now(), 288 | metadata: { 289 | orgId: "org_demo_123", 290 | userId: "user_demo_456", 291 | customField: "custom_value", 292 | }, 293 | }); 294 | 295 | const payment = await t.query(api.public.getPayment, { 296 | stripePaymentIntentId: "pi_org123", 297 | }); 298 | 299 | expect(payment?.orgId).toBe("org_demo_123"); 300 | expect(payment?.userId).toBe("user_demo_456"); 301 | expect(payment?.metadata).toEqual({ 302 | orgId: "org_demo_123", 303 | userId: "user_demo_456", 304 | customField: "custom_value", 305 | }); 306 | }); 307 | 308 | test("list payments by customer ID", async () => { 309 | const t = convexTest(schema, modules); 310 | 311 | // Create multiple payments for the same customer 312 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 313 | stripePaymentIntentId: "pi_cust1", 314 | stripeCustomerId: "cus_multi_test", 315 | amount: 1000, 316 | currency: "usd", 317 | status: "succeeded", 318 | created: Date.now(), 319 | metadata: {}, 320 | }); 321 | 322 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 323 | stripePaymentIntentId: "pi_cust2", 324 | stripeCustomerId: "cus_multi_test", 325 | amount: 2000, 326 | currency: "usd", 327 | status: "succeeded", 328 | created: Date.now(), 329 | metadata: {}, 330 | }); 331 | 332 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 333 | stripePaymentIntentId: "pi_other", 334 | stripeCustomerId: "cus_other", 335 | amount: 3000, 336 | currency: "usd", 337 | status: "succeeded", 338 | created: Date.now(), 339 | metadata: {}, 340 | }); 341 | 342 | // List payments for the specific customer 343 | const payments = await t.query(api.public.listPayments, { 344 | stripeCustomerId: "cus_multi_test", 345 | }); 346 | 347 | expect(payments).toHaveLength(2); 348 | expect(payments?.map((p: any) => p.stripePaymentIntentId)).toContain("pi_cust1"); 349 | expect(payments?.map((p: any) => p.stripePaymentIntentId)).toContain("pi_cust2"); 350 | }); 351 | 352 | test("list payments by user ID", async () => { 353 | const t = convexTest(schema, modules); 354 | 355 | // Create payments with different user IDs 356 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 357 | stripePaymentIntentId: "pi_user1", 358 | stripeCustomerId: "cus_test", 359 | amount: 1500, 360 | currency: "usd", 361 | status: "succeeded", 362 | created: Date.now(), 363 | metadata: { userId: "user_alice" }, 364 | }); 365 | 366 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 367 | stripePaymentIntentId: "pi_user2", 368 | stripeCustomerId: "cus_test", 369 | amount: 2500, 370 | currency: "usd", 371 | status: "succeeded", 372 | created: Date.now(), 373 | metadata: { userId: "user_alice" }, 374 | }); 375 | 376 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 377 | stripePaymentIntentId: "pi_user3", 378 | stripeCustomerId: "cus_test2", 379 | amount: 3500, 380 | currency: "usd", 381 | status: "succeeded", 382 | created: Date.now(), 383 | metadata: { userId: "user_bob" }, 384 | }); 385 | 386 | // List payments for user_alice 387 | const alicePayments = await t.query(api.public.listPaymentsByUserId, { 388 | userId: "user_alice", 389 | }); 390 | 391 | expect(alicePayments).toHaveLength(2); 392 | expect(alicePayments?.every((p: any) => p.userId === "user_alice")).toBe(true); 393 | }); 394 | 395 | test("list payments by org ID", async () => { 396 | const t = convexTest(schema, modules); 397 | 398 | // Create payments with different org IDs 399 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 400 | stripePaymentIntentId: "pi_org1", 401 | stripeCustomerId: "cus_test", 402 | amount: 1500, 403 | currency: "usd", 404 | status: "succeeded", 405 | created: Date.now(), 406 | metadata: { orgId: "org_acme" }, 407 | }); 408 | 409 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 410 | stripePaymentIntentId: "pi_org2", 411 | stripeCustomerId: "cus_test", 412 | amount: 2500, 413 | currency: "usd", 414 | status: "succeeded", 415 | created: Date.now(), 416 | metadata: { orgId: "org_acme" }, 417 | }); 418 | 419 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 420 | stripePaymentIntentId: "pi_org3", 421 | stripeCustomerId: "cus_test2", 422 | amount: 3500, 423 | currency: "usd", 424 | status: "succeeded", 425 | created: Date.now(), 426 | metadata: { orgId: "org_other" }, 427 | }); 428 | 429 | // List payments for org_acme 430 | const acmePayments = await t.query(api.public.listPaymentsByOrgId, { 431 | orgId: "org_acme", 432 | }); 433 | 434 | expect(acmePayments).toHaveLength(2); 435 | expect(acmePayments?.every((p: any) => p.orgId === "org_acme")).toBe(true); 436 | }); 437 | 438 | test("automatic customer linking - webhook timing fix", async () => { 439 | const t = convexTest(schema, modules); 440 | 441 | // Step 1: payment_intent.succeeded fires first (without customer) 442 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 443 | stripePaymentIntentId: "pi_timing_test", 444 | amount: 4999, 445 | currency: "usd", 446 | status: "succeeded", 447 | created: Date.now(), 448 | metadata: { orderId: "order_timing" }, 449 | }); 450 | 451 | // Verify payment exists without customer 452 | let payment = await t.query(api.public.getPayment, { 453 | stripePaymentIntentId: "pi_timing_test", 454 | }); 455 | 456 | expect(payment).toBeDefined(); 457 | expect(payment?.stripeCustomerId).toBeUndefined(); 458 | 459 | // Step 2: checkout.session.completed fires later with customer ID 460 | await t.mutation(api.private.updatePaymentCustomer, { 461 | stripePaymentIntentId: "pi_timing_test", 462 | stripeCustomerId: "cus_timing_test", 463 | }); 464 | 465 | // Verify payment now has customer ID 466 | payment = await t.query(api.public.getPayment, { 467 | stripePaymentIntentId: "pi_timing_test", 468 | }); 469 | 470 | expect(payment?.stripeCustomerId).toBe("cus_timing_test"); 471 | expect(payment?.amount).toBe(4999); // Other fields unchanged 472 | }); 473 | 474 | test("updatePaymentCustomer does not overwrite existing customer", async () => { 475 | const t = convexTest(schema, modules); 476 | 477 | // Create payment with customer ID 478 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 479 | stripePaymentIntentId: "pi_no_overwrite", 480 | stripeCustomerId: "cus_original", 481 | amount: 3000, 482 | currency: "usd", 483 | status: "succeeded", 484 | created: Date.now(), 485 | metadata: {}, 486 | }); 487 | 488 | // Try to update with different customer ID (should not change) 489 | await t.mutation(api.private.updatePaymentCustomer, { 490 | stripePaymentIntentId: "pi_no_overwrite", 491 | stripeCustomerId: "cus_different", 492 | }); 493 | 494 | // Verify original customer ID is preserved 495 | const payment = await t.query(api.public.getPayment, { 496 | stripePaymentIntentId: "pi_no_overwrite", 497 | }); 498 | 499 | expect(payment?.stripeCustomerId).toBe("cus_original"); 500 | }); 501 | 502 | test("handlePaymentIntentSucceeded updates existing payment with customer", async () => { 503 | const t = convexTest(schema, modules); 504 | 505 | // Create payment without customer 506 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 507 | stripePaymentIntentId: "pi_update_test", 508 | amount: 5500, 509 | currency: "eur", 510 | status: "succeeded", 511 | created: Date.now(), 512 | metadata: {}, 513 | }); 514 | 515 | // Same webhook fires again with customer (idempotency) 516 | await t.mutation(api.private.handlePaymentIntentSucceeded, { 517 | stripePaymentIntentId: "pi_update_test", 518 | stripeCustomerId: "cus_idempotent", 519 | amount: 5500, 520 | currency: "eur", 521 | status: "succeeded", 522 | created: Date.now(), 523 | metadata: {}, 524 | }); 525 | 526 | const payment = await t.query(api.public.getPayment, { 527 | stripePaymentIntentId: "pi_update_test", 528 | }); 529 | 530 | expect(payment?.stripeCustomerId).toBe("cus_idempotent"); 531 | }); 532 | 533 | -------------------------------------------------------------------------------- /example/convex/stripe.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Benji's Store - Stripe Integration 3 | * 4 | * This file demonstrates how to use the @convex-dev/stripe component 5 | * for handling payments and subscriptions with Clerk authentication. 6 | */ 7 | 8 | import { action, mutation, query } from "./_generated/server"; 9 | import { components } from "./_generated/api"; 10 | import { StripeSubscriptions } from "@convex-dev/stripe"; 11 | import { v } from "convex/values"; 12 | 13 | const stripeClient = new StripeSubscriptions(components.stripe, {}); 14 | 15 | // Validate required environment variables 16 | function getAppUrl(): string { 17 | const url = process.env.APP_URL; 18 | if (!url) { 19 | throw new Error( 20 | "APP_URL environment variable is not set. Add it in your Convex dashboard.", 21 | ); 22 | } 23 | return url; 24 | } 25 | 26 | // ============================================================================ 27 | // CUSTOMER MANAGEMENT (Customer Creation) 28 | // ============================================================================ 29 | 30 | /** 31 | * Create or get a Stripe customer for the current user. 32 | * This ensures every user has a linked Stripe customer. 33 | */ 34 | export const getOrCreateCustomer = action({ 35 | args: {}, 36 | returns: v.object({ 37 | customerId: v.string(), 38 | isNew: v.boolean(), 39 | }), 40 | handler: async (ctx) => { 41 | const identity = await ctx.auth.getUserIdentity(); 42 | if (!identity) throw new Error("Not authenticated"); 43 | 44 | return await stripeClient.getOrCreateCustomer(ctx, { 45 | userId: identity.subject, 46 | email: identity.email, 47 | name: identity.name, 48 | }); 49 | }, 50 | }); 51 | 52 | // ============================================================================ 53 | // CHECKOUT SESSIONS 54 | // ============================================================================ 55 | 56 | /** 57 | * Create a checkout session for a subscription. 58 | * Automatically creates/links a customer first. 59 | */ 60 | export const createSubscriptionCheckout = action({ 61 | args: { 62 | priceId: v.string(), 63 | quantity: v.optional(v.number()), 64 | }, 65 | returns: v.object({ 66 | sessionId: v.string(), 67 | url: v.union(v.string(), v.null()), 68 | }), 69 | handler: async (ctx, args) => { 70 | const identity = await ctx.auth.getUserIdentity(); 71 | if (!identity) throw new Error("Not authenticated"); 72 | 73 | // Get or create customer using the component 74 | const customerResult = await stripeClient.getOrCreateCustomer(ctx, { 75 | userId: identity.subject, 76 | email: identity.email, 77 | name: identity.name, 78 | }); 79 | 80 | // Create checkout session with subscription metadata for linking 81 | return await stripeClient.createCheckoutSession(ctx, { 82 | priceId: args.priceId, 83 | customerId: customerResult.customerId, 84 | mode: "subscription", 85 | quantity: args.quantity, 86 | successUrl: `${getAppUrl()}/?success=true`, 87 | cancelUrl: `${getAppUrl()}/?canceled=true`, 88 | metadata: { 89 | userId: identity.subject, 90 | productType: "hat_subscription", 91 | }, 92 | subscriptionMetadata: { 93 | userId: identity.subject, 94 | }, 95 | }); 96 | }, 97 | }); 98 | 99 | /** 100 | * Create a checkout session for a TEAM subscription. 101 | * Links the subscription to an organization ID. 102 | */ 103 | export const createTeamSubscriptionCheckout = action({ 104 | args: { 105 | priceId: v.string(), 106 | orgId: v.string(), 107 | quantity: v.optional(v.number()), 108 | }, 109 | returns: v.object({ 110 | sessionId: v.string(), 111 | url: v.union(v.string(), v.null()), 112 | }), 113 | handler: async (ctx, args) => { 114 | const identity = await ctx.auth.getUserIdentity(); 115 | if (!identity) throw new Error("Not authenticated"); 116 | 117 | // Get or create customer using the component 118 | const customerResult = await stripeClient.getOrCreateCustomer(ctx, { 119 | userId: identity.subject, 120 | email: identity.email, 121 | name: identity.name, 122 | }); 123 | 124 | // Create checkout session with BOTH userId and orgId in metadata 125 | return await stripeClient.createCheckoutSession(ctx, { 126 | priceId: args.priceId, 127 | customerId: customerResult.customerId, 128 | mode: "subscription", 129 | quantity: args.quantity ?? 1, 130 | successUrl: `${getAppUrl()}/?success=true&org=${args.orgId}`, 131 | cancelUrl: `${process.env.APP_URL}/?canceled=true`, 132 | metadata: { 133 | userId: identity.subject, 134 | orgId: args.orgId, 135 | productType: "team_subscription", 136 | }, 137 | subscriptionMetadata: { 138 | userId: identity.subject, 139 | orgId: args.orgId, 140 | }, 141 | }); 142 | }, 143 | }); 144 | 145 | /** 146 | * Create a checkout session for a one-time payment. 147 | */ 148 | export const createPaymentCheckout = action({ 149 | args: { 150 | priceId: v.string(), 151 | }, 152 | returns: v.object({ 153 | sessionId: v.string(), 154 | url: v.union(v.string(), v.null()), 155 | }), 156 | handler: async (ctx, args) => { 157 | const identity = await ctx.auth.getUserIdentity(); 158 | if (!identity) throw new Error("Not authenticated"); 159 | 160 | // Get or create customer using the component 161 | const customerResult = await stripeClient.getOrCreateCustomer(ctx, { 162 | userId: identity.subject, 163 | email: identity.email, 164 | name: identity.name, 165 | }); 166 | 167 | // Create checkout session with payment intent metadata for linking 168 | return await stripeClient.createCheckoutSession(ctx, { 169 | priceId: args.priceId, 170 | customerId: customerResult.customerId, 171 | mode: "payment", 172 | successUrl: `${getAppUrl()}/?success=true`, 173 | cancelUrl: `${getAppUrl()}/?canceled=true`, 174 | metadata: { 175 | userId: identity.subject, 176 | productType: "hat", 177 | }, 178 | paymentIntentMetadata: { 179 | userId: identity.subject, 180 | }, 181 | }); 182 | }, 183 | }); 184 | 185 | // ============================================================================ 186 | // SEAT-BASED PRICING (#5 - Quantity/Seats UI) 187 | // ============================================================================ 188 | 189 | /** 190 | * Update the seat count for a subscription. 191 | * Call this when users are added/removed from an organization. 192 | */ 193 | export const updateSeats = action({ 194 | args: { 195 | subscriptionId: v.string(), 196 | seatCount: v.number(), 197 | }, 198 | returns: v.null(), 199 | handler: async (ctx, args) => { 200 | const identity = await ctx.auth.getUserIdentity(); 201 | if (!identity) throw new Error("Not authenticated"); 202 | 203 | // Verify ownership 204 | const subscription = await ctx.runQuery( 205 | components.stripe.public.getSubscription, 206 | { stripeSubscriptionId: args.subscriptionId }, 207 | ); 208 | 209 | if (!subscription || subscription.userId !== identity.subject) { 210 | throw new Error("Subscription not found or access denied"); 211 | } 212 | 213 | // Use stripeClient which has access to the API key 214 | await stripeClient.updateSubscriptionQuantity(ctx, { 215 | stripeSubscriptionId: args.subscriptionId, 216 | quantity: args.seatCount, 217 | }); 218 | 219 | return null; 220 | }, 221 | }); 222 | 223 | // ============================================================================ 224 | // ORGANIZATION-BASED LOOKUPS (#4 - Team Billing) 225 | // ============================================================================ 226 | 227 | /** 228 | * Get subscription for an organization. 229 | */ 230 | export const getOrgSubscription = query({ 231 | args: { 232 | orgId: v.string(), 233 | }, 234 | returns: v.union( 235 | v.object({ 236 | stripeSubscriptionId: v.string(), 237 | stripeCustomerId: v.string(), 238 | status: v.string(), 239 | priceId: v.string(), 240 | quantity: v.optional(v.number()), 241 | currentPeriodEnd: v.number(), 242 | cancelAtPeriodEnd: v.boolean(), 243 | metadata: v.optional(v.any()), 244 | userId: v.optional(v.string()), 245 | orgId: v.optional(v.string()), 246 | }), 247 | v.null(), 248 | ), 249 | handler: async (ctx, args) => { 250 | return await ctx.runQuery(components.stripe.public.getSubscriptionByOrgId, { 251 | orgId: args.orgId, 252 | }); 253 | }, 254 | }); 255 | 256 | /** 257 | * Get all payments for an organization. 258 | */ 259 | export const getOrgPayments = query({ 260 | args: { 261 | orgId: v.string(), 262 | }, 263 | returns: v.array( 264 | v.object({ 265 | stripePaymentIntentId: v.string(), 266 | stripeCustomerId: v.optional(v.string()), 267 | amount: v.number(), 268 | currency: v.string(), 269 | status: v.string(), 270 | created: v.number(), 271 | metadata: v.optional(v.any()), 272 | userId: v.optional(v.string()), 273 | orgId: v.optional(v.string()), 274 | }), 275 | ), 276 | handler: async (ctx, args) => { 277 | return await ctx.runQuery(components.stripe.public.listPaymentsByOrgId, { 278 | orgId: args.orgId, 279 | }); 280 | }, 281 | }); 282 | 283 | /** 284 | * Get invoices for an organization's subscription. 285 | * Subscriptions generate invoices, not payment intents. 286 | */ 287 | export const getOrgInvoices = query({ 288 | args: { 289 | orgId: v.string(), 290 | }, 291 | returns: v.array( 292 | v.object({ 293 | stripeInvoiceId: v.string(), 294 | stripeCustomerId: v.string(), 295 | stripeSubscriptionId: v.optional(v.string()), 296 | status: v.string(), 297 | amountDue: v.number(), 298 | amountPaid: v.number(), 299 | created: v.number(), 300 | orgId: v.optional(v.string()), 301 | userId: v.optional(v.string()), 302 | }), 303 | ), 304 | handler: async (ctx, args) => { 305 | // Direct lookup by orgId (now that invoices have orgId index) 306 | return await ctx.runQuery(components.stripe.public.listInvoicesByOrgId, { 307 | orgId: args.orgId, 308 | }); 309 | }, 310 | }); 311 | 312 | /** 313 | * Link subscription to an organization (for team billing). 314 | */ 315 | export const linkSubscriptionToOrg = mutation({ 316 | args: { 317 | subscriptionId: v.string(), 318 | orgId: v.string(), 319 | userId: v.string(), 320 | }, 321 | returns: v.null(), 322 | handler: async (ctx, args) => { 323 | await ctx.runMutation(components.stripe.public.updateSubscriptionMetadata, { 324 | stripeSubscriptionId: args.subscriptionId, 325 | orgId: args.orgId, 326 | userId: args.userId, 327 | metadata: { 328 | linkedAt: new Date().toISOString(), 329 | }, 330 | }); 331 | return null; 332 | }, 333 | }); 334 | 335 | // ============================================================================ 336 | // SUBSCRIPTION QUERIES 337 | // ============================================================================ 338 | 339 | /** 340 | * Get subscription information by subscription ID. 341 | */ 342 | export const getSubscriptionInfo = query({ 343 | args: { 344 | subscriptionId: v.string(), 345 | }, 346 | returns: v.union( 347 | v.object({ 348 | stripeSubscriptionId: v.string(), 349 | stripeCustomerId: v.string(), 350 | status: v.string(), 351 | priceId: v.string(), 352 | quantity: v.optional(v.number()), 353 | currentPeriodEnd: v.number(), 354 | cancelAtPeriodEnd: v.boolean(), 355 | metadata: v.optional(v.any()), 356 | userId: v.optional(v.string()), 357 | orgId: v.optional(v.string()), 358 | }), 359 | v.null(), 360 | ), 361 | handler: async (ctx, args) => { 362 | return await ctx.runQuery(components.stripe.public.getSubscription, { 363 | stripeSubscriptionId: args.subscriptionId, 364 | }); 365 | }, 366 | }); 367 | 368 | // ============================================================================ 369 | // SUBSCRIPTION MANAGEMENT 370 | // ============================================================================ 371 | 372 | /** 373 | * Cancel a subscription either immediately or at period end. 374 | */ 375 | export const cancelSubscription = action({ 376 | args: { 377 | subscriptionId: v.string(), 378 | immediately: v.optional(v.boolean()), 379 | }, 380 | returns: v.null(), 381 | handler: async (ctx, args) => { 382 | const identity = await ctx.auth.getUserIdentity(); 383 | if (!identity) throw new Error("Not authenticated"); 384 | 385 | // Verify ownership by checking the subscription's userId 386 | const subscription = await ctx.runQuery( 387 | components.stripe.public.getSubscription, 388 | { stripeSubscriptionId: args.subscriptionId }, 389 | ); 390 | 391 | if (!subscription || subscription.userId !== identity.subject) { 392 | throw new Error("Subscription not found or access denied"); 393 | } 394 | 395 | await stripeClient.cancelSubscription(ctx, { 396 | stripeSubscriptionId: args.subscriptionId, 397 | cancelAtPeriodEnd: !args.immediately, 398 | }); 399 | 400 | return null; 401 | }, 402 | }); 403 | 404 | /** 405 | * Reactivate a subscription that was set to cancel at period end. 406 | */ 407 | export const reactivateSubscription = action({ 408 | args: { 409 | subscriptionId: v.string(), 410 | }, 411 | returns: v.null(), 412 | handler: async (ctx, args) => { 413 | const identity = await ctx.auth.getUserIdentity(); 414 | if (!identity) throw new Error("Not authenticated"); 415 | 416 | // Verify ownership 417 | const subscription = await ctx.runQuery( 418 | components.stripe.public.getSubscription, 419 | { stripeSubscriptionId: args.subscriptionId }, 420 | ); 421 | 422 | if (!subscription || subscription.userId !== identity.subject) { 423 | throw new Error("Subscription not found or access denied"); 424 | } 425 | 426 | if (!subscription.cancelAtPeriodEnd) { 427 | throw new Error("Subscription is not set to cancel"); 428 | } 429 | 430 | // Reactivate by setting cancel_at_period_end to false 431 | await stripeClient.reactivateSubscription(ctx, { 432 | stripeSubscriptionId: args.subscriptionId, 433 | }); 434 | 435 | return null; 436 | }, 437 | }); 438 | 439 | // ============================================================================ 440 | // CUSTOMER PORTAL (#6 - Manage Billing) 441 | // ============================================================================ 442 | 443 | /** 444 | * Generate a link to the Stripe Customer Portal where users can 445 | * manage their subscriptions, update payment methods, retry failed payments, etc. 446 | */ 447 | export const getCustomerPortalUrl = action({ 448 | args: {}, 449 | returns: v.union( 450 | v.object({ 451 | url: v.string(), 452 | }), 453 | v.null(), 454 | ), 455 | handler: async (ctx, args) => { 456 | const identity = await ctx.auth.getUserIdentity(); 457 | if (!identity) throw new Error("Not authenticated"); 458 | 459 | // Find customer ID from subscriptions or payments 460 | const subscriptions = await ctx.runQuery( 461 | components.stripe.public.listSubscriptionsByUserId, 462 | { userId: identity.subject }, 463 | ); 464 | 465 | if (subscriptions.length > 0) { 466 | return await stripeClient.createCustomerPortalSession(ctx, { 467 | customerId: subscriptions[0].stripeCustomerId, 468 | returnUrl: `${getAppUrl()}/`, 469 | }); 470 | } 471 | 472 | const payments = await ctx.runQuery( 473 | components.stripe.public.listPaymentsByUserId, 474 | { userId: identity.subject }, 475 | ); 476 | 477 | if (payments.length > 0 && payments[0].stripeCustomerId) { 478 | return await stripeClient.createCustomerPortalSession(ctx, { 479 | customerId: payments[0].stripeCustomerId, 480 | returnUrl: `${getAppUrl()}/`, 481 | }); 482 | } 483 | 484 | // No customer found 485 | return null; 486 | }, 487 | }); 488 | 489 | // ============================================================================ 490 | // CUSTOMER DATA 491 | // ============================================================================ 492 | 493 | /** 494 | * Get customer data including subscriptions and invoices. 495 | */ 496 | export const getCustomerData = query({ 497 | args: { 498 | customerId: v.string(), 499 | }, 500 | returns: v.object({ 501 | customer: v.union( 502 | v.object({ 503 | stripeCustomerId: v.string(), 504 | email: v.optional(v.string()), 505 | name: v.optional(v.string()), 506 | metadata: v.optional(v.any()), 507 | }), 508 | v.null(), 509 | ), 510 | subscriptions: v.array( 511 | v.object({ 512 | stripeSubscriptionId: v.string(), 513 | stripeCustomerId: v.string(), 514 | status: v.string(), 515 | priceId: v.string(), 516 | quantity: v.optional(v.number()), 517 | currentPeriodEnd: v.number(), 518 | cancelAtPeriodEnd: v.boolean(), 519 | metadata: v.optional(v.any()), 520 | userId: v.optional(v.string()), 521 | orgId: v.optional(v.string()), 522 | }), 523 | ), 524 | invoices: v.array( 525 | v.object({ 526 | stripeInvoiceId: v.string(), 527 | stripeCustomerId: v.string(), 528 | stripeSubscriptionId: v.optional(v.string()), 529 | status: v.string(), 530 | amountDue: v.number(), 531 | amountPaid: v.number(), 532 | created: v.number(), 533 | }), 534 | ), 535 | }), 536 | handler: async (ctx, args) => { 537 | const customer = await ctx.runQuery(components.stripe.public.getCustomer, { 538 | stripeCustomerId: args.customerId, 539 | }); 540 | const subscriptions = await ctx.runQuery( 541 | components.stripe.public.listSubscriptions, 542 | { stripeCustomerId: args.customerId }, 543 | ); 544 | const invoices = await ctx.runQuery(components.stripe.public.listInvoices, { 545 | stripeCustomerId: args.customerId, 546 | }); 547 | 548 | return { 549 | customer, 550 | subscriptions, 551 | invoices, 552 | }; 553 | }, 554 | }); 555 | 556 | // ============================================================================ 557 | // USER-SPECIFIC QUERIES (for profile page) 558 | // ============================================================================ 559 | 560 | /** 561 | * Get all subscriptions for the current authenticated user. 562 | * Uses the userId stored in subscription metadata for lookup. 563 | */ 564 | export const getUserSubscriptions = query({ 565 | args: {}, 566 | returns: v.array( 567 | v.object({ 568 | stripeSubscriptionId: v.string(), 569 | stripeCustomerId: v.string(), 570 | status: v.string(), 571 | priceId: v.string(), 572 | quantity: v.optional(v.number()), 573 | currentPeriodEnd: v.number(), 574 | cancelAtPeriodEnd: v.boolean(), 575 | metadata: v.optional(v.any()), 576 | userId: v.optional(v.string()), 577 | orgId: v.optional(v.string()), 578 | }), 579 | ), 580 | handler: async (ctx, args) => { 581 | const identity = await ctx.auth.getUserIdentity(); 582 | if (!identity) return []; 583 | 584 | return await ctx.runQuery( 585 | components.stripe.public.listSubscriptionsByUserId, 586 | { userId: identity.subject }, 587 | ); 588 | }, 589 | }); 590 | 591 | /** 592 | * Get all one-time payments for the current authenticated user. 593 | */ 594 | export const getUserPayments = query({ 595 | args: {}, 596 | returns: v.array( 597 | v.object({ 598 | stripePaymentIntentId: v.string(), 599 | stripeCustomerId: v.optional(v.string()), 600 | amount: v.number(), 601 | currency: v.string(), 602 | status: v.string(), 603 | created: v.number(), 604 | metadata: v.optional(v.any()), 605 | userId: v.optional(v.string()), 606 | orgId: v.optional(v.string()), 607 | }), 608 | ), 609 | handler: async (ctx, args) => { 610 | const identity = await ctx.auth.getUserIdentity(); 611 | if (!identity) return []; 612 | 613 | return await ctx.runQuery(components.stripe.public.listPaymentsByUserId, { 614 | userId: identity.subject, 615 | }); 616 | }, 617 | }); 618 | 619 | /** 620 | * Check if user has any subscriptions with past_due status (#9 - Failed Payment) 621 | */ 622 | export const getFailedPaymentSubscriptions = query({ 623 | args: {}, 624 | returns: v.array( 625 | v.object({ 626 | stripeSubscriptionId: v.string(), 627 | stripeCustomerId: v.string(), 628 | status: v.string(), 629 | currentPeriodEnd: v.number(), 630 | }), 631 | ), 632 | handler: async (ctx, args) => { 633 | const identity = await ctx.auth.getUserIdentity(); 634 | if (!identity) return []; 635 | 636 | const subscriptions = await ctx.runQuery( 637 | components.stripe.public.listSubscriptionsByUserId, 638 | { userId: identity.subject }, 639 | ); 640 | 641 | return subscriptions 642 | .filter( 643 | (sub: { status: string }) => 644 | sub.status === "past_due" || sub.status === "unpaid", 645 | ) 646 | .map((sub: any) => ({ 647 | stripeSubscriptionId: sub.stripeSubscriptionId, 648 | stripeCustomerId: sub.stripeCustomerId, 649 | status: sub.status, 650 | currentPeriodEnd: sub.currentPeriodEnd, 651 | })); 652 | }, 653 | }); 654 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { httpActionGeneric } from "convex/server"; 2 | import StripeSDK from "stripe"; 3 | import type { 4 | MutationCtx, 5 | ActionCtx, 6 | HttpRouter, 7 | RegisterRoutesConfig, 8 | StripeEventHandlers, 9 | } from "./types.js"; 10 | import type { ComponentApi } from "../component/_generated/component.js"; 11 | 12 | /** 13 | * Time window (in seconds) to check for recent subscriptions when processing 14 | * payment_intent.succeeded events. This helps avoid creating duplicate payment 15 | * records for subscription payments. 16 | */ 17 | const RECENT_SUBSCRIPTION_WINDOW_SECONDS = 10 * 60; // 10 minutes 18 | 19 | export type StripeComponent = ComponentApi; 20 | 21 | export type { RegisterRoutesConfig, StripeEventHandlers }; 22 | 23 | /** 24 | * Stripe Component Client 25 | * 26 | * Provides methods for managing Stripe customers, subscriptions, payments, 27 | * and webhooks through Convex. 28 | */ 29 | export class StripeSubscriptions { 30 | private _apiKey: string; 31 | constructor( 32 | public component: StripeComponent, 33 | options?: { 34 | STRIPE_SECRET_KEY?: string; 35 | }, 36 | ) { 37 | this._apiKey = options?.STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY!; 38 | } 39 | get apiKey() { 40 | if (!this._apiKey) { 41 | throw new Error("STRIPE_SECRET_KEY environment variable is not set"); 42 | } 43 | return this._apiKey; 44 | } 45 | 46 | /** 47 | * Update subscription quantity (for seat-based pricing). 48 | * This will update both Stripe and the local database. 49 | */ 50 | async updateSubscriptionQuantity( 51 | ctx: ActionCtx, 52 | args: { 53 | stripeSubscriptionId: string; 54 | quantity: number; 55 | }, 56 | ) { 57 | // Delegate to the component's public action, passing the API key 58 | await ctx.runAction(this.component.public.updateSubscriptionQuantity, { 59 | stripeSubscriptionId: args.stripeSubscriptionId, 60 | quantity: args.quantity, 61 | apiKey: this.apiKey, 62 | }); 63 | 64 | return null; 65 | } 66 | 67 | /** 68 | * Cancel a subscription either immediately or at period end. 69 | * Updates both Stripe and the local database. 70 | */ 71 | async cancelSubscription( 72 | ctx: ActionCtx, 73 | args: { 74 | stripeSubscriptionId: string; 75 | cancelAtPeriodEnd?: boolean; 76 | }, 77 | ) { 78 | const stripe = new StripeSDK(this.apiKey); 79 | const cancelAtPeriodEnd = args.cancelAtPeriodEnd ?? true; 80 | 81 | let subscription: StripeSDK.Subscription; 82 | 83 | if (cancelAtPeriodEnd) { 84 | subscription = await stripe.subscriptions.update( 85 | args.stripeSubscriptionId, 86 | { 87 | cancel_at_period_end: true, 88 | }, 89 | ); 90 | } else { 91 | subscription = await stripe.subscriptions.cancel( 92 | args.stripeSubscriptionId, 93 | ); 94 | } 95 | 96 | // Update local database immediately (don't wait for webhook) 97 | await ctx.runMutation(this.component.private.handleSubscriptionUpdated, { 98 | stripeSubscriptionId: subscription.id, 99 | status: subscription.status, 100 | currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, 101 | cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, 102 | quantity: subscription.items.data[0]?.quantity ?? 1, 103 | metadata: subscription.metadata || {}, 104 | }); 105 | 106 | return null; 107 | } 108 | 109 | /** 110 | * Reactivate a subscription that was set to cancel at period end. 111 | * Updates both Stripe and the local database. 112 | */ 113 | async reactivateSubscription( 114 | ctx: ActionCtx, 115 | args: { 116 | stripeSubscriptionId: string; 117 | }, 118 | ) { 119 | const stripe = new StripeSDK(this.apiKey); 120 | 121 | // Reactivate by setting cancel_at_period_end to false 122 | const subscription = await stripe.subscriptions.update( 123 | args.stripeSubscriptionId, 124 | { 125 | cancel_at_period_end: false, 126 | }, 127 | ); 128 | 129 | // Update local database immediately 130 | await ctx.runMutation(this.component.private.handleSubscriptionUpdated, { 131 | stripeSubscriptionId: subscription.id, 132 | status: subscription.status, 133 | currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, 134 | cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, 135 | quantity: subscription.items.data[0]?.quantity ?? 1, 136 | metadata: subscription.metadata || {}, 137 | }); 138 | 139 | return null; 140 | } 141 | 142 | // ============================================================================ 143 | // CHECKOUT & PAYMENTS 144 | // ============================================================================ 145 | 146 | /** 147 | * Create a Stripe Checkout session for one-time payments or subscriptions. 148 | */ 149 | async createCheckoutSession( 150 | ctx: ActionCtx, 151 | args: { 152 | priceId: string; 153 | customerId?: string; 154 | mode: "payment" | "subscription" | "setup"; 155 | successUrl: string; 156 | cancelUrl: string; 157 | quantity?: number; 158 | metadata?: Record; 159 | /** Metadata to attach to the subscription (only for mode: "subscription") */ 160 | subscriptionMetadata?: Record; 161 | /** Metadata to attach to the payment intent (only for mode: "payment") */ 162 | paymentIntentMetadata?: Record; 163 | }, 164 | ) { 165 | const stripe = new StripeSDK(this.apiKey); 166 | 167 | const sessionParams: StripeSDK.Checkout.SessionCreateParams = { 168 | mode: args.mode, 169 | line_items: [ 170 | { 171 | price: args.priceId, 172 | quantity: args.quantity ?? 1, 173 | }, 174 | ], 175 | success_url: args.successUrl, 176 | cancel_url: args.cancelUrl, 177 | metadata: args.metadata || {}, 178 | }; 179 | 180 | if (args.customerId) { 181 | sessionParams.customer = args.customerId; 182 | } 183 | 184 | // Add subscription metadata for linking userId/orgId 185 | if (args.mode === "subscription" && args.subscriptionMetadata) { 186 | sessionParams.subscription_data = { 187 | metadata: args.subscriptionMetadata, 188 | }; 189 | } 190 | 191 | // Add payment intent metadata for linking userId/orgId 192 | if (args.mode === "payment" && args.paymentIntentMetadata) { 193 | sessionParams.payment_intent_data = { 194 | metadata: args.paymentIntentMetadata, 195 | }; 196 | } 197 | 198 | const session = await stripe.checkout.sessions.create(sessionParams); 199 | 200 | return { 201 | sessionId: session.id, 202 | url: session.url, 203 | }; 204 | } 205 | 206 | /** 207 | * Create a new Stripe customer. 208 | * 209 | * @param args.idempotencyKey - Optional key to prevent duplicate customer creation. 210 | * If two requests come in with the same key, Stripe returns the same customer. 211 | * Recommended: pass `userId` to prevent race conditions. 212 | */ 213 | async createCustomer( 214 | ctx: ActionCtx, 215 | args: { 216 | email?: string; 217 | name?: string; 218 | metadata?: Record; 219 | idempotencyKey?: string; 220 | }, 221 | ) { 222 | const stripe = new StripeSDK(this.apiKey); 223 | 224 | // Use idempotency key to prevent duplicate customers from race conditions 225 | const requestOptions = args.idempotencyKey 226 | ? { idempotencyKey: `create_customer_${args.idempotencyKey}` } 227 | : undefined; 228 | 229 | const customer = await stripe.customers.create( 230 | { 231 | email: args.email, 232 | name: args.name, 233 | metadata: args.metadata, 234 | }, 235 | requestOptions, 236 | ); 237 | 238 | // Store in our database 239 | await ctx.runMutation(this.component.public.createOrUpdateCustomer, { 240 | stripeCustomerId: customer.id, 241 | email: args.email, 242 | name: args.name, 243 | metadata: args.metadata, 244 | }); 245 | 246 | return { 247 | customerId: customer.id, 248 | }; 249 | } 250 | 251 | /** 252 | * Get or create a Stripe customer for a user. 253 | * Checks existing subscriptions/payments first to avoid duplicates. 254 | */ 255 | async getOrCreateCustomer( 256 | ctx: ActionCtx, 257 | args: { 258 | userId: string; 259 | email?: string; 260 | name?: string; 261 | }, 262 | ) { 263 | // Check if customer exists by userId in subscriptions 264 | const existingSubs = await ctx.runQuery( 265 | this.component.public.listSubscriptionsByUserId, 266 | { userId: args.userId }, 267 | ); 268 | 269 | if (existingSubs.length > 0) { 270 | return { customerId: existingSubs[0].stripeCustomerId, isNew: false }; 271 | } 272 | 273 | // Check existing payments 274 | const existingPayments = await ctx.runQuery( 275 | this.component.public.listPaymentsByUserId, 276 | { userId: args.userId }, 277 | ); 278 | 279 | if (existingPayments.length > 0 && existingPayments[0].stripeCustomerId) { 280 | return { customerId: existingPayments[0].stripeCustomerId, isNew: false }; 281 | } 282 | 283 | // Create a new customer with idempotency key to prevent race conditions 284 | const result = await this.createCustomer(ctx, { 285 | email: args.email, 286 | name: args.name, 287 | metadata: { userId: args.userId }, 288 | idempotencyKey: args.userId, // Prevents duplicate customers if called concurrently 289 | }); 290 | 291 | return { customerId: result.customerId, isNew: true }; 292 | } 293 | 294 | /** 295 | * Create a Stripe Customer Portal session for managing subscriptions. 296 | */ 297 | async createCustomerPortalSession( 298 | ctx: ActionCtx, 299 | args: { 300 | customerId: string; 301 | returnUrl: string; 302 | }, 303 | ) { 304 | const stripe = new StripeSDK(this.apiKey); 305 | 306 | const session = await stripe.billingPortal.sessions.create({ 307 | customer: args.customerId, 308 | return_url: args.returnUrl, 309 | }); 310 | 311 | return { 312 | url: session.url, 313 | }; 314 | } 315 | 316 | // ============================================================================ 317 | // WEBHOOK REGISTRATION 318 | // ============================================================================ 319 | } 320 | /** 321 | * Register webhook routes with the HTTP router. 322 | * This simplifies webhook setup by handling signature verification 323 | * and routing events to the appropriate handlers automatically. 324 | * 325 | * @param http - The HTTP router instance 326 | * @param config - Optional configuration for webhook path and event handlers 327 | * 328 | * @example 329 | * ```typescript 330 | * // convex/http.ts 331 | * import { httpRouter } from "convex/server"; 332 | * import { stripe } from "./stripe"; 333 | * 334 | * const http = httpRouter(); 335 | * 336 | * stripe.registerRoutes(http, { 337 | * events: { 338 | * "customer.subscription.updated": async (ctx, event) => { 339 | * // Your custom logic after default handling 340 | * console.log("Subscription updated:", event.data.object); 341 | * }, 342 | * }, 343 | * }); 344 | * 345 | * export default http; 346 | * ``` 347 | */ 348 | export function registerRoutes( 349 | http: HttpRouter, 350 | component: ComponentApi, 351 | config?: RegisterRoutesConfig, 352 | ) { 353 | const webhookPath = config?.webhookPath ?? "/stripe/webhook"; 354 | const eventHandlers = config?.events ?? {}; 355 | 356 | http.route({ 357 | path: webhookPath, 358 | method: "POST", 359 | handler: httpActionGeneric(async (ctx, req) => { 360 | const webhookSecret = 361 | config?.STRIPE_WEBHOOK_SECRET || process.env.STRIPE_WEBHOOK_SECRET; 362 | 363 | if (!webhookSecret) { 364 | console.error("❌ STRIPE_WEBHOOK_SECRET is not set"); 365 | return new Response("Webhook secret not configured", { status: 500 }); 366 | } 367 | 368 | const signature = req.headers.get("stripe-signature"); 369 | if (!signature) { 370 | console.error("❌ No Stripe signature in headers"); 371 | return new Response("No signature provided", { status: 400 }); 372 | } 373 | 374 | const body = await req.text(); 375 | 376 | const apiKey = config?.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY; 377 | 378 | if (!apiKey) { 379 | console.error("❌ STRIPE_SECRET_KEY is not set"); 380 | return new Response("Stripe secret key not configured", { 381 | status: 500, 382 | }); 383 | } 384 | 385 | const stripe = new StripeSDK(apiKey); 386 | 387 | // Verify webhook signature 388 | let event: StripeSDK.Event; 389 | try { 390 | event = await stripe.webhooks.constructEventAsync( 391 | body, 392 | signature, 393 | webhookSecret, 394 | ); 395 | } catch (err) { 396 | console.error("❌ Webhook signature verification failed:", err); 397 | return new Response( 398 | `Webhook signature verification failed: ${err instanceof Error ? err.message : String(err)}`, 399 | { status: 400 }, 400 | ); 401 | } 402 | 403 | // Process the event with default handlers 404 | try { 405 | await processEvent(ctx, component, event, stripe); 406 | 407 | // Call generic event handler if provided 408 | if (config?.onEvent) { 409 | await config.onEvent(ctx, event); 410 | } 411 | 412 | // Call custom event handler if provided 413 | const eventType = event.type; 414 | const customHandler: 415 | | ((ctx: any, event: any) => Promise) 416 | | undefined = eventHandlers[eventType] as any; 417 | if (customHandler) { 418 | await customHandler(ctx, event); 419 | } 420 | } catch (error) { 421 | console.error("❌ Error processing webhook:", error); 422 | return new Response("Error processing webhook", { status: 500 }); 423 | } 424 | 425 | return new Response(JSON.stringify({ received: true }), { 426 | status: 200, 427 | headers: { "Content-Type": "application/json" }, 428 | }); 429 | }), 430 | }); 431 | } 432 | 433 | /** 434 | * Internal method to process Stripe webhook events with default handling. 435 | * This handles the database syncing for all supported event types. 436 | */ 437 | async function processEvent( 438 | ctx: MutationCtx | ActionCtx, 439 | component: ComponentApi, 440 | event: StripeSDK.Event, 441 | stripe: StripeSDK, 442 | ): Promise { 443 | switch (event.type) { 444 | case "customer.created": 445 | case "customer.updated": { 446 | const customer = event.data.object as StripeSDK.Customer; 447 | const handler = 448 | event.type === "customer.created" 449 | ? component.private.handleCustomerCreated 450 | : component.private.handleCustomerUpdated; 451 | 452 | await ctx.runMutation(handler, { 453 | stripeCustomerId: customer.id, 454 | email: customer.email || undefined, 455 | name: customer.name || undefined, 456 | metadata: customer.metadata, 457 | }); 458 | break; 459 | } 460 | 461 | case "customer.subscription.created": { 462 | const subscription = event.data.object as StripeSDK.Subscription; 463 | await ctx.runMutation(component.private.handleSubscriptionCreated, { 464 | stripeSubscriptionId: subscription.id, 465 | stripeCustomerId: subscription.customer as string, 466 | status: subscription.status, 467 | currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, 468 | cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, 469 | quantity: subscription.items.data[0]?.quantity ?? 1, 470 | priceId: subscription.items.data[0]?.price.id || "", 471 | metadata: subscription.metadata || {}, 472 | }); 473 | break; 474 | } 475 | 476 | case "customer.subscription.updated": { 477 | const subscription = event.data.object as any; 478 | await ctx.runMutation(component.private.handleSubscriptionUpdated, { 479 | stripeSubscriptionId: subscription.id, 480 | status: subscription.status, 481 | currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, 482 | cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, 483 | quantity: subscription.items.data[0]?.quantity ?? 1, 484 | metadata: subscription.metadata || {}, 485 | }); 486 | break; 487 | } 488 | 489 | case "customer.subscription.deleted": { 490 | const subscription = event.data.object as StripeSDK.Subscription; 491 | await ctx.runMutation(component.private.handleSubscriptionDeleted, { 492 | stripeSubscriptionId: subscription.id, 493 | }); 494 | break; 495 | } 496 | 497 | case "checkout.session.completed": { 498 | const session = event.data.object as StripeSDK.Checkout.Session; 499 | await ctx.runMutation(component.private.handleCheckoutSessionCompleted, { 500 | stripeCheckoutSessionId: session.id, 501 | stripeCustomerId: session.customer 502 | ? (session.customer as string) 503 | : undefined, 504 | mode: session.mode || "payment", 505 | metadata: session.metadata || undefined, 506 | }); 507 | 508 | // For payment mode, link the payment to the customer if we have both 509 | if ( 510 | session.mode === "payment" && 511 | session.customer && 512 | session.payment_intent 513 | ) { 514 | await ctx.runMutation(component.private.updatePaymentCustomer, { 515 | stripePaymentIntentId: session.payment_intent as string, 516 | stripeCustomerId: session.customer as string, 517 | }); 518 | } 519 | 520 | // For subscription mode, fetch and store the latest invoice 521 | if (session.mode === "subscription" && session.subscription) { 522 | try { 523 | const subscription = await stripe.subscriptions.retrieve( 524 | session.subscription as string, 525 | ); 526 | if (subscription.latest_invoice) { 527 | const invoice = await stripe.invoices.retrieve( 528 | subscription.latest_invoice as string, 529 | ); 530 | await ctx.runMutation(component.private.handleInvoiceCreated, { 531 | stripeInvoiceId: invoice.id, 532 | stripeCustomerId: invoice.customer as string, 533 | stripeSubscriptionId: subscription.id, 534 | status: invoice.status || "paid", 535 | amountDue: invoice.amount_due, 536 | amountPaid: invoice.amount_paid, 537 | created: invoice.created, 538 | }); 539 | } 540 | } catch (err) { 541 | console.error("Error fetching invoice for subscription:", err); 542 | } 543 | } 544 | break; 545 | } 546 | 547 | case "invoice.created": 548 | case "invoice.finalized": { 549 | const invoice = event.data.object as StripeSDK.Invoice; 550 | await ctx.runMutation(component.private.handleInvoiceCreated, { 551 | stripeInvoiceId: invoice.id, 552 | stripeCustomerId: invoice.customer as string, 553 | stripeSubscriptionId: (invoice as any).subscription as 554 | | string 555 | | undefined, 556 | status: invoice.status || "open", 557 | amountDue: invoice.amount_due, 558 | amountPaid: invoice.amount_paid, 559 | created: invoice.created, 560 | }); 561 | break; 562 | } 563 | 564 | case "invoice.paid": 565 | case "invoice.payment_succeeded": { 566 | const invoice = event.data.object as any; 567 | await ctx.runMutation(component.private.handleInvoicePaid, { 568 | stripeInvoiceId: invoice.id, 569 | amountPaid: invoice.amount_paid, 570 | }); 571 | break; 572 | } 573 | 574 | case "invoice.payment_failed": { 575 | const invoice = event.data.object as StripeSDK.Invoice; 576 | await ctx.runMutation(component.private.handleInvoicePaymentFailed, { 577 | stripeInvoiceId: invoice.id, 578 | }); 579 | break; 580 | } 581 | 582 | case "payment_intent.succeeded": { 583 | const paymentIntent = event.data.object as any; 584 | 585 | // Check if this is a subscription payment 586 | if (paymentIntent.invoice) { 587 | try { 588 | const invoice = await stripe.invoices.retrieve( 589 | paymentIntent.invoice as string, 590 | ); 591 | if ((invoice as any).subscription) { 592 | console.log( 593 | "⏭️ Skipping payment_intent.succeeded - subscription payment", 594 | ); 595 | break; 596 | } 597 | } catch (err) { 598 | console.error("Error checking invoice:", err); 599 | } 600 | } 601 | 602 | // Check for recent subscriptions 603 | if (paymentIntent.customer) { 604 | const recentSubscriptions = await ctx.runQuery( 605 | component.public.listSubscriptions, 606 | { 607 | stripeCustomerId: paymentIntent.customer as string, 608 | }, 609 | ); 610 | 611 | const recentWindowStart = 612 | Date.now() / 1000 - RECENT_SUBSCRIPTION_WINDOW_SECONDS; 613 | const recentSubscription = recentSubscriptions.find( 614 | (sub: any) => sub._creationTime > recentWindowStart, 615 | ); 616 | 617 | if (recentSubscription) { 618 | console.log( 619 | "⏭️ Skipping payment_intent.succeeded - recent subscription", 620 | ); 621 | break; 622 | } 623 | } 624 | 625 | await ctx.runMutation(component.private.handlePaymentIntentSucceeded, { 626 | stripePaymentIntentId: paymentIntent.id, 627 | stripeCustomerId: paymentIntent.customer 628 | ? (paymentIntent.customer as string) 629 | : undefined, 630 | amount: paymentIntent.amount, 631 | currency: paymentIntent.currency, 632 | status: paymentIntent.status, 633 | created: paymentIntent.created, 634 | metadata: paymentIntent.metadata || {}, 635 | }); 636 | break; 637 | } 638 | 639 | default: 640 | console.log(`ℹ️ Unhandled event type: ${event.type}`); 641 | } 642 | } 643 | 644 | export default StripeSubscriptions; 645 | -------------------------------------------------------------------------------- /.cursor/rules/convex_rules.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples 3 | globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx 4 | --- 5 | 6 | # Convex guidelines 7 | ## Function guidelines 8 | ### New function syntax 9 | - ALWAYS use the new function syntax for Convex functions. For example: 10 | ```typescript 11 | import { query } from "./_generated/server"; 12 | import { v } from "convex/values"; 13 | export const f = query({ 14 | args: {}, 15 | returns: v.null(), 16 | handler: async (ctx, args) => { 17 | // Function body 18 | }, 19 | }); 20 | ``` 21 | 22 | ### Http endpoint syntax 23 | - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: 24 | ```typescript 25 | import { httpRouter } from "convex/server"; 26 | import { httpAction } from "./_generated/server"; 27 | const http = httpRouter(); 28 | http.route({ 29 | path: "/echo", 30 | method: "POST", 31 | handler: httpAction(async (ctx, req) => { 32 | const body = await req.bytes(); 33 | return new Response(body, { status: 200 }); 34 | }), 35 | }); 36 | ``` 37 | - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. 38 | 39 | ### Validators 40 | - Below is an example of an array validator: 41 | ```typescript 42 | import { mutation } from "./_generated/server"; 43 | import { v } from "convex/values"; 44 | 45 | export default mutation({ 46 | args: { 47 | simpleArray: v.array(v.union(v.string(), v.number())), 48 | }, 49 | handler: async (ctx, args) => { 50 | //... 51 | }, 52 | }); 53 | ``` 54 | - Below is an example of a schema with validators that codify a discriminated union type: 55 | ```typescript 56 | import { defineSchema, defineTable } from "convex/server"; 57 | import { v } from "convex/values"; 58 | 59 | export default defineSchema({ 60 | results: defineTable( 61 | v.union( 62 | v.object({ 63 | kind: v.literal("error"), 64 | errorMessage: v.string(), 65 | }), 66 | v.object({ 67 | kind: v.literal("success"), 68 | value: v.number(), 69 | }), 70 | ), 71 | ) 72 | }); 73 | ``` 74 | - Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value: 75 | ```typescript 76 | import { query } from "./_generated/server"; 77 | import { v } from "convex/values"; 78 | 79 | export const exampleQuery = query({ 80 | args: {}, 81 | returns: v.null(), 82 | handler: async (ctx, args) => { 83 | console.log("This query returns a null value"); 84 | return null; 85 | }, 86 | }); 87 | ``` 88 | - Here are the valid Convex types along with their respective validators: 89 | Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | 90 | | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 91 | | Id | string | `doc._id` | `v.id(tableName)` | | 92 | | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | 93 | | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | 94 | | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | 95 | | Boolean | boolean | `true` | `v.boolean()` | 96 | | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | 97 | | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | 98 | | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | 99 | | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | 100 | | Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | 101 | 102 | ### Function registration 103 | - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. 104 | - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. 105 | - You CANNOT register a function through the `api` or `internal` objects. 106 | - ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator. 107 | - If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`. 108 | 109 | ### Function calling 110 | - Use `ctx.runQuery` to call a query from a query, mutation, or action. 111 | - Use `ctx.runMutation` to call a mutation from a mutation or action. 112 | - Use `ctx.runAction` to call an action from an action. 113 | - ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. 114 | - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. 115 | - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. 116 | - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, 117 | ``` 118 | export const f = query({ 119 | args: { name: v.string() }, 120 | returns: v.string(), 121 | handler: async (ctx, args) => { 122 | return "Hello " + args.name; 123 | }, 124 | }); 125 | 126 | export const g = query({ 127 | args: {}, 128 | returns: v.null(), 129 | handler: async (ctx, args) => { 130 | const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); 131 | return null; 132 | }, 133 | }); 134 | ``` 135 | 136 | ### Function references 137 | - Function references are pointers to registered Convex functions. 138 | - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. 139 | - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. 140 | - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. 141 | - A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. 142 | - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. 143 | 144 | ### Api design 145 | - Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory. 146 | - Use `query`, `mutation`, and `action` to define public functions. 147 | - Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions. 148 | 149 | ### Pagination 150 | - Paginated queries are queries that return a list of results in incremental pages. 151 | - You can define pagination using the following syntax: 152 | 153 | ```ts 154 | import { v } from "convex/values"; 155 | import { query, mutation } from "./_generated/server"; 156 | import { paginationOptsValidator } from "convex/server"; 157 | export const listWithExtraArg = query({ 158 | args: { paginationOpts: paginationOptsValidator, author: v.string() }, 159 | handler: async (ctx, args) => { 160 | return await ctx.db 161 | .query("messages") 162 | .filter((q) => q.eq(q.field("author"), args.author)) 163 | .order("desc") 164 | .paginate(args.paginationOpts); 165 | }, 166 | }); 167 | ``` 168 | Note: `paginationOpts` is an object with the following properties: 169 | - `numItems`: the maximum number of documents to return (the validator is `v.number()`) 170 | - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) 171 | - A query that ends in `.paginate()` returns an object that has the following properties: 172 | - page (contains an array of documents that you fetches) 173 | - isDone (a boolean that represents whether or not this is the last page of documents) 174 | - continueCursor (a string that represents the cursor to use to fetch the next page of documents) 175 | 176 | 177 | ## Validator guidelines 178 | - `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead. 179 | - Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported. 180 | 181 | ## Schema guidelines 182 | - Always define your schema in `convex/schema.ts`. 183 | - Always import the schema definition functions from `convex/server`. 184 | - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. 185 | - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". 186 | - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. 187 | 188 | ## Typescript guidelines 189 | - You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. 190 | - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: 191 | ```ts 192 | import { query } from "./_generated/server"; 193 | import { Doc, Id } from "./_generated/dataModel"; 194 | 195 | export const exampleQuery = query({ 196 | args: { userIds: v.array(v.id("users")) }, 197 | returns: v.record(v.id("users"), v.string()), 198 | handler: async (ctx, args) => { 199 | const idToUsername: Record, string> = {}; 200 | for (const userId of args.userIds) { 201 | const user = await ctx.db.get(userId); 202 | if (user) { 203 | idToUsername[user._id] = user.username; 204 | } 205 | } 206 | 207 | return idToUsername; 208 | }, 209 | }); 210 | ``` 211 | - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. 212 | - Always use `as const` for string literals in discriminated union types. 213 | - When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];` 214 | - When using the `Record` type, make sure to always define your records as `const record: Record = {...};` 215 | - Always add `@types/node` to your `package.json` when using any Node.js built-in modules. 216 | 217 | ## Full text search guidelines 218 | - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: 219 | 220 | const messages = await ctx.db 221 | .query("messages") 222 | .withSearchIndex("search_body", (q) => 223 | q.search("body", "hello hi").eq("channel", "#general"), 224 | ) 225 | .take(10); 226 | 227 | ## Query guidelines 228 | - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. 229 | - Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result. 230 | - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. 231 | - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. 232 | ### Ordering 233 | - By default Convex always returns documents in ascending `_creationTime` order. 234 | - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. 235 | - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. 236 | 237 | 238 | ## Mutation guidelines 239 | - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. 240 | - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. 241 | 242 | ## Action guidelines 243 | - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. 244 | - Never use `ctx.db` inside of an action. Actions don't have access to the database. 245 | - Below is an example of the syntax for an action: 246 | ```ts 247 | import { action } from "./_generated/server"; 248 | 249 | export const exampleAction = action({ 250 | args: {}, 251 | returns: v.null(), 252 | handler: async (ctx, args) => { 253 | console.log("This action does not return anything"); 254 | return null; 255 | }, 256 | }); 257 | ``` 258 | 259 | ## Scheduling guidelines 260 | ### Cron guidelines 261 | - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. 262 | - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. 263 | - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, 264 | ```ts 265 | import { cronJobs } from "convex/server"; 266 | import { internal } from "./_generated/api"; 267 | import { internalAction } from "./_generated/server"; 268 | 269 | const empty = internalAction({ 270 | args: {}, 271 | returns: v.null(), 272 | handler: async (ctx, args) => { 273 | console.log("empty"); 274 | }, 275 | }); 276 | 277 | const crons = cronJobs(); 278 | 279 | // Run `internal.crons.empty` every two hours. 280 | crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); 281 | 282 | export default crons; 283 | ``` 284 | - You can register Convex functions within `crons.ts` just like any other file. 285 | - If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. 286 | 287 | 288 | ## File storage guidelines 289 | - Convex includes file storage for large files like images, videos, and PDFs. 290 | - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. 291 | - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. 292 | 293 | Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. 294 | ``` 295 | import { query } from "./_generated/server"; 296 | import { Id } from "./_generated/dataModel"; 297 | 298 | type FileMetadata = { 299 | _id: Id<"_storage">; 300 | _creationTime: number; 301 | contentType?: string; 302 | sha256: string; 303 | size: number; 304 | } 305 | 306 | export const exampleQuery = query({ 307 | args: { fileId: v.id("_storage") }, 308 | returns: v.null(), 309 | handler: async (ctx, args) => { 310 | const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId); 311 | console.log(metadata); 312 | return null; 313 | }, 314 | }); 315 | ``` 316 | - Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. 317 | 318 | 319 | # Examples: 320 | ## Example: chat-app 321 | 322 | ### Task 323 | ``` 324 | Create a real-time chat application backend with AI responses. The app should: 325 | - Allow creating users with names 326 | - Support multiple chat channels 327 | - Enable users to send messages to channels 328 | - Automatically generate AI responses to user messages 329 | - Show recent message history 330 | 331 | The backend should provide APIs for: 332 | 1. User management (creation) 333 | 2. Channel management (creation) 334 | 3. Message operations (sending, listing) 335 | 4. AI response generation using OpenAI's GPT-4 336 | 337 | Messages should be stored with their channel, author, and content. The system should maintain message order 338 | and limit history display to the 10 most recent messages per channel. 339 | 340 | ``` 341 | 342 | ### Analysis 343 | 1. Task Requirements Summary: 344 | - Build a real-time chat backend with AI integration 345 | - Support user creation 346 | - Enable channel-based conversations 347 | - Store and retrieve messages with proper ordering 348 | - Generate AI responses automatically 349 | 350 | 2. Main Components Needed: 351 | - Database tables: users, channels, messages 352 | - Public APIs for user/channel management 353 | - Message handling functions 354 | - Internal AI response generation system 355 | - Context loading for AI responses 356 | 357 | 3. Public API and Internal Functions Design: 358 | Public Mutations: 359 | - createUser: 360 | - file path: convex/index.ts 361 | - arguments: {name: v.string()} 362 | - returns: v.object({userId: v.id("users")}) 363 | - purpose: Create a new user with a given name 364 | - createChannel: 365 | - file path: convex/index.ts 366 | - arguments: {name: v.string()} 367 | - returns: v.object({channelId: v.id("channels")}) 368 | - purpose: Create a new channel with a given name 369 | - sendMessage: 370 | - file path: convex/index.ts 371 | - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()} 372 | - returns: v.null() 373 | - purpose: Send a message to a channel and schedule a response from the AI 374 | 375 | Public Queries: 376 | - listMessages: 377 | - file path: convex/index.ts 378 | - arguments: {channelId: v.id("channels")} 379 | - returns: v.array(v.object({ 380 | _id: v.id("messages"), 381 | _creationTime: v.number(), 382 | channelId: v.id("channels"), 383 | authorId: v.optional(v.id("users")), 384 | content: v.string(), 385 | })) 386 | - purpose: List the 10 most recent messages from a channel in descending creation order 387 | 388 | Internal Functions: 389 | - generateResponse: 390 | - file path: convex/index.ts 391 | - arguments: {channelId: v.id("channels")} 392 | - returns: v.null() 393 | - purpose: Generate a response from the AI for a given channel 394 | - loadContext: 395 | - file path: convex/index.ts 396 | - arguments: {channelId: v.id("channels")} 397 | - returns: v.array(v.object({ 398 | _id: v.id("messages"), 399 | _creationTime: v.number(), 400 | channelId: v.id("channels"), 401 | authorId: v.optional(v.id("users")), 402 | content: v.string(), 403 | })) 404 | - writeAgentResponse: 405 | - file path: convex/index.ts 406 | - arguments: {channelId: v.id("channels"), content: v.string()} 407 | - returns: v.null() 408 | - purpose: Write an AI response to a given channel 409 | 410 | 4. Schema Design: 411 | - users 412 | - validator: { name: v.string() } 413 | - indexes: 414 | - channels 415 | - validator: { name: v.string() } 416 | - indexes: 417 | - messages 418 | - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() } 419 | - indexes 420 | - by_channel: ["channelId"] 421 | 422 | 5. Background Processing: 423 | - AI response generation runs asynchronously after each user message 424 | - Uses OpenAI's GPT-4 to generate contextual responses 425 | - Maintains conversation context using recent message history 426 | 427 | 428 | ### Implementation 429 | 430 | #### package.json 431 | ```typescript 432 | { 433 | "name": "chat-app", 434 | "description": "This example shows how to build a chat app without authentication.", 435 | "version": "1.0.0", 436 | "dependencies": { 437 | "convex": "^1.17.4", 438 | "openai": "^4.79.0" 439 | }, 440 | "devDependencies": { 441 | "typescript": "^5.7.3" 442 | } 443 | } 444 | ``` 445 | 446 | #### tsconfig.json 447 | ```typescript 448 | { 449 | "compilerOptions": { 450 | "target": "ESNext", 451 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 452 | "skipLibCheck": true, 453 | "allowSyntheticDefaultImports": true, 454 | "strict": true, 455 | "forceConsistentCasingInFileNames": true, 456 | "module": "ESNext", 457 | "moduleResolution": "Bundler", 458 | "resolveJsonModule": true, 459 | "isolatedModules": true, 460 | "allowImportingTsExtensions": true, 461 | "noEmit": true, 462 | "jsx": "react-jsx" 463 | }, 464 | "exclude": ["convex"], 465 | "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"] 466 | } 467 | ``` 468 | 469 | #### convex/index.ts 470 | ```typescript 471 | import { 472 | query, 473 | mutation, 474 | internalQuery, 475 | internalMutation, 476 | internalAction, 477 | } from "./_generated/server"; 478 | import { v } from "convex/values"; 479 | import OpenAI from "openai"; 480 | import { internal } from "./_generated/api"; 481 | 482 | /** 483 | * Create a user with a given name. 484 | */ 485 | export const createUser = mutation({ 486 | args: { 487 | name: v.string(), 488 | }, 489 | returns: v.id("users"), 490 | handler: async (ctx, args) => { 491 | return await ctx.db.insert("users", { name: args.name }); 492 | }, 493 | }); 494 | 495 | /** 496 | * Create a channel with a given name. 497 | */ 498 | export const createChannel = mutation({ 499 | args: { 500 | name: v.string(), 501 | }, 502 | returns: v.id("channels"), 503 | handler: async (ctx, args) => { 504 | return await ctx.db.insert("channels", { name: args.name }); 505 | }, 506 | }); 507 | 508 | /** 509 | * List the 10 most recent messages from a channel in descending creation order. 510 | */ 511 | export const listMessages = query({ 512 | args: { 513 | channelId: v.id("channels"), 514 | }, 515 | returns: v.array( 516 | v.object({ 517 | _id: v.id("messages"), 518 | _creationTime: v.number(), 519 | channelId: v.id("channels"), 520 | authorId: v.optional(v.id("users")), 521 | content: v.string(), 522 | }), 523 | ), 524 | handler: async (ctx, args) => { 525 | const messages = await ctx.db 526 | .query("messages") 527 | .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) 528 | .order("desc") 529 | .take(10); 530 | return messages; 531 | }, 532 | }); 533 | 534 | /** 535 | * Send a message to a channel and schedule a response from the AI. 536 | */ 537 | export const sendMessage = mutation({ 538 | args: { 539 | channelId: v.id("channels"), 540 | authorId: v.id("users"), 541 | content: v.string(), 542 | }, 543 | returns: v.null(), 544 | handler: async (ctx, args) => { 545 | const channel = await ctx.db.get(args.channelId); 546 | if (!channel) { 547 | throw new Error("Channel not found"); 548 | } 549 | const user = await ctx.db.get(args.authorId); 550 | if (!user) { 551 | throw new Error("User not found"); 552 | } 553 | await ctx.db.insert("messages", { 554 | channelId: args.channelId, 555 | authorId: args.authorId, 556 | content: args.content, 557 | }); 558 | await ctx.scheduler.runAfter(0, internal.index.generateResponse, { 559 | channelId: args.channelId, 560 | }); 561 | return null; 562 | }, 563 | }); 564 | 565 | const openai = new OpenAI(); 566 | 567 | export const generateResponse = internalAction({ 568 | args: { 569 | channelId: v.id("channels"), 570 | }, 571 | returns: v.null(), 572 | handler: async (ctx, args) => { 573 | const context = await ctx.runQuery(internal.index.loadContext, { 574 | channelId: args.channelId, 575 | }); 576 | const response = await openai.chat.completions.create({ 577 | model: "gpt-4o", 578 | messages: context, 579 | }); 580 | const content = response.choices[0].message.content; 581 | if (!content) { 582 | throw new Error("No content in response"); 583 | } 584 | await ctx.runMutation(internal.index.writeAgentResponse, { 585 | channelId: args.channelId, 586 | content, 587 | }); 588 | return null; 589 | }, 590 | }); 591 | 592 | export const loadContext = internalQuery({ 593 | args: { 594 | channelId: v.id("channels"), 595 | }, 596 | returns: v.array( 597 | v.object({ 598 | role: v.union(v.literal("user"), v.literal("assistant")), 599 | content: v.string(), 600 | }), 601 | ), 602 | handler: async (ctx, args) => { 603 | const channel = await ctx.db.get(args.channelId); 604 | if (!channel) { 605 | throw new Error("Channel not found"); 606 | } 607 | const messages = await ctx.db 608 | .query("messages") 609 | .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) 610 | .order("desc") 611 | .take(10); 612 | 613 | const result = []; 614 | for (const message of messages) { 615 | if (message.authorId) { 616 | const user = await ctx.db.get(message.authorId); 617 | if (!user) { 618 | throw new Error("User not found"); 619 | } 620 | result.push({ 621 | role: "user" as const, 622 | content: `${user.name}: ${message.content}`, 623 | }); 624 | } else { 625 | result.push({ role: "assistant" as const, content: message.content }); 626 | } 627 | } 628 | return result; 629 | }, 630 | }); 631 | 632 | export const writeAgentResponse = internalMutation({ 633 | args: { 634 | channelId: v.id("channels"), 635 | content: v.string(), 636 | }, 637 | returns: v.null(), 638 | handler: async (ctx, args) => { 639 | await ctx.db.insert("messages", { 640 | channelId: args.channelId, 641 | content: args.content, 642 | }); 643 | return null; 644 | }, 645 | }); 646 | ``` 647 | 648 | #### convex/schema.ts 649 | ```typescript 650 | import { defineSchema, defineTable } from "convex/server"; 651 | import { v } from "convex/values"; 652 | 653 | export default defineSchema({ 654 | channels: defineTable({ 655 | name: v.string(), 656 | }), 657 | 658 | users: defineTable({ 659 | name: v.string(), 660 | }), 661 | 662 | messages: defineTable({ 663 | channelId: v.id("channels"), 664 | authorId: v.optional(v.id("users")), 665 | content: v.string(), 666 | }).index("by_channel", ["channelId"]), 667 | }); 668 | ``` 669 | 670 | #### src/App.tsx 671 | ```typescript 672 | export default function App() { 673 | return
Hello World
; 674 | } 675 | ``` --------------------------------------------------------------------------------