├── 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 | 
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 | [](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 | ```
--------------------------------------------------------------------------------