Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is `@convex-dev/stripe`, a Convex component for integrating Stripe payments, subscriptions, and billing. It's published to npm and used as a dependency in Convex applications.

## Common Commands

```bash
# Development (runs backend, frontend example, and watch build concurrently)
npm run dev

# Build component
npm run build

# Run tests
npm run test

# Run single test file
npx vitest run src/client/index.test.ts

# Run tests in watch mode
npm run test:watch

# Type checking
npm run typecheck

# Linting
npm run lint

# Full validation before release
npm run preversion # runs clean, ci, build, test, lint, typecheck
```

## Architecture

### Directory Structure

- `src/component/` - Convex component code that runs on Convex servers
- `schema.ts` - Database schema (customers, subscriptions, payments, invoices, checkout_sessions)
- `public.ts` - Public queries/mutations callable by app code
- `private.ts` - Internal mutations for webhook event processing
- `convex.config.ts` - Component configuration
- `src/client/` - Client SDK used by consuming applications
- `index.ts` - `StripeSubscriptions` class and `registerRoutes` function
- `types.ts` - TypeScript type definitions
- `src/react/` - React hooks (if any)
- `example/` - Example Convex application demonstrating usage
- `convex/` - Example Convex functions using the component

### How It Works

1. **Component Pattern**: This is a Convex component - it exports a config (`convex.config.js`) that apps install via `app.use(stripe)` in their `convex.config.ts`
2. **Client SDK**: The `StripeSubscriptions` class wraps Stripe API calls and syncs data to the component's database
3. **Webhook Handler**: `registerRoutes()` sets up HTTP endpoint for Stripe webhooks, processes events, and stores data

### Key Exports

```typescript
// Main client class
import { StripeSubscriptions, registerRoutes } from "@convex-dev/stripe";

// Component config for convex.config.ts
import stripe from "@convex-dev/stripe/convex.config.js";
```

### Testing

Tests use `convex-test` framework. Test files are co-located with source (`.test.ts` suffix).

## Convex Guidelines

- Always use the new function syntax with `args`, `returns`, and `handler`
- Always include return validators (use `v.null()` for functions that return nothing)
- Use `internalQuery`/`internalMutation`/`internalAction` for private functions
- Never infer type "any" - use explicit types
- Always commit the `_generated` folder when using Convex
- Do NOT use `filter` in queries - use `withIndex` instead
44 changes: 44 additions & 0 deletions example/convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,50 @@ const http = httpRouter();
registerRoutes(http, components.stripe, {
webhookPath: "/stripe/webhook",
events: {
"product.created": async (ctx, event) => {
const product = event.data.object;
console.log("📦 Custom handler: Product created!", {
id: product.id,
name: product.name,
active: product.active,
});
},
"product.updated": async (ctx, event) => {
const product = event.data.object;
console.log("📦 Custom handler: Product updated!", {
id: product.id,
name: product.name,
active: product.active,
});
},
"product.deleted": async (ctx, event) => {
const product = event.data.object;
console.log("📦 Custom handler: Product deleted!", {
id: product.id,
});
},
"price.created": async (ctx, event) => {
const price = event.data.object;
console.log("💵 Custom handler: Price created!", {
id: price.id,
product: price.product,
unitAmount: price.unit_amount,
currency: price.currency,
});
},
"price.updated": async (ctx, event) => {
const price = event.data.object;
console.log("💵 Custom handler: Price updated!", {
id: price.id,
active: price.active,
});
},
"price.deleted": async (ctx, event) => {
const price = event.data.object;
console.log("💵 Custom handler: Price deleted!", {
id: price.id,
});
},
"customer.subscription.updated": async (ctx, event) => {
// Example custom handler: Log subscription updates
const subscription = event.data.object;
Expand Down
220 changes: 216 additions & 4 deletions example/convex/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,218 @@ function getAppUrl(): string {
return url;
}

// ============================================================================
// PRODUCTS & PRICES
// ============================================================================

// Shared validators for products and prices
const productFields = {
stripeProductId: v.string(),
name: v.string(),
description: v.optional(v.string()),
active: v.boolean(),
type: v.optional(v.string()),
defaultPriceId: v.optional(v.string()),
metadata: v.optional(v.any()),
images: v.optional(v.array(v.string())),
};

const priceFields = {
stripePriceId: v.string(),
stripeProductId: v.string(),
active: v.boolean(),
currency: v.string(),
type: v.string(),
unitAmount: v.optional(v.number()),
description: v.optional(v.string()),
lookupKey: v.optional(v.string()),
recurringInterval: v.optional(v.string()),
recurringIntervalCount: v.optional(v.number()),
trialPeriodDays: v.optional(v.number()),
usageType: v.optional(v.string()),
billingScheme: v.optional(v.string()),
tiersMode: v.optional(v.string()),
tiers: v.optional(
v.array(
v.object({
upTo: v.union(v.number(), v.null()),
flatAmount: v.optional(v.number()),
unitAmount: v.optional(v.number()),
}),
),
),
metadata: v.optional(v.any()),
};

/**
* Get a product by its Stripe product ID.
*/
export const getProduct = query({
args: { stripeProductId: v.string() },
returns: v.union(v.object(productFields), v.null()),
handler: async (ctx, args) => {
return await ctx.runQuery(components.stripe.public.getProduct, {
stripeProductId: args.stripeProductId,
});
},
});

/**
* List all products.
*/
export const listProducts = query({
args: {},
returns: v.array(v.object(productFields)),
handler: async (ctx) => {
return await ctx.runQuery(components.stripe.public.listProducts, {});
},
});

/**
* List all active products.
*/
export const listActiveProducts = query({
args: {},
returns: v.array(v.object(productFields)),
handler: async (ctx) => {
return await ctx.runQuery(components.stripe.public.listActiveProducts, {});
},
});

/**
* Get a price by its Stripe price ID.
*/
export const getPrice = query({
args: { stripePriceId: v.string() },
returns: v.union(v.object(priceFields), v.null()),
handler: async (ctx, args) => {
return await ctx.runQuery(components.stripe.public.getPrice, {
stripePriceId: args.stripePriceId,
});
},
});

/**
* Get a price by its lookup key.
*/
export const getPriceByLookupKey = query({
args: { lookupKey: v.string() },
returns: v.union(v.object(priceFields), v.null()),
handler: async (ctx, args) => {
return await ctx.runQuery(components.stripe.public.getPriceByLookupKey, {
lookupKey: args.lookupKey,
});
},
});

/**
* List all prices.
*/
export const listPrices = query({
args: {},
returns: v.array(v.object(priceFields)),
handler: async (ctx) => {
return await ctx.runQuery(components.stripe.public.listPrices, {});
},
});

/**
* List all active prices.
*/
export const listActivePrices = query({
args: {},
returns: v.array(v.object(priceFields)),
handler: async (ctx) => {
return await ctx.runQuery(components.stripe.public.listActivePrices, {});
},
});

/**
* List all prices for a specific product.
*/
export const listPricesByProduct = query({
args: { stripeProductId: v.string() },
returns: v.array(v.object(priceFields)),
handler: async (ctx, args) => {
return await ctx.runQuery(components.stripe.public.listPricesByProduct, {
stripeProductId: args.stripeProductId,
});
},
});

/**
* List all active prices for a specific product.
*/
export const listActivePricesByProduct = query({
args: { stripeProductId: v.string() },
returns: v.array(v.object(priceFields)),
handler: async (ctx, args) => {
return await ctx.runQuery(
components.stripe.public.listActivePricesByProduct,
{ stripeProductId: args.stripeProductId },
);
},
});

/**
* Get a product with all its prices.
*/
export const getProductWithPrices = query({
args: { stripeProductId: v.string() },
returns: v.union(
v.object({
product: v.object(productFields),
prices: v.array(v.object(priceFields)),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.runQuery(components.stripe.public.getProductWithPrices, {
stripeProductId: args.stripeProductId,
});
},
});

// ============================================================================
// SYNC ACTIONS
// ============================================================================

/**
* Sync all products from Stripe to the local database.
* Useful for initial setup or populating existing products.
*/
export const syncProducts = action({
args: {},
returns: v.object({ synced: v.number() }),
handler: async (ctx) => {
return await stripeClient.syncProducts(ctx);
},
});

/**
* Sync all prices from Stripe to the local database.
* Useful for initial setup or populating existing prices.
*/
export const syncPrices = action({
args: {},
returns: v.object({ synced: v.number() }),
handler: async (ctx) => {
return await stripeClient.syncPrices(ctx);
},
});

/**
* Sync all products and prices from Stripe to the local database.
* Products are synced first, then prices.
*/
export const syncProductsAndPrices = action({
args: {},
returns: v.object({ products: v.number(), prices: v.number() }),
handler: async (ctx) => {
return await stripeClient.syncProductsAndPrices(ctx);
},
});

// ============================================================================
// CUSTOMER MANAGEMENT (Customer Creation)
// ============================================================================
Expand Down Expand Up @@ -452,7 +664,7 @@ export const getCustomerPortalUrl = action({
}),
v.null(),
),
handler: async (ctx, args) => {
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");

Expand Down Expand Up @@ -577,7 +789,7 @@ export const getUserSubscriptions = query({
orgId: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];

Expand Down Expand Up @@ -606,7 +818,7 @@ export const getUserPayments = query({
orgId: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];

Expand All @@ -629,7 +841,7 @@ export const getFailedPaymentSubscriptions = query({
currentPeriodEnd: v.number(),
}),
),
handler: async (ctx, args) => {
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];

Expand Down
Loading