working checkouts

This commit is contained in:
Shawn Erquhart
2025-02-20 13:52:42 -05:00
parent 92f33e47ca
commit 8e5643c183
8 changed files with 85 additions and 69 deletions

View File

@@ -96,7 +96,6 @@ export declare const components: {
"mutation", "mutation",
"internal", "internal",
{ {
callback?: string;
subscription: { subscription: {
amount: number | null; amount: number | null;
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
@@ -204,6 +203,12 @@ export declare const components: {
userId: string; userId: string;
} | null } | null
>; >;
insertCustomer: FunctionReference<
"mutation",
"internal",
{ id: string; userId: string },
string
>;
listProducts: FunctionReference< listProducts: FunctionReference<
"query", "query",
"internal", "internal",

View File

@@ -11,10 +11,15 @@ export const MAX_FREE_TODOS = 3;
export const MAX_PREMIUM_TODOS = 6; export const MAX_PREMIUM_TODOS = 6;
export const { generateCheckoutLink } = polar.checkoutApi({ export const { generateCheckoutLink } = polar.checkoutApi({
products: {
premium: "5fde8344-5fca-4d0b-adeb-2052cddfd9ed",
premiumPlus: "db548a6f-ff8c-4969-8f02-5f7301a36e7c",
},
getUserInfo: async (ctx) => { getUserInfo: async (ctx) => {
const user = await ctx.runQuery(api.example.getCurrentUser); const user = await ctx.runQuery(api.example.getCurrentUser);
return { return {
userId: user._id, userId: user._id,
email: user.email,
}; };
}, },
}); });

View File

@@ -58,6 +58,7 @@ const seed = internalAction({
recurringInterval: "month", recurringInterval: "month",
prices: [ prices: [
{ {
amountType: "fixed",
priceAmount: 1000, priceAmount: 1000,
}, },
], ],
@@ -68,6 +69,7 @@ const seed = internalAction({
recurringInterval: "year", recurringInterval: "year",
prices: [ prices: [
{ {
amountType: "fixed",
priceAmount: 10000, priceAmount: 10000,
}, },
], ],
@@ -78,6 +80,7 @@ const seed = internalAction({
recurringInterval: "month", recurringInterval: "month",
prices: [ prices: [
{ {
amountType: "fixed",
priceAmount: 2000, priceAmount: 2000,
}, },
], ],
@@ -88,6 +91,7 @@ const seed = internalAction({
recurringInterval: "year", recurringInterval: "year",
prices: [ prices: [
{ {
amountType: "fixed",
priceAmount: 20000, priceAmount: 20000,
}, },
], ],

View File

@@ -74,10 +74,7 @@ export function UpgradeCTA({
</li> </li>
</ul> </ul>
{!isPremium && !isPremiumPlus && ( {!isPremium && !isPremiumPlus && (
<CheckoutLink <CheckoutLink polarApi={api.example} productKey="premium">
polarApi={api.example}
productId="2d368710-520b-49b0-ba7b-9c2d60d7b1c2"
>
<Button <Button
variant="secondary" variant="secondary"
className="w-full bg-white text-indigo-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-indigo-300 dark:hover:bg-gray-700" className="w-full bg-white text-indigo-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-indigo-300 dark:hover:bg-gray-700"
@@ -142,10 +139,7 @@ export function UpgradeCTA({
</li> </li>
</ul> </ul>
{!isPremiumPlus && ( {!isPremiumPlus && (
<CheckoutLink <CheckoutLink polarApi={api.example} productKey="premiumPlus">
polarApi={api.example}
productId="premium-plus-product-id"
>
<Button <Button
variant="secondary" variant="secondary"
className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20" className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20"

View File

@@ -2,13 +2,11 @@ import "./polyfill";
import { import {
ApiFromModules, ApiFromModules,
FunctionReference, FunctionReference,
GenericActionCtx,
GenericDataModel, GenericDataModel,
type HttpRouter, type HttpRouter,
WithoutSystemFields,
actionGeneric, actionGeneric,
createFunctionHandle,
httpActionGeneric, httpActionGeneric,
GenericActionCtx,
} from "convex/server"; } from "convex/server";
import { import {
type ComponentApi, type ComponentApi,
@@ -18,17 +16,18 @@ import {
RunQueryCtx, RunQueryCtx,
} from "../component/util"; } from "../component/util";
import { Polar as PolarSdk } from "@polar-sh/sdk"; import { Polar as PolarSdk } from "@polar-sh/sdk";
import { Doc } from "../component/_generated/dataModel";
import { Infer, v } from "convex/values"; import { Infer, v } from "convex/values";
import schema from "../component/schema"; import schema from "../component/schema";
import { WebhookSubscriptionCreatedPayload } from "@polar-sh/sdk/models/components/webhooksubscriptioncreatedpayload.js"; import { WebhookSubscriptionCreatedPayload } from "@polar-sh/sdk/models/components/webhooksubscriptioncreatedpayload.js";
import { WebhookSubscriptionUpdatedPayload } from "@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload.js"; import { WebhookSubscriptionUpdatedPayload } from "@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload.js";
import { WebhookProductCreatedPayload } from "@polar-sh/sdk/models/components/webhookproductcreatedpayload.js"; import { WebhookProductCreatedPayload } from "@polar-sh/sdk/models/components/webhookproductcreatedpayload.js";
import { WebhookProductUpdatedPayload } from "@polar-sh/sdk/models/components/webhookproductupdatedpayload.js"; import { WebhookProductUpdatedPayload } from "@polar-sh/sdk/models/components/webhookproductupdatedpayload.js";
import { Checkout } from "@polar-sh/sdk/models/components/checkout.js";
import { import {
validateEvent, validateEvent,
WebhookVerificationError, WebhookVerificationError,
} from "@polar-sh/sdk/webhooks"; } from "@polar-sh/sdk/webhooks";
import { EmptyObject } from "convex-helpers";
export const subscriptionValidator = schema.tables.subscriptions.validator; export const subscriptionValidator = schema.tables.subscriptions.validator;
export type Subscription = Infer<typeof subscriptionValidator>; export type Subscription = Infer<typeof subscriptionValidator>;
@@ -45,14 +44,14 @@ export type CheckoutApi<DataModel extends GenericDataModel> = ApiFromModules<{
export class Polar<DataModel extends GenericDataModel> { export class Polar<DataModel extends GenericDataModel> {
private polar: PolarSdk; private polar: PolarSdk;
public onScheduleCreated?: SubscriptionHandler;
constructor( constructor(
public component: ComponentApi, public component: ComponentApi,
options: { private options: {
onScheduleCreated?: FunctionReference< getUserInfo?: FunctionReference<
"mutation", "query",
"internal", "internal",
{ subscription: WithoutSystemFields<Doc<"subscriptions">> } EmptyObject,
{ userId: string; email?: string }
>; >;
} = {} } = {}
) { ) {
@@ -61,41 +60,48 @@ export class Polar<DataModel extends GenericDataModel> {
server: server:
(process.env["POLAR_SERVER"] as "sandbox" | "production") ?? "sandbox", (process.env["POLAR_SERVER"] as "sandbox" | "production") ?? "sandbox",
}); });
this.onScheduleCreated = options.onScheduleCreated;
} }
getCustomerByUserId(ctx: RunQueryCtx, userId: string) { getCustomerByUserId(ctx: RunQueryCtx, userId: string) {
return ctx.runQuery(this.component.lib.getCustomerByUserId, { userId }); return ctx.runQuery(this.component.lib.getCustomerByUserId, { userId });
} }
/*
async createCheckoutSession( async createCheckoutSession(
ctx: GenericActionCtx<DataModel>, ctx: GenericActionCtx<DataModel>,
{ {
productId, productId,
userId, userId,
email, email,
}: { productId: string; userId: string; email: string } origin,
) { }: { productId: string; userId: string; email: string; origin: string }
const customer = ): Promise<Checkout> {
(await ctx.runQuery(this.component.lib.getCustomerByUserId, { const dbCustomer = await ctx.runQuery(
this.component.lib.getCustomerByUserId,
{
userId, userId,
})) || }
(await this.polar.customers.create({ );
email, const customerId =
metadata: { dbCustomer?.id ||
userId, (
}, await this.polar.customers.create({
})); email,
const customerId = customer?.id; metadata: {
if (!customerId) { userId,
throw new Error("Customer not found"); },
})
).id;
if (!dbCustomer) {
await ctx.runMutation(this.component.lib.insertCustomer, {
id: customerId,
userId,
});
} }
return this.polar.checkouts.custom.create({ return this.polar.checkouts.create({
allowDiscountCodes: true, allowDiscountCodes: true,
productId, products: [productId],
customerId, customerId,
embedOrigin: origin,
}); });
} }
*/
listProducts( listProducts(
ctx: RunQueryCtx, ctx: RunQueryCtx,
{ includeArchived }: { includeArchived: boolean } { includeArchived }: { includeArchived: boolean }
@@ -117,15 +123,16 @@ export class Polar<DataModel extends GenericDataModel> {
return ctx.runQuery(this.component.lib.getProduct, { id: productId }); return ctx.runQuery(this.component.lib.getProduct, { id: productId });
} }
checkoutApi(opts: { checkoutApi(opts: {
products: Record<string, string>;
getUserInfo: (ctx: RunQueryCtx) => Promise<{ getUserInfo: (ctx: RunQueryCtx) => Promise<{
userId: string; userId: string;
email?: string; email: string;
}>; }>;
}) { }) {
return { return {
generateCheckoutLink: actionGeneric({ generateCheckoutLink: actionGeneric({
args: { args: {
productId: v.string(), productKey: v.string(),
origin: v.string(), origin: v.string(),
}, },
returns: v.object({ returns: v.object({
@@ -133,17 +140,13 @@ export class Polar<DataModel extends GenericDataModel> {
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const { userId, email } = await opts.getUserInfo(ctx); const { userId, email } = await opts.getUserInfo(ctx);
const { url } = await this.polar.checkouts.create({ const { url } = await this.createCheckoutSession(ctx, {
productId: args.productId, productId: opts.products?.[args.productKey],
embedOrigin: args.origin, userId,
customerEmail: email, email,
metadata: { origin: args.origin,
userId,
},
}); });
return { return { url };
url,
};
}, },
}), }),
}; };
@@ -194,9 +197,6 @@ export class Polar<DataModel extends GenericDataModel> {
event.data.metadata.userId as string, event.data.metadata.userId as string,
event.data event.data
), ),
callback:
this.onScheduleCreated &&
(await createFunctionHandle(this.onScheduleCreated)),
}); });
break; break;
} }

View File

@@ -82,7 +82,6 @@ export type Mounts = {
"mutation", "mutation",
"public", "public",
{ {
callback?: string;
subscription: { subscription: {
amount: number | null; amount: number | null;
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
@@ -185,6 +184,12 @@ export type Mounts = {
userId: string; userId: string;
} | null } | null
>; >;
insertCustomer: FunctionReference<
"mutation",
"public",
{ id: string; userId: string },
string
>;
listProducts: FunctionReference< listProducts: FunctionReference<
"query", "query",
"public", "public",

View File

@@ -1,9 +1,7 @@
import { v, VString } from "convex/values"; import { v } from "convex/values";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import schema from "./schema"; import schema from "./schema";
import { asyncMap } from "convex-helpers"; import { asyncMap } from "convex-helpers";
import { FunctionHandle, WithoutSystemFields } from "convex/server";
import { Doc } from "./_generated/dataModel";
export const getCustomerByUserId = query({ export const getCustomerByUserId = query({
args: { args: {
@@ -25,6 +23,20 @@ export const getCustomerByUserId = query({
}, },
}); });
export const insertCustomer = mutation({
args: {
id: v.string(),
userId: v.string(),
},
returns: v.id("customers"),
handler: async (ctx, args) => {
return ctx.db.insert("customers", {
id: args.id,
userId: args.userId,
});
},
});
export const upsertCustomer = mutation({ export const upsertCustomer = mutation({
args: { args: {
userId: v.string(), userId: v.string(),
@@ -163,21 +175,12 @@ export const listProducts = query({
}, },
}); });
type Subscription = WithoutSystemFields<Doc<"subscriptions">>;
const subscriptionCallbackValidator = v.string() as VString<
FunctionHandle<"mutation", { subscription: Subscription }>
>;
export const createSubscription = mutation({ export const createSubscription = mutation({
args: { args: {
subscription: schema.tables.subscriptions.validator, subscription: schema.tables.subscriptions.validator,
callback: v.optional(subscriptionCallbackValidator),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await ctx.db.insert("subscriptions", args.subscription); await ctx.db.insert("subscriptions", args.subscription);
if (args.callback) {
await ctx.runMutation(args.callback, { subscription: args.subscription });
}
}, },
}); });

View File

@@ -6,12 +6,12 @@ import { useAction } from "convex/react";
export const CheckoutLink = <DataModel extends GenericDataModel>({ export const CheckoutLink = <DataModel extends GenericDataModel>({
polarApi, polarApi,
productId, productKey,
children, children,
className, className,
}: PropsWithChildren<{ }: PropsWithChildren<{
polarApi: CheckoutApi<DataModel>; polarApi: CheckoutApi<DataModel>;
productId: string; productKey: string;
className?: string; className?: string;
}>) => { }>) => {
const generateCheckoutLink = useAction(polarApi.generateCheckoutLink); const generateCheckoutLink = useAction(polarApi.generateCheckoutLink);
@@ -20,7 +20,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
useEffect(() => { useEffect(() => {
PolarEmbedCheckout.init(); PolarEmbedCheckout.init();
void generateCheckoutLink({ void generateCheckoutLink({
productId, productKey,
origin: window.location.origin, origin: window.location.origin,
}).then(({ url }) => setCheckoutLink(url)); }).then(({ url }) => setCheckoutLink(url));
}, []); }, []);
@@ -30,7 +30,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
className={className} className={className}
href={checkoutLink} href={checkoutLink}
data-polar-checkout data-polar-checkout
//data-polar-checkout-theme="light" data-polar-checkout-theme="dark"
> >
{children} {children}
</a> </a>