add schema, client methods

This commit is contained in:
Shawn Erquhart
2024-11-12 18:39:18 -05:00
parent c8d6ecf16b
commit a10040f52e
11 changed files with 1862 additions and 1072 deletions

View File

@@ -39,110 +39,609 @@ export declare const internal: FilterApi<
export declare const components: { export declare const components: {
polar: { polar: {
init: { lib: {
seedProducts: FunctionReference< getBenefit: FunctionReference<
"query",
"internal",
{ id: string },
{
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
} | null
>;
getBenefitGrant: FunctionReference<
"query",
"internal",
{ id: string },
{
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
} | null
>;
getOrder: FunctionReference<
"query",
"internal",
{ id: string },
{
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
} | null
>;
getProduct: FunctionReference<
"query",
"internal",
{ 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;
}>;
} | null
>;
getSubscription: FunctionReference<
"query",
"internal",
{ id: string },
{
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string;
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
userId: string;
} | null
>;
insertBenefit: FunctionReference<
"mutation",
"internal",
{
benefit: {
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
},
any
>;
insertBenefitGrant: FunctionReference<
"mutation",
"internal",
{
benefitGrant: {
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
};
},
any
>;
insertOrder: FunctionReference<
"mutation",
"internal",
{
order: {
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
};
},
any
>;
insertProduct: FunctionReference<
"mutation",
"internal",
{
product: {
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;
}>;
};
},
any
>;
insertSubscription: FunctionReference<
"mutation",
"internal",
{
subscription: {
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string;
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
userId: string;
};
},
any
>;
listBenefits: FunctionReference<
"query",
"internal",
any,
Array<{
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
}>
>;
listPlans: FunctionReference<
"query",
"internal",
{ includeArchived: boolean },
Array<{
_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;
}>;
}>
>;
listUserBenefitGrants: FunctionReference<
"query",
"internal",
{ userId: string },
Array<{
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
}>
>;
listUserSubscriptions: FunctionReference<
"query",
"internal",
{ userId: string },
Array<{
_creationTime: number;
_id: string;
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: 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;
userId: string;
}>
>;
pullProducts: FunctionReference<
"action", "action",
"internal", "internal",
{ polarAccessToken: string; polarOrganizationId: string }, { polarAccessToken: string; polarOrganizationId: string },
any any
>; >;
}; updateBenefit: FunctionReference<
lib: {
createUser: FunctionReference<
"mutation", "mutation",
"internal", "internal",
{ userId: string },
any
>;
deleteUserSubscription: FunctionReference<
"mutation",
"internal",
{ userId: string },
any
>;
getOnboardingCheckoutUrl: FunctionReference<
"action",
"internal",
{ {
polarAccessToken: string; benefit: {
successUrl: string; createdAt: string;
userEmail?: string; deletable: boolean;
userId: string; description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
}, },
any any
>; >;
getPlanByKey: FunctionReference< updateBenefitGrant: FunctionReference<
"query",
"internal",
{ key: "free" | "pro" },
any
>;
getProOnboardingCheckoutUrl: FunctionReference<
"action",
"internal",
{
interval: "month" | "year";
polarAccessToken: string;
successUrl: string;
userId: string;
},
any
>;
getUser: FunctionReference<
"query",
"internal",
{ userId: string },
null | {
polarId?: string;
subscription?: {
cancelAtPeriodEnd?: boolean;
currency: "usd" | "eur";
currentPeriodEnd?: number;
currentPeriodStart?: number;
interval: "month" | "year";
localUserId: string;
planId: string;
polarId: string;
polarPriceId: string;
status: string;
};
subscriptionIsPending?: boolean;
subscriptionPendingId?: string;
userId: string;
}
>;
getUserByLocalId: FunctionReference<
"query",
"internal",
{ localUserId: string },
any
>;
listPlans: FunctionReference<"query", "internal", {}, any>;
replaceSubscription: FunctionReference<
"mutation", "mutation",
"internal", "internal",
{ {
input: { benefitGrant: {
cancelAtPeriodEnd?: boolean; benefitId: string;
currency: "usd" | "eur"; createdAt: string;
currentPeriodEnd?: number; grantedAt: string | null;
currentPeriodStart: number; id: string;
interval: "month" | "year"; isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
};
},
any
>;
updateOrder: FunctionReference<
"mutation",
"internal",
{
order: {
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
};
},
any
>;
updateProduct: FunctionReference<
"mutation",
"internal",
{
product: {
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;
}>;
};
},
any
>;
updateProducts: FunctionReference<
"mutation",
"internal",
{
polarAccessToken: string;
products: Array<{
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;
}>;
}>;
},
any
>;
updateSubscription: FunctionReference<
"mutation",
"internal",
{
subscription: {
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string;
startedAt: string | null;
status: string; status: string;
userId: string;
}; };
localUserId: string;
subscriptionPolarId: string;
}, },
any any
>; >;
setSubscriptionPending: FunctionReference<
"mutation",
"internal",
{ userId: string },
any
>;
}; };
}; };
}; };

View File

@@ -67,7 +67,8 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"convex": "^1.17.0" "convex": "^1.17.0",
"@polar-sh/sdk": "^0.13.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
@@ -85,9 +86,6 @@
"types": "./dist/commonjs/client/index.d.ts", "types": "./dist/commonjs/client/index.d.ts",
"module": "./dist/esm/client/index.js", "module": "./dist/esm/client/index.js",
"dependencies": { "dependencies": {
"@convex-dev/auth": "^0.0.74",
"@polar-sh/sdk": "^0.13.5",
"@react-email/components": "0.0.26",
"convex-helpers": "^0.1.63", "convex-helpers": "^0.1.63",
"standardwebhooks": "^1.0.0" "standardwebhooks": "^1.0.0"
} }

View File

@@ -1,93 +1,188 @@
import { type HttpRouter, httpActionGeneric } from "convex/server";
import { import {
ComponentApi, Benefit$inboundSchema,
RunActionCtx, BenefitGrant$inboundSchema,
RunMutationCtx, Product$inboundSchema,
RunQueryCtx, Subscription$inboundSchema,
type WebhookBenefitCreatedPayload$Outbound,
type WebhookBenefitGrantCreatedPayload$Outbound,
type WebhookBenefitGrantUpdatedPayload$Outbound,
type WebhookBenefitUpdatedPayload$Outbound,
WebhookOrderCreatedPayload$inboundSchema,
type WebhookOrderCreatedPayload$Outbound,
type WebhookProductCreatedPayload$Outbound,
type WebhookProductUpdatedPayload$Outbound,
type WebhookSubscriptionCreatedPayload$Outbound,
type WebhookSubscriptionUpdatedPayload$Outbound,
} from "@polar-sh/sdk/models/components";
import {
type FunctionReference,
type HttpRouter,
createFunctionHandle,
httpActionGeneric,
} from "convex/server";
import { Webhook } from "standardwebhooks";
import {
convertToDatabaseBenefit,
convertToDatabaseBenefitGrant,
convertToDatabaseOrder,
convertToDatabaseProduct,
convertToDatabaseSubscription,
type ComponentApi,
type RunActionCtx,
type RunQueryCtx,
} from "../component/util"; } from "../component/util";
import { handleWebhook } from "../component/webhook";
export type EventType = (
| WebhookOrderCreatedPayload$Outbound
| WebhookSubscriptionCreatedPayload$Outbound
| WebhookSubscriptionUpdatedPayload$Outbound
| WebhookBenefitCreatedPayload$Outbound
| WebhookBenefitUpdatedPayload$Outbound
| WebhookProductCreatedPayload$Outbound
| WebhookProductUpdatedPayload$Outbound
| WebhookBenefitGrantCreatedPayload$Outbound
| WebhookBenefitGrantUpdatedPayload$Outbound
)["type"];
export type EventHandler = FunctionReference<
"mutation",
"internal",
{ payload: unknown }
>;
export class Polar { export class Polar {
public readonly httpPath: string; public readonly httpPath: string;
public eventCallback?: EventHandler;
constructor( constructor(
public component: ComponentApi, public component: ComponentApi,
options: { options: {
httpPath?: string; httpPath?: string;
eventCallback?: EventHandler;
} = {} } = {}
) { ) {
this.eventCallback = options?.eventCallback;
this.httpPath = options.httpPath ?? "/polar/events"; this.httpPath = options.httpPath ?? "/polar/events";
} }
async getUserSubscription(ctx: RunQueryCtx, userId: string) { async listUserSubscriptions(ctx: RunQueryCtx, userId: string) {
const user = await ctx.runQuery(this.component.lib.getUser, { userId }); return ctx.runQuery(this.component.lib.listUserSubscriptions, {
return {
subscriptionIsPending: user?.subscriptionIsPending,
subscription: user?.subscription,
};
}
async deleteUserSubscription(ctx: RunMutationCtx, userId: string) {
return ctx.runMutation(this.component.lib.deleteUserSubscription, {
userId, userId,
}); });
} }
async seedProducts(ctx: RunActionCtx) { async listProducts(
return ctx.runAction(this.component.init.seedProducts, { ctx: RunQueryCtx,
{ includeArchived = false }: { includeArchived?: boolean } = {}
) {
return ctx.runQuery(this.component.lib.listPlans, { includeArchived });
}
async pullProducts(ctx: RunActionCtx) {
return ctx.runAction(this.component.lib.pullProducts, {
polarAccessToken: process.env.POLAR_ACCESS_TOKEN!, polarAccessToken: process.env.POLAR_ACCESS_TOKEN!,
polarOrganizationId: process.env.POLAR_ORGANIZATION_ID!, polarOrganizationId: process.env.POLAR_ORGANIZATION_ID!,
}); });
} }
async getOnboardingCheckoutUrl(
ctx: RunActionCtx,
args: {
successUrl: string;
userId: string;
userEmail?: string;
}
) {
return ctx.runAction(this.component.lib.getOnboardingCheckoutUrl, {
successUrl: args.successUrl,
userId: args.userId,
userEmail: args.userEmail,
polarAccessToken: process.env.POLAR_ACCESS_TOKEN!,
});
}
async getProOnboardingCheckoutUrl(
ctx: RunActionCtx,
args: {
interval: "month" | "year";
successUrl: string;
userId: string;
}
) {
return ctx.runAction(this.component.lib.getProOnboardingCheckoutUrl, {
interval: args.interval,
successUrl: args.successUrl,
userId: args.userId,
polarAccessToken: process.env.POLAR_ACCESS_TOKEN!,
});
}
async setSubscriptionPending(ctx: RunMutationCtx, userId: string) {
return ctx.runMutation(this.component.lib.setSubscriptionPending, {
userId,
});
}
async listPlans(ctx: RunQueryCtx) {
return ctx.runQuery(this.component.lib.listPlans);
}
registerRoutes(http: HttpRouter) { registerRoutes(http: HttpRouter) {
http.route({ http.route({
path: this.httpPath, path: this.httpPath,
method: "POST", method: "POST",
handler: httpActionGeneric(async (ctx, request) => { handler: httpActionGeneric(async (ctx, request) => {
return handleWebhook(this.component, ctx, request); if (!request.body) {
throw new Error("No body");
}
const body = await request.text();
const wh = new Webhook(btoa(process.env.POLAR_WEBHOOK_SECRET!));
const headers = Object.fromEntries(request.headers.entries());
const payload = wh.verify(body, headers) as {
type: EventType;
data: unknown;
};
switch (payload.type) {
case "order.created": {
await ctx.runMutation(this.component.lib.insertOrder, {
order: convertToDatabaseOrder(
WebhookOrderCreatedPayload$inboundSchema.parse(payload).data
),
});
break;
}
case "subscription.created": {
await ctx.runMutation(this.component.lib.insertSubscription, {
subscription: convertToDatabaseSubscription(
Subscription$inboundSchema.parse(payload.data)
),
});
break;
}
case "subscription.updated": {
await ctx.runMutation(this.component.lib.updateSubscription, {
subscription: convertToDatabaseSubscription(
Subscription$inboundSchema.parse(payload.data)
),
});
break;
}
case "product.created": {
await ctx.runMutation(this.component.lib.insertProduct, {
product: convertToDatabaseProduct(
Product$inboundSchema.parse(payload.data)
),
});
break;
}
case "product.updated": {
await ctx.runMutation(this.component.lib.updateProduct, {
product: convertToDatabaseProduct(
Product$inboundSchema.parse(payload.data)
),
});
break;
}
case "benefit.created": {
await ctx.runMutation(this.component.lib.insertBenefit, {
benefit: convertToDatabaseBenefit(
Benefit$inboundSchema.parse(payload.data)
),
});
break;
}
case "benefit.updated": {
await ctx.runMutation(this.component.lib.updateBenefit, {
benefit: convertToDatabaseBenefit(
Benefit$inboundSchema.parse(payload.data)
),
});
break;
}
case "benefit_grant.created": {
await ctx.runMutation(this.component.lib.insertBenefitGrant, {
benefitGrant: convertToDatabaseBenefitGrant(
BenefitGrant$inboundSchema.parse(payload.data)
),
});
break;
}
case "benefit_grant.updated": {
await ctx.runMutation(this.component.lib.updateBenefitGrant, {
benefitGrant: convertToDatabaseBenefitGrant(
BenefitGrant$inboundSchema.parse(payload.data)
),
});
break;
}
}
if (this.eventCallback) {
await ctx.runMutation(
await createFunctionHandle(this.eventCallback),
{ payload }
);
}
return new Response("OK", { status: 200 });
}), }),
}); });
} }

View File

@@ -8,12 +8,8 @@
* @module * @module
*/ */
import type * as email_index from "../email/index.js";
import type * as email_templates_subscriptionEmail from "../email/templates/subscriptionEmail.js";
import type * as init from "../init.js";
import type * as lib from "../lib.js"; import type * as lib from "../lib.js";
import type * as util from "../util.js"; import type * as util from "../util.js";
import type * as webhook from "../webhook.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -29,118 +25,613 @@ import type {
* ``` * ```
*/ */
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
"email/index": typeof email_index;
"email/templates/subscriptionEmail": typeof email_templates_subscriptionEmail;
init: typeof init;
lib: typeof lib; lib: typeof lib;
util: typeof util; util: typeof util;
webhook: typeof webhook;
}>; }>;
export type Mounts = { export type Mounts = {
init: { lib: {
seedProducts: FunctionReference< getBenefit: FunctionReference<
"query",
"public",
{ id: string },
{
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
} | null
>;
getBenefitGrant: FunctionReference<
"query",
"public",
{ id: string },
{
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
} | null
>;
getOrder: FunctionReference<
"query",
"public",
{ id: string },
{
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
} | null
>;
getProduct: FunctionReference<
"query",
"public",
{ 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;
}>;
} | null
>;
getSubscription: FunctionReference<
"query",
"public",
{ id: string },
{
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string;
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
userId: string;
} | null
>;
insertBenefit: FunctionReference<
"mutation",
"public",
{
benefit: {
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
},
any
>;
insertBenefitGrant: FunctionReference<
"mutation",
"public",
{
benefitGrant: {
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
};
},
any
>;
insertOrder: FunctionReference<
"mutation",
"public",
{
order: {
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
};
},
any
>;
insertProduct: FunctionReference<
"mutation",
"public",
{
product: {
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;
}>;
};
},
any
>;
insertSubscription: FunctionReference<
"mutation",
"public",
{
subscription: {
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string;
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
userId: string;
};
},
any
>;
listBenefits: FunctionReference<
"query",
"public",
any,
Array<{
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
}>
>;
listPlans: FunctionReference<
"query",
"public",
{ includeArchived: boolean },
Array<{
_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;
}>;
}>
>;
listUserBenefitGrants: FunctionReference<
"query",
"public",
{ userId: string },
Array<{
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
}>
>;
listUserSubscriptions: FunctionReference<
"query",
"public",
{ userId: string },
Array<{
_creationTime: number;
_id: string;
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: 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;
userId: string;
}>
>;
pullProducts: FunctionReference<
"action", "action",
"public", "public",
{ polarAccessToken: string; polarOrganizationId: string }, { polarAccessToken: string; polarOrganizationId: string },
any any
>; >;
}; updateBenefit: FunctionReference<
lib: {
createUser: FunctionReference<
"mutation", "mutation",
"public", "public",
{ userId: string },
any
>;
deleteUserSubscription: FunctionReference<
"mutation",
"public",
{ userId: string },
any
>;
getOnboardingCheckoutUrl: FunctionReference<
"action",
"public",
{ {
polarAccessToken: string; benefit: {
successUrl: string; createdAt: string;
userEmail?: string; deletable: boolean;
userId: string; description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
}, },
any any
>; >;
getPlanByKey: FunctionReference< updateBenefitGrant: FunctionReference<
"query",
"public",
{ key: "free" | "pro" },
any
>;
getProOnboardingCheckoutUrl: FunctionReference<
"action",
"public",
{
interval: "month" | "year";
polarAccessToken: string;
successUrl: string;
userId: string;
},
any
>;
getUser: FunctionReference<
"query",
"public",
{ userId: string },
null | {
polarId?: string;
subscription?: {
cancelAtPeriodEnd?: boolean;
currency: "usd" | "eur";
currentPeriodEnd?: number;
currentPeriodStart?: number;
interval: "month" | "year";
localUserId: string;
planId: string;
polarId: string;
polarPriceId: string;
status: string;
};
subscriptionIsPending?: boolean;
subscriptionPendingId?: string;
userId: string;
}
>;
getUserByLocalId: FunctionReference<
"query",
"public",
{ localUserId: string },
any
>;
listPlans: FunctionReference<"query", "public", {}, any>;
replaceSubscription: FunctionReference<
"mutation", "mutation",
"public", "public",
{ {
input: { benefitGrant: {
cancelAtPeriodEnd?: boolean; benefitId: string;
currency: "usd" | "eur"; createdAt: string;
currentPeriodEnd?: number; grantedAt: string | null;
currentPeriodStart: number; id: string;
interval: "month" | "year"; isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
};
},
any
>;
updateOrder: FunctionReference<
"mutation",
"public",
{
order: {
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
};
},
any
>;
updateProduct: FunctionReference<
"mutation",
"public",
{
product: {
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;
}>;
};
},
any
>;
updateProducts: FunctionReference<
"mutation",
"public",
{
polarAccessToken: string;
products: Array<{
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;
}>;
}>;
},
any
>;
updateSubscription: FunctionReference<
"mutation",
"public",
{
subscription: {
amount: number | null;
cancelAtPeriodEnd: boolean;
checkoutId: string | null;
createdAt: string;
currency: string | null;
currentPeriodEnd: string | null;
currentPeriodStart: string;
endedAt: string | null;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string;
startedAt: string | null;
status: string; status: string;
userId: string;
}; };
localUserId: string;
subscriptionPolarId: string;
}, },
any any
>; >;
setSubscriptionPending: FunctionReference<
"mutation",
"public",
{ userId: string },
any
>;
}; };
}; };
// For now fullApiWithMounts is only fullApi which provides // For now fullApiWithMounts is only fullApi which provides

View File

@@ -1,55 +0,0 @@
import { z } from "zod";
const ResendSuccessSchema = z.object({
id: z.string(),
});
const ResendErrorSchema = z.union([
z.object({
name: z.string(),
message: z.string(),
statusCode: z.number(),
}),
z.object({
name: z.literal("UnknownError"),
message: z.literal("Unknown Error"),
statusCode: z.literal(500),
cause: z.any(),
}),
]);
export type SendEmailOptions = {
to: string | string[];
subject: string;
html: string;
text?: string;
};
export async function sendEmail(options: SendEmailOptions) {
const from =
process.env.RESEND_SENDER_EMAIL_AUTH ??
"Convex SaaS <onboarding@resend.dev>";
const email = { from, ...options };
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(email),
});
const data = await response.json();
const parsedData = ResendSuccessSchema.safeParse(data);
if (response.ok && parsedData.success) {
return { status: "success", data: parsedData } as const;
}
const parsedErrorResult = ResendErrorSchema.safeParse(data);
if (parsedErrorResult.success) {
console.error(parsedErrorResult.data);
throw new Error(`Error sending email: ${parsedErrorResult.data.message}`);
}
console.error(data);
throw new Error("Error sending email");
}

View File

@@ -1,143 +0,0 @@
import {
Body,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import { sendEmail } from "../index";
type SubscriptionEmailOptions = {
email: string;
subscriptionId: string;
};
/**
* Templates.
*/
export function SubscriptionSuccessEmail({ email }: SubscriptionEmailOptions) {
return (
<Html>
<Head />
<Preview>Successfully Subscribed to PRO</Preview>
<Body
style={{
backgroundColor: "#ffffff",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
}}
>
<Container style={{ margin: "0 auto", padding: "20px 0 48px" }}>
<Img
src={`${process.env.SITE_URL}/images/convex-logo-email.jpg`}
width="40"
height="37"
alt=""
/>
<Text style={{ fontSize: "16px", lineHeight: "26px" }}>
Hello {email}!
</Text>
<Text style={{ fontSize: "16px", lineHeight: "26px" }}>
Your subscription to PRO has been successfully processed.
<br />
We hope you enjoy the new features!
</Text>
<Text style={{ fontSize: "16px", lineHeight: "26px" }}>
The <Link href={`${process.env.SITE_URL}`}>domain-name.com</Link>{" "}
team.
</Text>
<Hr style={{ borderColor: "#cccccc", margin: "20px 0" }} />
<Text style={{ color: "#8898aa", fontSize: "12px" }}>
200 domain-name.com
</Text>
</Container>
</Body>
</Html>
);
}
export function SubscriptionErrorEmail({ email }: SubscriptionEmailOptions) {
return (
<Html>
<Head />
<Preview>Subscription Issue - Customer Support</Preview>
<Body
style={{
backgroundColor: "#ffffff",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
}}
>
<Container style={{ margin: "0 auto", padding: "20px 0 48px" }}>
<Img
src="https://react-email-demo-ijnnx5hul-resend.vercel.app/static/vercel-logo.png"
width="40"
height="37"
alt=""
/>
<Text style={{ fontSize: "16px", lineHeight: "26px" }}>
Hello {email}.
</Text>
<Text style={{ fontSize: "16px", lineHeight: "26px" }}>
We were unable to process your subscription to PRO tier.
<br />
But don't worry, we'll not charge you anything.
</Text>
<Text style={{ fontSize: "16px", lineHeight: "26px" }}>
The <Link href={`${process.env.SITE_URL}`}>domain-name.com</Link>{" "}
team.
</Text>
<Hr style={{ borderColor: "#cccccc", margin: "20px 0" }} />
<Text style={{ color: "#8898aa", fontSize: "12px" }}>
200 domain-name.com
</Text>
</Container>
</Body>
</Html>
);
}
/**
* Renders.
*/
export function renderSubscriptionSuccessEmail(args: SubscriptionEmailOptions) {
return render(<SubscriptionSuccessEmail {...args} />);
}
export function renderSubscriptionErrorEmail(args: SubscriptionEmailOptions) {
return render(<SubscriptionErrorEmail {...args} />);
}
/**
* Senders.
*/
export async function sendSubscriptionSuccessEmail({
email,
subscriptionId,
}: SubscriptionEmailOptions) {
const html = await renderSubscriptionSuccessEmail({ email, subscriptionId });
await sendEmail({
to: email,
subject: "Successfully Subscribed to PRO",
html,
});
}
export async function sendSubscriptionErrorEmail({
email,
subscriptionId,
}: SubscriptionEmailOptions) {
const html = await renderSubscriptionErrorEmail({ email, subscriptionId });
await sendEmail({
to: email,
subject: "Subscription Issue - Customer Support",
html,
});
}

View File

@@ -1,134 +0,0 @@
import { Polar } from "@polar-sh/sdk";
import { asyncMap } from "convex-helpers";
import { internal } from "./_generated/api";
import { action, internalMutation } from "./_generated/server";
import schema, { CURRENCIES, INTERVALS, PlanKey, PLANS } from "./schema";
import { v } from "convex/values";
const seedProducts = [
{
key: PLANS.FREE,
name: "Free",
description: "Some of the things, free forever.",
amountType: "free",
prices: {
[INTERVALS.MONTH]: {
[CURRENCIES.USD]: 0,
},
},
},
{
key: PLANS.PRO,
name: "Pro",
description: "All the things for one low monthly price.",
amountType: "fixed",
prices: {
[INTERVALS.MONTH]: {
[CURRENCIES.USD]: 2000,
},
[INTERVALS.YEAR]: {
[CURRENCIES.USD]: 20000,
},
},
},
] as const;
export const insertSeedPlan = internalMutation({
args: schema.tables.plans.validator,
handler: async (ctx, args) => {
await ctx.db.insert("plans", {
polarProductId: args.polarProductId,
key: args.key,
name: args.name,
description: args.description,
prices: args.prices,
});
},
});
const seedProductsAction = action({
args: {
polarAccessToken: v.string(),
polarOrganizationId: v.string(),
},
handler: async (ctx, args) => {
/**
* Stripe Products.
*/
const polar = new Polar({
server: "sandbox",
accessToken: args.polarAccessToken,
});
const products = await polar.products.list({
organizationId: args.polarOrganizationId,
isArchived: false,
});
if (products?.result?.items?.length) {
console.info("🏃‍♂️ Skipping Polar products creation and seeding.");
return;
}
await asyncMap(seedProducts, async (product) => {
// Create Polar product.
const polarProduct = await polar.products.create({
organizationId: args.polarOrganizationId,
name: product.name,
description: product.description,
prices: Object.entries(product.prices).map(([interval, amount]) => ({
amountType: product.amountType,
priceAmount: amount.usd,
recurringInterval: interval,
})),
});
const monthPrice = polarProduct.prices.find(
(price) =>
price.type === "recurring" &&
price.recurringInterval === INTERVALS.MONTH
);
const yearPrice = polarProduct.prices.find(
(price) =>
price.type === "recurring" &&
price.recurringInterval === INTERVALS.YEAR
);
await ctx.runMutation(internal.init.insertSeedPlan, {
polarProductId: polarProduct.id,
key: product.key as PlanKey,
name: product.name,
description: product.description,
prices: {
...(!monthPrice
? {}
: {
month: {
usd: {
polarId: monthPrice?.id,
amount:
monthPrice.amountType === "fixed"
? monthPrice.priceAmount
: 0,
},
},
}),
...(!yearPrice
? {}
: {
year: {
usd: {
polarId: yearPrice?.id,
amount:
yearPrice.amountType === "fixed"
? yearPrice.priceAmount
: 0,
},
},
}),
},
});
});
console.info("📦 Polar Products have been successfully created.");
},
});
export { seedProductsAction as seedProducts };

View File

@@ -1,320 +1,323 @@
import { Polar } from "@polar-sh/sdk"; import { Polar } from "@polar-sh/sdk";
import { v } from "convex/values"; import { v } from "convex/values";
import { api, internal } from "./_generated/api"; import { api } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import { action, mutation, query } from "./_generated/server";
import {
action,
internalMutation,
mutation,
query,
QueryCtx,
} from "./_generated/server";
import schema from "./schema"; import schema from "./schema";
import { asyncMap } from "convex-helpers";
import { convertToDatabaseProduct } from "./util";
const createCheckout = async ({ export const getSubscription = query({
polarAccessToken,
customerEmail,
productPriceId,
successUrl,
polarSubscriptionId,
localUserId,
}: {
polarAccessToken: string;
customerEmail?: string;
productPriceId: string;
successUrl: string;
polarSubscriptionId?: string;
localUserId: Id<"users">;
}) => {
const polar = new Polar({
server: "sandbox",
accessToken: polarAccessToken,
});
if (polarSubscriptionId) {
return polar.checkouts.create({
productPriceId,
successUrl,
subscriptionId: polarSubscriptionId,
});
}
return polar.checkouts.custom.create({
productPriceId,
successUrl,
customerEmail,
metadata: {
userId: localUserId,
},
});
};
export const getPlanByKey = query({
args: { args: {
key: schema.tables.plans.validator.fields.key, id: v.id("subscriptions"),
}, },
returns: v.union(schema.tables.subscriptions.validator, v.null()),
handler: async (ctx, args) => { handler: async (ctx, args) => {
return ctx.db return ctx.db
.query("plans") .query("subscriptions")
.withIndex("key", (q) => q.eq("key", args.key)) .withIndex("id", (q) => q.eq("id", args.id))
.unique(); .unique();
}, },
}); });
export const createUser = mutation({ export const getOrder = query({
args: { args: {
userId: v.string(), id: v.id("orders"),
}, },
returns: v.union(schema.tables.orders.validator, v.null()),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await ctx.db.insert("users", { return ctx.db
userId: args.userId, .query("orders")
}); .withIndex("id", (q) => q.eq("id", args.id))
const user = await ctx.db.get(userId); .unique();
if (!user) {
throw new Error("User not found");
}
return user;
}, },
}); });
const getUser = async (ctx: QueryCtx, localUserId: Id<"users">) => { export const getProduct = query({
const user = await ctx.db.get(localUserId); args: {
if (!user) { id: v.id("products"),
return null;
}
const { subscriptionPendingId, ...subscriptionUser } = user;
const subscription =
(await ctx.db
.query("subscriptions")
.withIndex("localUserId", (q) => q.eq("localUserId", user._id))
.unique()) || undefined;
const plan = subscription ? await ctx.db.get(subscription.planId) : undefined;
return {
...subscriptionUser,
subscriptionIsPending: !!subscriptionPendingId,
...(subscription && {
subscription: {
...subscription,
plan,
}, },
}), returns: v.union(schema.tables.products.validator, v.null()),
}; handler: async (ctx, args) => {
}; return ctx.db
.query("products")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
const getUserQuery = query({ export const listUserSubscriptions = query({
args: { args: {
userId: v.string(), userId: v.string(),
}, },
returns: v.union( returns: v.array(
v.null(),
v.object({ v.object({
...schema.tables.users.validator.fields, ...schema.tables.subscriptions.validator.fields,
subscriptionIsPending: v.optional(v.boolean()), _id: v.id("subscriptions"),
subscription: v.optional(schema.tables.subscriptions.validator), _creationTime: v.number(),
product: v.optional(
v.object({
...schema.tables.products.validator.fields,
_id: v.id("products"),
_creationTime: v.number(),
})
),
}) })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await ctx.db return asyncMap(
.query("users") ctx.db
.withIndex("userId", (q) => q.eq("userId", args.userId))
.unique();
if (!user) {
return null;
}
return getUser(ctx, user._id);
},
});
export { getUserQuery as getUser };
export const getUserByLocalId = query({
args: {
localUserId: v.id("users"),
},
handler: async (ctx, args) => getUser(ctx, args.localUserId),
});
export const deleteUserSubscription = mutation({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("userId", (q) => q.eq("userId", args.userId))
.unique();
if (!user) {
throw new Error("User not found");
}
const subscription = await ctx.db
.query("subscriptions") .query("subscriptions")
.withIndex("localUserId", (q) => q.eq("localUserId", user._id)) .withIndex("userId", (q) => q.eq("userId", args.userId))
.unique(); .collect(),
if (subscription) { async (subscription) => {
await ctx.db.delete(subscription._id); const product = subscription.productId
? (await ctx.db
.query("products")
.withIndex("id", (q) => q.eq("id", subscription.productId))
.unique()) || undefined
: undefined;
return {
...subscription,
product,
};
} }
}, );
});
export const getOnboardingCheckoutUrl = action({
args: {
successUrl: v.string(),
userId: v.string(),
userEmail: v.optional(v.string()),
polarAccessToken: v.string(),
},
handler: async (ctx, args) => {
const user =
(await ctx.runQuery(api.lib.getUser, {
userId: args.userId,
})) ||
(await ctx.runMutation(api.lib.createUser, {
userId: args.userId,
}));
const product = await ctx.runQuery(api.lib.getPlanByKey, {
key: "free",
});
const price = product?.prices.month?.usd;
if (!price) {
throw new Error("Price not found");
}
const checkout = await createCheckout({
polarAccessToken: args.polarAccessToken,
customerEmail: args.userEmail,
productPriceId: price.polarId,
successUrl: args.successUrl,
localUserId: user?._id,
});
return checkout.url;
},
});
export const getProOnboardingCheckoutUrl = action({
args: {
interval: schema.tables.subscriptions.validator.fields.interval,
polarAccessToken: v.string(),
successUrl: v.string(),
userId: v.string(),
},
handler: async (ctx, args) => {
const product = await ctx.runQuery(api.lib.getPlanByKey, {
key: "pro",
});
const price =
args.interval === "month"
? product?.prices.month?.usd
: product?.prices.year?.usd;
if (!price) {
throw new Error("Price not found");
}
const user = await ctx.runQuery(api.lib.getUser, {
userId: args.userId,
});
if (!user) {
throw new Error("User not found");
}
const checkout = await createCheckout({
polarAccessToken: args.polarAccessToken,
productPriceId: price.polarId,
successUrl: args.successUrl,
polarSubscriptionId: user?.subscription?.polarId,
localUserId: user?._id,
});
return checkout.url;
}, },
}); });
export const listPlans = query({ export const listPlans = query({
args: {}, args: {
handler: async (ctx) => { includeArchived: v.boolean(),
const plans = await ctx.db.query("plans").collect(); },
return plans.sort((a, b) => a.key.localeCompare(b.key)); returns: v.array(
v.object({
...schema.tables.products.validator.fields,
_id: v.id("products"),
_creationTime: v.number(),
})
),
handler: async (ctx, args) => {
if (args.includeArchived) {
return ctx.db.query("products").collect();
}
return ctx.db
.query("products")
.withIndex("isArchived", (q) => q.lt("isArchived", true))
.collect();
}, },
}); });
export const replaceSubscription = mutation({ export const insertOrder = mutation({
args: { args: {
localUserId: v.id("users"), order: schema.tables.orders.validator,
subscriptionPolarId: v.string(),
input: v.object({
currency: schema.tables.subscriptions.validator.fields.currency,
productId: v.string(),
priceId: v.string(),
interval: schema.tables.subscriptions.validator.fields.interval,
status: v.string(),
currentPeriodStart: v.number(),
currentPeriodEnd: v.optional(v.number()),
cancelAtPeriodEnd: v.optional(v.boolean()),
}),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const subscription = await ctx.db await ctx.db.insert("orders", args.order);
.query("subscriptions") },
.withIndex("localUserId", (q) => q.eq("localUserId", args.localUserId))
.unique();
if (subscription) {
await ctx.db.delete(subscription._id);
}
const plan = await ctx.db
.query("plans")
.withIndex("polarProductId", (q) =>
q.eq("polarProductId", args.input.productId)
)
.unique();
if (!plan) {
throw new Error("Plan not found");
}
await ctx.db.insert("subscriptions", {
localUserId: args.localUserId,
planId: plan._id,
polarId: args.subscriptionPolarId,
polarPriceId: args.input.priceId,
interval: args.input.interval,
status: args.input.status,
currency: args.input.currency,
currentPeriodStart: args.input.currentPeriodStart,
currentPeriodEnd: args.input.currentPeriodEnd,
cancelAtPeriodEnd: args.input.cancelAtPeriodEnd,
}); });
const user = await ctx.db.get(args.localUserId);
if (!user?.subscriptionPendingId) { export const updateOrder = mutation({
args: {
order: schema.tables.orders.validator,
},
handler: async (ctx, args) => {
const existingOrder = await ctx.db
.query("orders")
.withIndex("id", (q) => q.eq("id", args.order.id))
.unique();
if (existingOrder) {
await ctx.db.patch(existingOrder._id, args.order);
}
},
});
export const insertSubscription = mutation({
args: {
subscription: schema.tables.subscriptions.validator,
},
handler: async (ctx, args) => {
await ctx.db.insert("subscriptions", args.subscription);
},
});
export const updateSubscription = mutation({
args: {
subscription: schema.tables.subscriptions.validator,
},
handler: async (ctx, args) => {
const existingSubscription = await ctx.db
.query("subscriptions")
.withIndex("id", (q) => q.eq("id", args.subscription.id))
.unique();
if (existingSubscription) {
await ctx.db.patch(existingSubscription._id, args.subscription);
}
},
});
export const insertProduct = mutation({
args: {
product: schema.tables.products.validator,
},
handler: async (ctx, args) => {
await ctx.db.insert("products", args.product);
},
});
export const updateProduct = mutation({
args: {
product: schema.tables.products.validator,
},
handler: async (ctx, args) => {
const existingProduct = await ctx.db
.query("products")
.withIndex("id", (q) => q.eq("id", args.product.id))
.unique();
if (existingProduct) {
await ctx.db.patch(existingProduct._id, args.product);
}
},
});
export const updateProducts = mutation({
args: {
polarAccessToken: v.string(),
products: v.array(schema.tables.products.validator),
},
handler: async (ctx, args) => {
await asyncMap(args.products, async (product) => {
console.log(product);
const existingProduct = await ctx.db
.query("products")
.withIndex("id", (q) => q.eq("id", product.id))
.unique();
if (existingProduct) {
await ctx.db.patch(existingProduct._id, product);
return; return;
} }
await ctx.scheduler.cancel(user.subscriptionPendingId); await ctx.db.insert("products", product);
await ctx.db.patch(args.localUserId, {
subscriptionPendingId: undefined,
}); });
}, },
}); });
export const setSubscriptionPending = mutation({ export const pullProducts = action({
args: {
polarAccessToken: v.string(),
polarOrganizationId: v.string(),
},
handler: async (ctx, args) => {
const polar = new Polar({
server: "sandbox",
accessToken: args.polarAccessToken,
});
let page = 1;
let maxPage;
do {
const products = await polar.products.list({
page,
limit: 10,
organizationId: args.polarOrganizationId,
});
page = page + 1;
maxPage = products.result.pagination.maxPage;
await ctx.runMutation(api.lib.updateProducts, {
polarAccessToken: args.polarAccessToken,
products: products.result.items.map(convertToDatabaseProduct),
});
} while (maxPage >= page);
},
});
export const insertBenefit = mutation({
args: {
benefit: schema.tables.benefits.validator,
},
handler: async (ctx, args) => {
await ctx.db.insert("benefits", args.benefit);
},
});
export const updateBenefit = mutation({
args: {
benefit: schema.tables.benefits.validator,
},
handler: async (ctx, args) => {
const existingBenefit = await ctx.db
.query("benefits")
.withIndex("id", (q) => q.eq("id", args.benefit.id))
.unique();
if (existingBenefit) {
await ctx.db.patch(existingBenefit._id, args.benefit);
}
},
});
export const getBenefit = query({
args: {
id: v.id("benefits"),
},
returns: v.union(schema.tables.benefits.validator, v.null()),
handler: async (ctx, args) => {
return ctx.db
.query("benefits")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
export const listBenefits = query({
returns: v.array(schema.tables.benefits.validator),
handler: async (ctx, _args) => {
return ctx.db.query("benefits").collect();
},
});
export const insertBenefitGrant = mutation({
args: {
benefitGrant: schema.tables.benefitGrants.validator,
},
handler: async (ctx, args) => {
await ctx.db.insert("benefitGrants", args.benefitGrant);
},
});
export const updateBenefitGrant = mutation({
args: {
benefitGrant: schema.tables.benefitGrants.validator,
},
handler: async (ctx, args) => {
const existingBenefitGrant = await ctx.db
.query("benefitGrants")
.withIndex("id", (q) => q.eq("id", args.benefitGrant.id))
.unique();
if (existingBenefitGrant) {
await ctx.db.patch(existingBenefitGrant._id, args.benefitGrant);
}
},
});
export const getBenefitGrant = query({
args: {
id: v.id("benefitGrants"),
},
returns: v.union(schema.tables.benefitGrants.validator, v.null()),
handler: async (ctx, args) => {
return ctx.db
.query("benefitGrants")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
export const listUserBenefitGrants = query({
args: { args: {
userId: v.string(), userId: v.string(),
}, },
returns: v.array(schema.tables.benefitGrants.validator),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await ctx.db return ctx.db
.query("users") .query("benefitGrants")
.withIndex("userId", (q) => q.eq("userId", args.userId)) .withIndex("userId", (q) => q.eq("userId", args.userId))
.unique(); .collect();
if (!user) {
throw new Error("User not found");
}
const scheduledFunctionId = await ctx.scheduler.runAfter(
1000 * 120,
internal.lib.unsetSubscriptionPending,
{ localUserId: user._id }
);
await ctx.db.patch(user._id, {
subscriptionPendingId: scheduledFunctionId,
});
},
});
export const unsetSubscriptionPending = internalMutation({
args: {
localUserId: v.id("users"),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.localUserId, {
subscriptionPendingId: undefined,
});
}, },
}); });

View File

@@ -1,14 +1,5 @@
import { defineSchema, defineTable } from "convex/server"; import { defineSchema, defineTable } from "convex/server";
import { Infer, v } from "convex/values"; import { v } from "convex/values";
export const CURRENCIES = {
USD: "usd",
EUR: "eur",
} as const;
export const currencyValidator = v.union(
v.literal(CURRENCIES.USD),
v.literal(CURRENCIES.EUR)
);
export const INTERVALS = { export const INTERVALS = {
MONTH: "month", MONTH: "month",
@@ -19,58 +10,129 @@ export const intervalValidator = v.union(
v.literal(INTERVALS.YEAR) v.literal(INTERVALS.YEAR)
); );
const priceValidator = v.object({ export default defineSchema(
polarId: v.string(), {
amount: v.number(),
});
const pricesValidator = v.object({
[CURRENCIES.USD]: v.optional(priceValidator),
[CURRENCIES.EUR]: v.optional(priceValidator),
});
export const PLANS = {
FREE: "free",
PRO: "pro",
} as const;
export const planKeyValidator = v.union(
v.literal(PLANS.FREE),
v.literal(PLANS.PRO)
);
export type PlanKey = Infer<typeof planKeyValidator>;
export default defineSchema({
users: defineTable({ users: defineTable({
id: v.string(),
userId: v.string(), userId: v.string(),
polarId: v.optional(v.string()),
subscriptionPendingId: v.optional(v.id("_scheduled_functions")),
}) })
.index("userId", ["userId"]) .index("id", ["id"])
.index("polarId", ["polarId"]), .index("userId", ["userId"]),
plans: defineTable({ benefits: defineTable({
key: planKeyValidator, id: v.string(),
polarProductId: v.string(), createdAt: v.string(),
name: v.string(), modifiedAt: v.union(v.string(), v.null()),
organizationId: v.string(),
type: v.optional(v.string()),
description: v.string(), description: v.string(),
prices: v.object({ selectable: v.boolean(),
[INTERVALS.MONTH]: v.optional(pricesValidator), deletable: v.boolean(),
[INTERVALS.YEAR]: v.optional(pricesValidator), properties: v.record(v.string(), v.any()),
}), }).index("id", ["id"]),
benefitGrants: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
userId: v.string(),
benefitId: v.string(),
properties: v.record(v.string(), v.any()),
isGranted: v.boolean(),
isRevoked: v.boolean(),
subscriptionId: v.union(v.string(), v.null()),
orderId: v.union(v.string(), v.null()),
grantedAt: v.union(v.string(), v.null()),
revokedAt: v.union(v.string(), v.null()),
}) })
.index("key", ["key"]) .index("id", ["id"])
.index("polarProductId", ["polarProductId"]), .index("userId", ["userId"]),
orders: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
userId: v.union(v.string(), v.null()),
productId: v.union(v.string(), v.null()),
productPriceId: v.string(),
subscriptionId: v.union(v.string(), v.null()),
checkoutId: v.union(v.string(), v.null()),
metadata: v.record(v.string(), v.any()),
amount: v.number(),
taxAmount: v.number(),
currency: v.string(),
billingReason: v.string(),
})
.index("id", ["id"])
.index("userId", ["userId"]),
products: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
name: v.string(),
description: v.union(v.string(), v.null()),
isRecurring: v.boolean(),
isArchived: v.boolean(),
organizationId: v.string(),
prices: v.array(
v.object({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
amountType: v.optional(v.string()),
isArchived: v.boolean(),
productId: v.string(),
priceCurrency: v.optional(v.string()),
priceAmount: v.optional(v.number()),
type: v.optional(v.string()),
recurringInterval: v.optional(v.string()),
})
),
medias: v.array(
v.object({
id: v.string(),
organizationId: v.string(),
name: v.string(),
path: v.string(),
mimeType: v.string(),
size: v.number(),
storageVersion: v.union(v.string(), v.null()),
checksumEtag: v.union(v.string(), v.null()),
checksumSha256Base64: v.union(v.string(), v.null()),
checksumSha256Hex: v.union(v.string(), v.null()),
createdAt: v.string(),
lastModifiedAt: v.union(v.string(), v.null()),
version: v.union(v.string(), v.null()),
service: v.optional(v.string()),
isUploaded: v.boolean(),
sizeReadable: v.string(),
publicUrl: v.string(),
})
),
})
.index("id", ["id"])
.index("isArchived", ["isArchived"]),
subscriptions: defineTable({ subscriptions: defineTable({
planId: v.id("plans"), id: v.string(),
polarId: v.string(), createdAt: v.string(),
polarPriceId: v.string(), modifiedAt: v.union(v.string(), v.null()),
currency: currencyValidator, amount: v.union(v.number(), v.null()),
interval: intervalValidator, currency: v.union(v.string(), v.null()),
recurringInterval: v.string(),
status: v.string(), status: v.string(),
currentPeriodStart: v.optional(v.number()), currentPeriodStart: v.string(),
currentPeriodEnd: v.optional(v.number()), currentPeriodEnd: v.union(v.string(), v.null()),
cancelAtPeriodEnd: v.optional(v.boolean()), cancelAtPeriodEnd: v.boolean(),
localUserId: v.id("users"), startedAt: v.union(v.string(), v.null()),
endedAt: v.union(v.string(), v.null()),
userId: v.string(),
productId: v.string(),
priceId: v.string(),
checkoutId: v.union(v.string(), v.null()),
metadata: v.record(v.string(), v.any()),
}) })
.index("localUserId", ["localUserId"]) .index("id", ["id"])
.index("polarId", ["polarId"]), .index("userId", ["userId"])
}); .index("userId_status", ["userId", "status"]),
},
{
schemaValidation: true,
}
);

View File

@@ -1,4 +1,4 @@
import { GenericQueryCtx } from "convex/server"; import { GenericQueryCtx, WithoutSystemFields } from "convex/server";
import { Expand, FunctionReference } from "convex/server"; import { Expand, FunctionReference } from "convex/server";
import { GenericMutationCtx } from "convex/server"; import { GenericMutationCtx } from "convex/server";
@@ -6,6 +6,14 @@ import { GenericDataModel } from "convex/server";
import { GenericActionCtx } from "convex/server"; import { GenericActionCtx } from "convex/server";
import { GenericId } from "convex/values"; import { GenericId } from "convex/values";
import { Mounts } from "./_generated/api"; import { Mounts } from "./_generated/api";
import {
Benefit,
BenefitGrant,
Order,
Product,
Subscription,
} from "@polar-sh/sdk/models/components";
import { Doc } from "./_generated/dataModel";
export type RunQueryCtx = { export type RunQueryCtx = {
runQuery: GenericQueryCtx<GenericDataModel>["runQuery"]; runQuery: GenericQueryCtx<GenericDataModel>["runQuery"];
@@ -45,3 +53,137 @@ export type UseApi<API> = Expand<{
}>; }>;
export type ComponentApi = UseApi<Mounts>; export type ComponentApi = UseApi<Mounts>;
export const convertToDatabaseOrder = (
order: Order
): WithoutSystemFields<Doc<"orders">> => {
return {
id: order.id,
userId: order.userId,
productId: order.productId,
productPriceId: order.productPriceId,
subscriptionId: order.subscriptionId,
checkoutId: order.checkoutId,
createdAt: order.createdAt.toISOString(),
modifiedAt: order.modifiedAt?.toISOString() ?? null,
metadata: order.metadata,
amount: order.amount,
taxAmount: order.taxAmount,
currency: order.currency,
billingReason: order.billingReason,
};
};
export const convertToDatabaseSubscription = (
subscription: Subscription
): WithoutSystemFields<Doc<"subscriptions">> => {
return {
id: subscription.id,
createdAt: subscription.createdAt.toISOString(),
modifiedAt: subscription.modifiedAt?.toISOString() ?? null,
userId: subscription.userId,
productId: subscription.productId,
priceId: subscription.priceId,
checkoutId: subscription.checkoutId,
amount: subscription.amount,
currency: subscription.currency,
recurringInterval: subscription.recurringInterval,
status: subscription.status,
currentPeriodStart: subscription.currentPeriodStart.toISOString(),
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
startedAt: subscription.startedAt?.toISOString() ?? null,
endedAt: subscription.endedAt?.toISOString() ?? null,
metadata: subscription.metadata,
};
};
export const convertToDatabaseProduct = (
product: Product
): WithoutSystemFields<Doc<"products">> => {
return {
id: product.id,
organizationId: product.organizationId,
name: product.name,
description: product.description,
isRecurring: product.isRecurring,
isArchived: product.isArchived,
createdAt: product.createdAt.toISOString(),
modifiedAt: product.modifiedAt?.toISOString() ?? null,
prices: product.prices.map((price) => ({
id: price.id,
productId: price.productId,
amountType: price.amountType,
isArchived: price.isArchived,
createdAt: price.createdAt.toISOString(),
modifiedAt: price.modifiedAt?.toISOString() ?? null,
recurringInterval:
price.type === "recurring" ? price.recurringInterval : undefined,
priceAmount: price.amountType === "fixed" ? price.priceAmount : undefined,
priceCurrency:
price.amountType === "fixed" || price.amountType === "custom"
? price.priceCurrency
: undefined,
minimumAmount:
price.amountType === "custom" ? price.minimumAmount : undefined,
maximumAmount:
price.amountType === "custom" ? price.maximumAmount : undefined,
presetAmount:
price.amountType === "custom" ? price.presetAmount : undefined,
type: price.type,
})),
medias: product.medias.map((media) => ({
id: media.id,
organizationId: media.organizationId,
name: media.name,
path: media.path,
mimeType: media.mimeType,
size: media.size,
storageVersion: media.storageVersion,
checksumEtag: media.checksumEtag,
checksumSha256Base64: media.checksumSha256Base64,
checksumSha256Hex: media.checksumSha256Hex,
createdAt: media.createdAt.toISOString(),
lastModifiedAt: media.lastModifiedAt?.toISOString() ?? null,
version: media.version,
isUploaded: media.isUploaded,
sizeReadable: media.sizeReadable,
publicUrl: media.publicUrl,
})),
};
};
export const convertToDatabaseBenefit = (
benefit: Benefit
): WithoutSystemFields<Doc<"benefits">> => {
return {
id: benefit.id,
organizationId: benefit.organizationId,
description: benefit.description,
selectable: benefit.selectable,
deletable: benefit.deletable,
properties: benefit.properties,
createdAt: benefit.createdAt.toISOString(),
modifiedAt: benefit.modifiedAt?.toISOString() ?? null,
type: benefit.type,
};
};
export const convertToDatabaseBenefitGrant = (
benefitGrant: BenefitGrant
): WithoutSystemFields<Doc<"benefitGrants">> => {
return {
id: benefitGrant.id,
userId: benefitGrant.userId,
benefitId: benefitGrant.benefitId,
properties: benefitGrant.properties,
isGranted: benefitGrant.isGranted,
isRevoked: benefitGrant.isRevoked,
subscriptionId: benefitGrant.subscriptionId,
orderId: benefitGrant.orderId,
createdAt: benefitGrant.createdAt.toISOString(),
modifiedAt: benefitGrant.modifiedAt?.toISOString() ?? null,
grantedAt: benefitGrant.grantedAt?.toISOString() ?? null,
revokedAt: benefitGrant.revokedAt?.toISOString() ?? null,
};
};

View File

@@ -1,168 +0,0 @@
import { type GenericActionCtx, type GenericDataModel } from "convex/server";
import {
type WebhookSubscriptionCreatedPayload,
type WebhookSubscriptionCreatedPayload$Outbound,
WebhookSubscriptionCreatedPayload$inboundSchema as WebhookSubscriptionCreatedPayloadSchema,
} from "@polar-sh/sdk/models/components/webhooksubscriptioncreatedpayload";
import {
type WebhookSubscriptionUpdatedPayload,
type WebhookSubscriptionUpdatedPayload$Outbound,
WebhookSubscriptionUpdatedPayload$inboundSchema as WebhookSubscriptionUpdatedPayloadSchema,
} from "@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload";
import { Webhook } from "standardwebhooks";
import type { Doc } from "../component/_generated/dataModel";
import {
sendSubscriptionErrorEmail,
sendSubscriptionSuccessEmail,
} from "../component/email/templates/subscriptionEmail";
import { ComponentApi } from "./util";
const handleUpdateSubscription = async (
component: ComponentApi,
ctx: GenericActionCtx<GenericDataModel>,
user: Doc<"users">,
subscription:
| WebhookSubscriptionCreatedPayload
| WebhookSubscriptionUpdatedPayload
) => {
const subscriptionItem = subscription.data;
await ctx.runMutation(component.lib.replaceSubscription, {
localUserId: user._id,
subscriptionPolarId: subscription.data.id,
input: {
productId: subscriptionItem.productId,
priceId: subscriptionItem.priceId,
interval: subscriptionItem.recurringInterval,
status: subscriptionItem.status,
currency: "usd",
currentPeriodStart: subscriptionItem.currentPeriodStart.getTime(),
currentPeriodEnd: subscriptionItem.currentPeriodEnd?.getTime(),
cancelAtPeriodEnd: subscriptionItem.cancelAtPeriodEnd,
},
});
};
const handleSubscriptionChange = async (
component: ComponentApi,
ctx: GenericActionCtx<GenericDataModel>,
event: WebhookSubscriptionCreatedPayload | WebhookSubscriptionUpdatedPayload
) => {
const userId = event.data.metadata.userId;
const email = event.data.user.email;
const user = await ctx.runQuery(component.lib.getUserByLocalId, {
localUserId: userId,
});
if (!user) {
throw new Error("User not found");
}
await handleUpdateSubscription(component, ctx, user, event);
const freePlan = await ctx.runQuery(component.lib.getPlanByKey, {
key: "free",
});
// Only send email for paid plans
if (event.data.productId !== freePlan?.polarProductId) {
await sendSubscriptionSuccessEmail({
email,
subscriptionId: event.data.id,
});
}
return new Response(null);
};
const handlePolarSubscriptionUpdatedError = async (
component: ComponentApi,
ctx: GenericActionCtx<GenericDataModel>,
event: WebhookSubscriptionCreatedPayload | WebhookSubscriptionUpdatedPayload
) => {
const userId = event.data.metadata.userId;
const email = event.data.user.email;
const subscription = event.data;
const user = await ctx.runQuery(component.lib.getUserByLocalId, {
localUserId: userId,
});
if (!user) throw new Error("User not found");
const freePlan = await ctx.runQuery(component.lib.getPlanByKey, {
key: "free",
});
// Only send email for paid plans
if (event.data.productId !== freePlan?.polarProductId) {
await sendSubscriptionErrorEmail({
email,
subscriptionId: subscription.id,
});
}
return new Response(null);
};
export const handleWebhook = async (
component: ComponentApi,
ctx: GenericActionCtx<GenericDataModel>,
request: Request
) => {
if (!request.body) {
return new Response(null, { status: 400 });
}
const wh = new Webhook(btoa(process.env.POLAR_WEBHOOK_SECRET!));
const body = await request.text();
const event = wh.verify(
body,
Object.fromEntries(request.headers.entries())
) as
| WebhookSubscriptionCreatedPayload$Outbound
| WebhookSubscriptionUpdatedPayload$Outbound
| { type: string };
console.log("event", event);
try {
switch (event.type) {
/**
* Occurs when a subscription has been created.
*/
case "subscription.created": {
return handleSubscriptionChange(
component,
ctx,
WebhookSubscriptionCreatedPayloadSchema.parse(event)
);
}
/**
* Occurs when a subscription has been updated.
* E.g. when a user upgrades or downgrades their plan.
*/
case "subscription.updated": {
return handleSubscriptionChange(
component,
ctx,
WebhookSubscriptionUpdatedPayloadSchema.parse(event)
);
}
}
} catch {
switch (event.type) {
case "subscription.created": {
return handlePolarSubscriptionUpdatedError(
component,
ctx,
WebhookSubscriptionCreatedPayloadSchema.parse(event)
);
}
case "subscription.updated": {
return handlePolarSubscriptionUpdatedError(
component,
ctx,
WebhookSubscriptionUpdatedPayloadSchema.parse(event)
);
}
}
}
return new Response("OK", { status: 200 });
};