mirror of
https://github.com/LukeHagar/polar.git
synced 2025-12-09 20:57:43 +00:00
wip
This commit is contained in:
69
example/convex/_generated/api.d.ts
vendored
69
example/convex/_generated/api.d.ts
vendored
@@ -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",
|
||||
|
||||
@@ -3,27 +3,33 @@ 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
69
src/component/_generated/api.d.ts
vendored
69
src/component/_generated/api.d.ts
vendored
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user