This commit is contained in:
Shawn Erquhart
2025-02-21 10:02:14 -05:00
parent 66d01aa4d7
commit 2d67f648eb
8 changed files with 310 additions and 102 deletions

View File

@@ -118,6 +118,75 @@ export declare const components: {
},
any
>;
getCurrentSubscription: FunctionReference<
"query",
"internal",
{ userId: string },
{
_creationTime: number;
_id: string;
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
customerId: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string;
product: {
_creationTime: number;
_id: string;
createdAt: string;
description: string | null;
id: string;
isArchived: boolean;
isRecurring: boolean;
medias: Array<{
checksumEtag: string | null;
checksumSha256Base64: string | null;
checksumSha256Hex: string | null;
createdAt: string;
id: string;
isUploaded: boolean;
lastModifiedAt: string | null;
mimeType: string;
name: string;
organizationId: string;
path: string;
publicUrl: string;
service?: string;
size: number;
sizeReadable: string;
storageVersion: string | null;
version: string | null;
}>;
modifiedAt: string | null;
name: string;
organizationId: string;
prices: Array<{
amountType?: string;
createdAt: string;
id: string;
isArchived: boolean;
modifiedAt: string | null;
priceAmount?: number;
priceCurrency?: string;
productId: string;
recurringInterval?: string;
type?: string;
}>;
};
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
} | null
>;
getCustomerByUserId: FunctionReference<
"query",
"internal",

View File

@@ -3,21 +3,18 @@ import { api, components } from "./_generated/api";
import { QueryCtx, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { DataModel, Id } from "./_generated/dataModel";
import { PREMIUM_PLAN_NAME, PREMIUM_PLUS_PLAN_NAME } from "./seed";
export const polar = new Polar<DataModel>(components.polar);
export const MAX_FREE_TODOS = 3;
export const MAX_PREMIUM_TODOS = 6;
export const { generateCheckoutLink, generateCustomerPortalUrl } =
polar.checkoutApi({
products: {
const products = {
premium: "5fde8344-5fca-4d0b-adeb-2052cddfd9ed",
premiumPlus: "db548a6f-ff8c-4969-8f02-5f7301a36e7c",
},
};
export const polar = new Polar<DataModel>(components.polar, {
products,
getUserInfo: async (ctx) => {
const user = await ctx.runQuery(api.example.getCurrentUser);
const user: { _id: Id<"users">; email: string } = await ctx.runQuery(
api.example.getCurrentUser
);
return {
userId: user._id,
email: user.email,
@@ -25,6 +22,15 @@ export const { generateCheckoutLink, generateCustomerPortalUrl } =
},
});
export const MAX_FREE_TODOS = 3;
export const MAX_PREMIUM_TODOS = 6;
export const { changeCurrentSubscription, cancelCurrentSubscription } =
polar.api();
export const { generateCheckoutLink, generateCustomerPortalUrl } =
polar.checkoutApi();
// In a real app you'll set up authentication, we just use a
// fake user for the example.
const currentUser = async (ctx: QueryCtx) => {
@@ -32,20 +38,12 @@ const currentUser = async (ctx: QueryCtx) => {
if (!user) {
throw new Error("No user found");
}
const subscriptions = await polar.listUserSubscriptions(ctx, {
const subscription = await polar.getCurrentSubscription(ctx, {
userId: user._id,
});
// In a real app you would have product ids from your Polar products,
// probably in environment variables, rather than comparing to hardcoded
// product names.
const isPremiumPlus = subscriptions.some(
(subscription) => subscription.product?.name === PREMIUM_PLUS_PLAN_NAME
);
const isPremiumPlus = subscription?.product?.id === products.premiumPlus;
const isPremium =
isPremiumPlus ||
subscriptions.some(
(subscription) => subscription.product?.name === PREMIUM_PLAN_NAME
);
isPremiumPlus || subscription?.product?.id === products.premium;
return {
...user,
isPremium,

View File

@@ -1,11 +1,8 @@
import { Polar } from "@convex-dev/polar";
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { polar } from "./example";
const http = httpRouter();
const polar = new Polar(components.polar);
polar.registerRoutes(http, {
// Optional custom path, default is "/events/polar"
path: "/events/polar",

View File

@@ -23,12 +23,10 @@ import { WebhookSubscriptionUpdatedPayload } from "@polar-sh/sdk/models/componen
import { WebhookProductCreatedPayload } from "@polar-sh/sdk/models/components/webhookproductcreatedpayload.js";
import { WebhookProductUpdatedPayload } from "@polar-sh/sdk/models/components/webhookproductupdatedpayload.js";
import { Checkout } from "@polar-sh/sdk/models/components/checkout.js";
import { CustomerSession } from "@polar-sh/sdk/models/components/customersession.js";
import {
validateEvent,
WebhookVerificationError,
} from "@polar-sh/sdk/webhooks";
import { EmptyObject } from "convex-helpers";
export const subscriptionValidator = schema.tables.subscriptions.validator;
export type Subscription = Infer<typeof subscriptionValidator>;
@@ -47,14 +45,14 @@ export class Polar<DataModel extends GenericDataModel> {
private polar: PolarSdk;
constructor(
public component: ComponentApi,
private options: {
getUserInfo?: FunctionReference<
"query",
"internal",
EmptyObject,
{ userId: string; email?: string }
>;
} = {}
private config: {
products: Record<string, string>;
getUserInfo: (ctx: RunQueryCtx) => Promise<{
userId: string;
email: string;
}>;
}
) {
this.polar = new PolarSdk({
accessToken: process.env["POLAR_ORGANIZATION_TOKEN"] ?? "",
@@ -103,41 +101,95 @@ export class Polar<DataModel extends GenericDataModel> {
embedOrigin: origin,
});
}
async createCustomerPortalSession(
ctx: GenericActionCtx<DataModel>,
{ userId }: { userId: string }
) {
const customer = await ctx.runQuery(
this.component.lib.getCustomerByUserId,
{ userId }
);
if (!customer) {
throw new Error("Customer not found");
}
const session = await this.polar.customerSessions.create({
customerId: customer.id,
});
return { url: session.customerPortalUrl };
}
listProducts(
ctx: RunQueryCtx,
{ includeArchived }: { includeArchived: boolean }
) {
return ctx.runQuery(this.component.lib.listProducts, { includeArchived });
}
listUserSubscriptions(ctx: RunQueryCtx, { userId }: { userId: string }) {
return ctx.runQuery(this.component.lib.listUserSubscriptions, { userId });
}
getSubscription(
ctx: RunQueryCtx,
{ subscriptionId }: { subscriptionId: string }
) {
return ctx.runQuery(this.component.lib.getSubscription, {
id: subscriptionId,
getCurrentSubscription(ctx: RunQueryCtx, { userId }: { userId: string }) {
return ctx.runQuery(this.component.lib.getCurrentSubscription, {
userId,
});
}
getProduct(ctx: RunQueryCtx, { productId }: { productId: string }) {
return ctx.runQuery(this.component.lib.getProduct, { id: productId });
}
createCustomerPortalSession(
async changeSubscription(
ctx: GenericActionCtx<DataModel>,
{ customerId }: { customerId: string }
): Promise<CustomerSession> {
return this.polar.customerSessions.create({
customerId,
{ productId }: { productId: string }
) {
const { userId } = await this.config.getUserInfo(ctx);
const subscription = await this.getCurrentSubscription(ctx, { userId });
if (!subscription) {
throw new Error("Subscription not found");
}
if (subscription.productId === productId) {
throw new Error("Subscription already on this product");
}
await this.polar.subscriptions.update({
id: subscription.id,
subscriptionUpdate: {
productId,
},
});
}
checkoutApi(opts: {
products: Record<string, string>;
getUserInfo: (ctx: RunQueryCtx) => Promise<{
userId: string;
email: string;
}>;
}) {
async cancelSubscription(ctx: GenericActionCtx<DataModel>) {
const { userId } = await this.config.getUserInfo(ctx);
const subscription = await this.getCurrentSubscription(ctx, { userId });
if (!subscription) {
throw new Error("Subscription not found");
}
if (subscription.status !== "active") {
throw new Error("Subscription is not active");
}
await this.polar.subscriptions.update({
id: subscription.id,
subscriptionUpdate: {
cancelAtPeriodEnd: true,
},
});
}
api() {
return {
changeCurrentSubscription: actionGeneric({
args: {
productId: v.string(),
},
handler: async (ctx, args) => {
await this.changeSubscription(ctx, {
productId: args.productId,
});
},
}),
cancelCurrentSubscription: actionGeneric({
args: {},
handler: async (ctx) => {
await this.cancelSubscription(ctx);
},
}),
};
}
checkoutApi() {
return {
generateCheckoutLink: actionGeneric({
args: {
@@ -148,9 +200,9 @@ export class Polar<DataModel extends GenericDataModel> {
url: v.string(),
}),
handler: async (ctx, args) => {
const { userId, email } = await opts.getUserInfo(ctx);
const { userId, email } = await this.config.getUserInfo(ctx);
const { url } = await this.createCheckoutSession(ctx, {
productId: opts.products?.[args.productKey],
productId: this.config.products?.[args.productKey],
userId,
email,
origin: args.origin,
@@ -160,23 +212,13 @@ export class Polar<DataModel extends GenericDataModel> {
}),
generateCustomerPortalUrl: actionGeneric({
args: {},
returns: v.union(v.object({ url: v.string() }), v.null()),
returns: v.object({ url: v.string() }),
handler: async (ctx) => {
const { userId } = await opts.getUserInfo(ctx);
const customer = await ctx.runQuery(
this.component.lib.getCustomerByUserId,
{ userId }
);
if (!customer) {
return null;
}
const session = await this.createCustomerPortalSession(ctx, {
customerId: customer.id,
const { userId } = await this.config.getUserInfo(ctx);
const { url } = await this.createCustomerPortalSession(ctx, {
userId,
});
return { url: session.customerPortalUrl };
return { url };
},
}),
};
@@ -222,27 +264,8 @@ export class Polar<DataModel extends GenericDataModel> {
);
switch (event.type) {
case "subscription.created": {
const newSubscription = convertToDatabaseSubscription(event.data);
const existingSubscriptions = await ctx.runQuery(
this.component.lib.listCustomerSubscriptions,
{ customerId: newSubscription.customerId }
);
for (const subscription of existingSubscriptions) {
if (
subscription.id !== newSubscription.id &&
subscription.status === "active" &&
!subscription.cancelAtPeriodEnd
) {
await this.polar.subscriptions.revoke({
id: subscription.id,
});
}
}
await ctx.runMutation(this.component.lib.createSubscription, {
subscription: newSubscription,
subscription: convertToDatabaseSubscription(event.data),
});
break;
}

View File

@@ -104,6 +104,75 @@ export type Mounts = {
},
any
>;
getCurrentSubscription: FunctionReference<
"query",
"public",
{ userId: string },
{
_creationTime: number;
_id: string;
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
customerId: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string;
product: {
_creationTime: number;
_id: string;
createdAt: string;
description: string | null;
id: string;
isArchived: boolean;
isRecurring: boolean;
medias: Array<{
checksumEtag: string | null;
checksumSha256Base64: string | null;
checksumSha256Hex: string | null;
createdAt: string;
id: string;
isUploaded: boolean;
lastModifiedAt: string | null;
mimeType: string;
name: string;
organizationId: string;
path: string;
publicUrl: string;
service?: string;
size: number;
sizeReadable: string;
storageVersion: string | null;
version: string | null;
}>;
modifiedAt: string | null;
name: string;
organizationId: string;
prices: Array<{
amountType?: string;
createdAt: string;
id: string;
isArchived: boolean;
modifiedAt: string | null;
priceAmount?: number;
priceCurrency?: string;
productId: string;
recurringInterval?: string;
type?: string;
}>;
};
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
} | null
>;
getCustomerByUserId: FunctionReference<
"query",
"public",

View File

@@ -103,6 +103,55 @@ export const getProduct = query({
},
});
// For apps that have 0 or 1 active subscription per user.
export const getCurrentSubscription = query({
args: {
userId: v.string(),
},
returns: v.union(
v.object({
...schema.tables.subscriptions.validator.fields,
_id: v.id("subscriptions"),
_creationTime: v.number(),
product: v.object({
...schema.tables.products.validator.fields,
_id: v.id("products"),
_creationTime: v.number(),
}),
}),
v.null()
),
handler: async (ctx, args) => {
const customer = await ctx.db
.query("customers")
.withIndex("userId", (q) => q.eq("userId", args.userId))
.unique();
if (!customer) {
return null;
}
const subscription = await ctx.db
.query("subscriptions")
.withIndex("customerId_endedAt", (q) =>
q.eq("customerId", customer.id).eq("endedAt", null)
)
.unique();
if (!subscription) {
return null;
}
const product = await ctx.db
.query("products")
.withIndex("id", (q) => q.eq("id", subscription.productId))
.unique();
if (!product) {
throw new Error(`Product not found: ${subscription.productId}`);
}
return {
...subscription,
product,
};
},
});
export const listUserSubscriptions = query({
args: {
userId: v.string(),

View File

@@ -77,7 +77,8 @@ export default defineSchema(
})
.index("id", ["id"])
.index("customerId", ["customerId"])
.index("customerId_status", ["customerId", "status"]),
.index("customerId_status", ["customerId", "status"])
.index("customerId_endedAt", ["customerId", "endedAt"]),
},
{
schemaValidation: true,

View File

@@ -9,7 +9,7 @@ export const CustomerPortalLink = <DataModel extends GenericDataModel>({
children,
className,
}: PropsWithChildren<{
polarApi: CheckoutApi<DataModel>;
polarApi: Pick<CheckoutApi<DataModel>, "generateCustomerPortalUrl">;
className?: string;
}>) => {
const generateCustomerPortalUrl = useAction(
@@ -41,10 +41,12 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
productKey,
children,
className,
theme = "dark",
}: PropsWithChildren<{
polarApi: CheckoutApi<DataModel>;
polarApi: Pick<CheckoutApi<DataModel>, "generateCheckoutLink">;
productKey: string;
className?: string;
theme?: "dark" | "light";
}>) => {
const generateCheckoutLink = useAction(polarApi.generateCheckoutLink);
const [checkoutLink, setCheckoutLink] = useState<string>();
@@ -62,7 +64,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
className={className}
href={checkoutLink}
data-polar-checkout
data-polar-checkout-theme="dark"
data-polar-checkout-theme={theme}
>
{children}
</a>