mirror of
https://github.com/LukeHagar/polar.git
synced 2025-12-06 04:20:58 +00:00
add schema, client methods
This commit is contained in:
665
example/convex/_generated/api.d.ts
vendored
665
example/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
},
|
||||
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<string, any>;
|
||||
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<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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
673
src/component/_generated/api.d.ts
vendored
673
src/component/_generated/api.d.ts
vendored
@@ -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;
|
||||
},
|
||||
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<string, any>;
|
||||
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<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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<typeof planKeyValidator>;
|
||||
|
||||
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"]),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user