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: {
polar: {
init: {
seedProducts: FunctionReference<
lib: {
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",
"internal",
{ polarAccessToken: string; polarOrganizationId: string },
any
>;
};
lib: {
createUser: FunctionReference<
updateBenefit: FunctionReference<
"mutation",
"internal",
{ userId: string },
any
>;
deleteUserSubscription: FunctionReference<
"mutation",
"internal",
{ userId: string },
any
>;
getOnboardingCheckoutUrl: FunctionReference<
"action",
"internal",
{
polarAccessToken: string;
successUrl: string;
userEmail?: string;
userId: string;
benefit: {
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
},
any
>;
getPlanByKey: 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<
updateBenefitGrant: FunctionReference<
"mutation",
"internal",
{
input: {
cancelAtPeriodEnd?: boolean;
currency: "usd" | "eur";
currentPeriodEnd?: number;
currentPeriodStart: number;
interval: "month" | "year";
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
>;
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;
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
userId: string;
};
localUserId: string;
subscriptionPolarId: string;
},
any
>;
setSubscriptionPending: FunctionReference<
"mutation",
"internal",
{ userId: string },
any
>;
};
};
};

View File

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

View File

@@ -1,93 +1,188 @@
import { type HttpRouter, httpActionGeneric } from "convex/server";
import {
ComponentApi,
RunActionCtx,
RunMutationCtx,
RunQueryCtx,
Benefit$inboundSchema,
BenefitGrant$inboundSchema,
Product$inboundSchema,
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";
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 {
public readonly httpPath: string;
public eventCallback?: EventHandler;
constructor(
public component: ComponentApi,
options: {
httpPath?: string;
eventCallback?: EventHandler;
} = {}
) {
this.eventCallback = options?.eventCallback;
this.httpPath = options.httpPath ?? "/polar/events";
}
async getUserSubscription(ctx: RunQueryCtx, userId: string) {
const user = await ctx.runQuery(this.component.lib.getUser, { userId });
return {
subscriptionIsPending: user?.subscriptionIsPending,
subscription: user?.subscription,
};
}
async deleteUserSubscription(ctx: RunMutationCtx, userId: string) {
return ctx.runMutation(this.component.lib.deleteUserSubscription, {
async listUserSubscriptions(ctx: RunQueryCtx, userId: string) {
return ctx.runQuery(this.component.lib.listUserSubscriptions, {
userId,
});
}
async seedProducts(ctx: RunActionCtx) {
return ctx.runAction(this.component.init.seedProducts, {
async listProducts(
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!,
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) {
http.route({
path: this.httpPath,
method: "POST",
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
*/
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 util from "../util.js";
import type * as webhook from "../webhook.js";
import type {
ApiFromModules,
@@ -29,118 +25,613 @@ import type {
* ```
*/
declare const fullApi: ApiFromModules<{
"email/index": typeof email_index;
"email/templates/subscriptionEmail": typeof email_templates_subscriptionEmail;
init: typeof init;
lib: typeof lib;
util: typeof util;
webhook: typeof webhook;
}>;
export type Mounts = {
init: {
seedProducts: FunctionReference<
lib: {
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",
"public",
{ polarAccessToken: string; polarOrganizationId: string },
any
>;
};
lib: {
createUser: FunctionReference<
updateBenefit: FunctionReference<
"mutation",
"public",
{ userId: string },
any
>;
deleteUserSubscription: FunctionReference<
"mutation",
"public",
{ userId: string },
any
>;
getOnboardingCheckoutUrl: FunctionReference<
"action",
"public",
{
polarAccessToken: string;
successUrl: string;
userEmail?: string;
userId: string;
benefit: {
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
},
any
>;
getPlanByKey: 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<
updateBenefitGrant: FunctionReference<
"mutation",
"public",
{
input: {
cancelAtPeriodEnd?: boolean;
currency: "usd" | "eur";
currentPeriodEnd?: number;
currentPeriodStart: number;
interval: "month" | "year";
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
>;
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;
productId: string;
recurringInterval: string;
startedAt: string | null;
status: string;
userId: string;
};
localUserId: string;
subscriptionPolarId: string;
},
any
>;
setSubscriptionPending: FunctionReference<
"mutation",
"public",
{ userId: string },
any
>;
};
};
// 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 { v } from "convex/values";
import { api, internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import {
action,
internalMutation,
mutation,
query,
QueryCtx,
} from "./_generated/server";
import { api } from "./_generated/api";
import { action, mutation, query } from "./_generated/server";
import schema from "./schema";
import { asyncMap } from "convex-helpers";
import { convertToDatabaseProduct } from "./util";
const createCheckout = async ({
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({
export const getSubscription = query({
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) => {
return ctx.db
.query("plans")
.withIndex("key", (q) => q.eq("key", args.key))
.query("subscriptions")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
export const createUser = mutation({
export const getOrder = query({
args: {
userId: v.string(),
id: v.id("orders"),
},
returns: v.union(schema.tables.orders.validator, v.null()),
handler: async (ctx, args) => {
const userId = await ctx.db.insert("users", {
userId: args.userId,
});
const user = await ctx.db.get(userId);
if (!user) {
throw new Error("User not found");
}
return user;
return ctx.db
.query("orders")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
const getUser = async (ctx: QueryCtx, localUserId: Id<"users">) => {
const user = await ctx.db.get(localUserId);
if (!user) {
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,
export const getProduct = query({
args: {
id: v.id("products"),
},
}),
};
};
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: {
userId: v.string(),
},
returns: v.union(
v.null(),
returns: v.array(
v.object({
...schema.tables.users.validator.fields,
subscriptionIsPending: v.optional(v.boolean()),
subscription: v.optional(schema.tables.subscriptions.validator),
...schema.tables.subscriptions.validator.fields,
_id: v.id("subscriptions"),
_creationTime: v.number(),
product: v.optional(
v.object({
...schema.tables.products.validator.fields,
_id: v.id("products"),
_creationTime: v.number(),
})
),
})
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.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
return asyncMap(
ctx.db
.query("subscriptions")
.withIndex("localUserId", (q) => q.eq("localUserId", user._id))
.unique();
if (subscription) {
await ctx.db.delete(subscription._id);
.withIndex("userId", (q) => q.eq("userId", args.userId))
.collect(),
async (subscription) => {
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({
args: {},
handler: async (ctx) => {
const plans = await ctx.db.query("plans").collect();
return plans.sort((a, b) => a.key.localeCompare(b.key));
args: {
includeArchived: v.boolean(),
},
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: {
localUserId: v.id("users"),
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()),
}),
order: schema.tables.orders.validator,
},
handler: async (ctx, args) => {
const subscription = await ctx.db
.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,
await ctx.db.insert("orders", args.order);
},
});
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;
}
await ctx.scheduler.cancel(user.subscriptionPendingId);
await ctx.db.patch(args.localUserId, {
subscriptionPendingId: undefined,
await ctx.db.insert("products", product);
});
},
});
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: {
userId: v.string(),
},
returns: v.array(schema.tables.benefitGrants.validator),
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
return ctx.db
.query("benefitGrants")
.withIndex("userId", (q) => q.eq("userId", args.userId))
.unique();
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,
});
.collect();
},
});

View File

@@ -1,14 +1,5 @@
import { defineSchema, defineTable } from "convex/server";
import { Infer, 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)
);
import { v } from "convex/values";
export const INTERVALS = {
MONTH: "month",
@@ -19,58 +10,129 @@ export const intervalValidator = v.union(
v.literal(INTERVALS.YEAR)
);
const priceValidator = v.object({
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({
export default defineSchema(
{
users: defineTable({
id: v.string(),
userId: v.string(),
polarId: v.optional(v.string()),
subscriptionPendingId: v.optional(v.id("_scheduled_functions")),
})
.index("userId", ["userId"])
.index("polarId", ["polarId"]),
plans: defineTable({
key: planKeyValidator,
polarProductId: v.string(),
name: v.string(),
.index("id", ["id"])
.index("userId", ["userId"]),
benefits: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
organizationId: v.string(),
type: v.optional(v.string()),
description: v.string(),
prices: v.object({
[INTERVALS.MONTH]: v.optional(pricesValidator),
[INTERVALS.YEAR]: v.optional(pricesValidator),
}),
selectable: v.boolean(),
deletable: v.boolean(),
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("polarProductId", ["polarProductId"]),
.index("id", ["id"])
.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({
planId: v.id("plans"),
polarId: v.string(),
polarPriceId: v.string(),
currency: currencyValidator,
interval: intervalValidator,
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
amount: v.union(v.number(), v.null()),
currency: v.union(v.string(), v.null()),
recurringInterval: v.string(),
status: v.string(),
currentPeriodStart: v.optional(v.number()),
currentPeriodEnd: v.optional(v.number()),
cancelAtPeriodEnd: v.optional(v.boolean()),
localUserId: v.id("users"),
currentPeriodStart: v.string(),
currentPeriodEnd: v.union(v.string(), v.null()),
cancelAtPeriodEnd: v.boolean(),
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("polarId", ["polarId"]),
});
.index("id", ["id"])
.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 { GenericMutationCtx } from "convex/server";
@@ -6,6 +6,14 @@ import { GenericDataModel } from "convex/server";
import { GenericActionCtx } from "convex/server";
import { GenericId } from "convex/values";
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 = {
runQuery: GenericQueryCtx<GenericDataModel>["runQuery"];
@@ -45,3 +53,137 @@ export type UseApi<API> = Expand<{
}>;
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 });
};