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

View File

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

View File

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

View File

@@ -74,10 +74,7 @@ export function UpgradeCTA({
</li>
</ul>
{!isPremium && !isPremiumPlus && (
<CheckoutLink
polarApi={api.example}
productId="2d368710-520b-49b0-ba7b-9c2d60d7b1c2"
>
<CheckoutLink polarApi={api.example} productKey="premium">
<Button
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"
@@ -142,10 +139,7 @@ export function UpgradeCTA({
</li>
</ul>
{!isPremiumPlus && (
<CheckoutLink
polarApi={api.example}
productId="premium-plus-product-id"
>
<CheckoutLink polarApi={api.example} productKey="premiumPlus">
<Button
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"

View File

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

View File

@@ -82,7 +82,6 @@ export type Mounts = {
"mutation",
"public",
{
callback?: string;
subscription: {
amount: number | null;
cancelAtPeriodEnd: boolean;
@@ -185,6 +184,12 @@ export type Mounts = {
userId: string;
} | null
>;
insertCustomer: FunctionReference<
"mutation",
"public",
{ id: string; userId: string },
string
>;
listProducts: FunctionReference<
"query",
"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 schema from "./schema";
import { asyncMap } from "convex-helpers";
import { FunctionHandle, WithoutSystemFields } from "convex/server";
import { Doc } from "./_generated/dataModel";
export const getCustomerByUserId = query({
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({
args: {
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({
args: {
subscription: schema.tables.subscriptions.validator,
callback: v.optional(subscriptionCallbackValidator),
},
handler: async (ctx, args) => {
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>({
polarApi,
productId,
productKey,
children,
className,
}: PropsWithChildren<{
polarApi: CheckoutApi<DataModel>;
productId: string;
productKey: string;
className?: string;
}>) => {
const generateCheckoutLink = useAction(polarApi.generateCheckoutLink);
@@ -20,7 +20,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
useEffect(() => {
PolarEmbedCheckout.init();
void generateCheckoutLink({
productId,
productKey,
origin: window.location.origin,
}).then(({ url }) => setCheckoutLink(url));
}, []);
@@ -30,7 +30,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
className={className}
href={checkoutLink}
data-polar-checkout
//data-polar-checkout-theme="light"
data-polar-checkout-theme="dark"
>
{children}
</a>