From a10040f52e3be0c1d3dcddba0237d83d39ca2f08 Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Tue, 12 Nov 2024 18:39:18 -0500 Subject: [PATCH] add schema, client methods --- example/convex/_generated/api.d.ts | 665 ++++++++++++++--- package.json | 6 +- src/client/index.ts | 217 ++++-- src/component/_generated/api.d.ts | 673 +++++++++++++++--- src/component/email/index.ts | 55 -- .../email/templates/subscriptionEmail.tsx | 143 ---- src/component/init.ts | 134 ---- src/component/lib.ts | 539 +++++++------- src/component/schema.ts | 190 +++-- src/component/util.ts | 144 +++- src/component/webhook.ts | 168 ----- 11 files changed, 1862 insertions(+), 1072 deletions(-) delete mode 100644 src/component/email/index.ts delete mode 100644 src/component/email/templates/subscriptionEmail.tsx delete mode 100644 src/component/init.ts delete mode 100644 src/component/webhook.ts diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index d0b28d8..cc0e324 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; - }, - 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; + benefit: { + createdAt: string; + deletable: boolean; + description: string; + id: string; + modifiedAt: string | null; + organizationId: string; + properties: Record; + selectable: boolean; + type?: 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; + 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; + 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; + 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 - >; }; }; }; diff --git a/package.json b/package.json index 5a7280b..3837ca9 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/client/index.ts b/src/client/index.ts index 20f4391..10e48b5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 }); }), }); } diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index e636b52..384ef6a 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; - }, - 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; + benefit: { + createdAt: string; + deletable: boolean; + description: string; + id: string; + modifiedAt: string | null; + organizationId: string; + properties: Record; + selectable: boolean; + type?: 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; + 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; + 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; + 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 diff --git a/src/component/email/index.ts b/src/component/email/index.ts deleted file mode 100644 index ff539b7..0000000 --- a/src/component/email/index.ts +++ /dev/null @@ -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 "; - 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"); -} diff --git a/src/component/email/templates/subscriptionEmail.tsx b/src/component/email/templates/subscriptionEmail.tsx deleted file mode 100644 index 03c85f9..0000000 --- a/src/component/email/templates/subscriptionEmail.tsx +++ /dev/null @@ -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 ( - - - Successfully Subscribed to PRO - - - - - Hello {email}! - - - Your subscription to PRO has been successfully processed. -
- We hope you enjoy the new features! -
- - The domain-name.com{" "} - team. - -
- - 200 domain-name.com - -
- - - ); -} - -export function SubscriptionErrorEmail({ email }: SubscriptionEmailOptions) { - return ( - - - Subscription Issue - Customer Support - - - - - Hello {email}. - - - We were unable to process your subscription to PRO tier. -
- But don't worry, we'll not charge you anything. -
- - The domain-name.com{" "} - team. - -
- - 200 domain-name.com - -
- - - ); -} - -/** - * Renders. - */ -export function renderSubscriptionSuccessEmail(args: SubscriptionEmailOptions) { - return render(); -} - -export function renderSubscriptionErrorEmail(args: SubscriptionEmailOptions) { - return render(); -} - -/** - * 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, - }); -} diff --git a/src/component/init.ts b/src/component/init.ts deleted file mode 100644 index d1c1061..0000000 --- a/src/component/init.ts +++ /dev/null @@ -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 }; diff --git a/src/component/lib.ts b/src/component/lib.ts index e3834cb..a24c9cd 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -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 - .query("subscriptions") - .withIndex("localUserId", (q) => q.eq("localUserId", user._id)) - .unique(); - if (subscription) { - await ctx.db.delete(subscription._id); - } - }, -}); - -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; + return asyncMap( + ctx.db + .query("subscriptions") + .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 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 + await ctx.db.insert("orders", args.order); + }, +}); + +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("localUserId", (q) => q.eq("localUserId", args.localUserId)) + .withIndex("id", (q) => q.eq("id", args.subscription.id)) .unique(); - if (subscription) { - await ctx.db.delete(subscription._id); + if (existingSubscription) { + await ctx.db.patch(existingSubscription._id, args.subscription); } - const plan = await ctx.db - .query("plans") - .withIndex("polarProductId", (q) => - q.eq("polarProductId", args.input.productId) - ) + }, +}); + +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 (!plan) { - throw new Error("Plan not found"); + if (existingProduct) { + await ctx.db.patch(existingProduct._id, args.product); } - 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) { - return; - } - await ctx.scheduler.cancel(user.subscriptionPendingId); - await ctx.db.patch(args.localUserId, { - subscriptionPendingId: undefined, + }, +}); + +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.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(); }, }); diff --git a/src/component/schema.ts b/src/component/schema.ts index 2d5d108..1d19831 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -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 default defineSchema( + { + users: defineTable({ + id: v.string(), + userId: 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(), + 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("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({ + 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.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("id", ["id"]) + .index("userId", ["userId"]) + .index("userId_status", ["userId", "status"]), + }, + { + schemaValidation: true, + } ); - -export type PlanKey = Infer; - -export default defineSchema({ - users: defineTable({ - 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(), - description: v.string(), - prices: v.object({ - [INTERVALS.MONTH]: v.optional(pricesValidator), - [INTERVALS.YEAR]: v.optional(pricesValidator), - }), - }) - .index("key", ["key"]) - .index("polarProductId", ["polarProductId"]), - subscriptions: defineTable({ - planId: v.id("plans"), - polarId: v.string(), - polarPriceId: v.string(), - currency: currencyValidator, - interval: intervalValidator, - status: v.string(), - currentPeriodStart: v.optional(v.number()), - currentPeriodEnd: v.optional(v.number()), - cancelAtPeriodEnd: v.optional(v.boolean()), - localUserId: v.id("users"), - }) - .index("localUserId", ["localUserId"]) - .index("polarId", ["polarId"]), -}); diff --git a/src/component/util.ts b/src/component/util.ts index 1b72525..e2c060e 100644 --- a/src/component/util.ts +++ b/src/component/util.ts @@ -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["runQuery"]; @@ -45,3 +53,137 @@ export type UseApi = Expand<{ }>; export type ComponentApi = UseApi; + +export const convertToDatabaseOrder = ( + order: Order +): WithoutSystemFields> => { + 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> => { + 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> => { + 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> => { + 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> => { + 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, + }; +}; diff --git a/src/component/webhook.ts b/src/component/webhook.ts deleted file mode 100644 index 556e2bb..0000000 --- a/src/component/webhook.ts +++ /dev/null @@ -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, - 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, - 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, - 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, - 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 }); -};