feat(subscriptions): added polar as payment provider for subscriptions
* feature(dashboard): add polar / subscription * wip(payments): manage subscription * wip(payments): add free product, faq and some other improvements * fix(root): change node to bundler in tsconfig * wip(payments): display current subscription * feat(dashboard): schedule project for deletion * wip(payments): support custom products/subscriptions * wip(payments): fix polar scripts * wip(payments): add json package to dockerfiles
This commit is contained in:
committed by
GitHub
parent
86bf9dd064
commit
168ebc3430
2
packages/payments/index.ts
Normal file
2
packages/payments/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src/polar';
|
||||
export * from './src/prices';
|
||||
22
packages/payments/package.json
Normal file
22
packages/payments/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@openpanel/payments",
|
||||
"version": "0.0.1",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@polar-sh/sdk": "^0.26.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/inquirer-autocomplete-prompt": "^3.0.3",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/react": "^18.2.0",
|
||||
"inquirer": "^9.3.5",
|
||||
"inquirer-autocomplete-prompt": "^3.0.1",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
246
packages/payments/scripts/create-custom-pricing.ts
Normal file
246
packages/payments/scripts/create-custom-pricing.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
import type { ProductCreate } from '@polar-sh/sdk/models/components/productcreate';
|
||||
import inquirer from 'inquirer';
|
||||
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { PRICING, getProducts, getSuccessUrl, polar } from '..';
|
||||
import { formatEventsCount } from './create-products';
|
||||
|
||||
// Register the autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
|
||||
|
||||
type Interval = 'month' | 'year';
|
||||
|
||||
interface Answers {
|
||||
isProduction: boolean;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
interval: Interval;
|
||||
price: number;
|
||||
eventsLimit: number;
|
||||
polarOrganizationId: string;
|
||||
polarApiKey: string;
|
||||
}
|
||||
|
||||
async function promptForInput() {
|
||||
// Get all organizations first
|
||||
const organizations = await db.organization.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const answers = await inquirer.prompt<Answers>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'isProduction',
|
||||
message: 'Is this for production?',
|
||||
choices: [
|
||||
{ name: 'Yes', value: true },
|
||||
{ name: 'No', value: false },
|
||||
],
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'polarOrganizationId',
|
||||
message: 'Enter your Polar organization ID:',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'polarApiKey',
|
||||
message: 'Enter your Polar API key:',
|
||||
validate: (input: string) => {
|
||||
if (!input) return 'API key is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'organizationId',
|
||||
message: 'Select organization:',
|
||||
source: (answersSoFar: any, input = '') => {
|
||||
return organizations
|
||||
.filter(
|
||||
(org) =>
|
||||
org.name.toLowerCase().includes(input.toLowerCase()) ||
|
||||
org.id.toLowerCase().includes(input.toLowerCase()),
|
||||
)
|
||||
.map((org) => ({
|
||||
name: `${org.name} (${org.id})`,
|
||||
value: org.id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'userId',
|
||||
message: 'Select user:',
|
||||
source: (answersSoFar: Answers, input = '') => {
|
||||
return db.organization
|
||||
.findFirst({
|
||||
where: {
|
||||
id: answersSoFar.organizationId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
role: true,
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((org) =>
|
||||
org?.members
|
||||
.filter(
|
||||
(member) =>
|
||||
member.user?.email
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase()) ||
|
||||
member.user?.firstName
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase()),
|
||||
)
|
||||
.map((member) => ({
|
||||
name: `${
|
||||
[member.user?.firstName, member.user?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ') || 'No name'
|
||||
} (${member.user?.email}) [${member.role}]`,
|
||||
value: member.user?.id,
|
||||
})),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'interval',
|
||||
message: 'Select billing interval:',
|
||||
choices: [
|
||||
{ name: 'Monthly', value: 'month' },
|
||||
{ name: 'Yearly', value: 'year' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'price',
|
||||
message: 'Enter price',
|
||||
validate: (input: number) => {
|
||||
if (!Number.isInteger(input)) return false;
|
||||
if (input < 0) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'eventsLimit',
|
||||
message: 'Enter events limit:',
|
||||
validate: (input: number) => {
|
||||
if (!Number.isInteger(input)) return false;
|
||||
if (input < 0) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Creating custom pricing...');
|
||||
const input = await promptForInput();
|
||||
|
||||
const polar = new Polar({
|
||||
accessToken: input.polarApiKey!,
|
||||
server: input.isProduction ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
projects: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await db.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.userId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('\nReview the following settings:');
|
||||
console.table({
|
||||
...input,
|
||||
organization: organization?.name,
|
||||
email: user?.email,
|
||||
name:
|
||||
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || 'No name',
|
||||
});
|
||||
|
||||
const { confirmed } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmed',
|
||||
message: 'Do you want to proceed?',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Operation canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
const product = await polar.products.create({
|
||||
organizationId: input.polarApiKey.includes('_oat_')
|
||||
? undefined
|
||||
: input.polarOrganizationId,
|
||||
name: `Custom product for ${organization.name}`,
|
||||
recurringInterval: 'month',
|
||||
prices: [
|
||||
{
|
||||
amountType: 'fixed',
|
||||
priceAmount: input.price * 100,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
eventsLimit: input.eventsLimit,
|
||||
organizationId: organization.id,
|
||||
userId: user.id,
|
||||
custom: true,
|
||||
},
|
||||
});
|
||||
|
||||
const checkoutLink = await polar.checkoutLinks.create({
|
||||
productId: product.id,
|
||||
allowDiscountCodes: false,
|
||||
metadata: {
|
||||
organizationId: organization.id,
|
||||
userId: user.id,
|
||||
},
|
||||
successUrl: getSuccessUrl(
|
||||
input.isProduction
|
||||
? 'https://dashboard.openpanel.dev'
|
||||
: 'http://localhost:3000',
|
||||
organization.id,
|
||||
organization.projects[0]?.id,
|
||||
),
|
||||
});
|
||||
|
||||
console.table(checkoutLink);
|
||||
console.log('Custom pricing created successfully!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => db.$disconnect());
|
||||
179
packages/payments/scripts/create-products.ts
Normal file
179
packages/payments/scripts/create-products.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
import type { ProductCreate } from '@polar-sh/sdk/models/components/productcreate';
|
||||
import inquirer from 'inquirer';
|
||||
import { PRICING } from '../';
|
||||
|
||||
export function formatEventsCount(events: number) {
|
||||
return new Intl.NumberFormat('en-gb', {
|
||||
notation: 'compact',
|
||||
}).format(events);
|
||||
}
|
||||
|
||||
interface Answers {
|
||||
isProduction: boolean;
|
||||
polarOrganizationId: string;
|
||||
polarApiKey: string;
|
||||
}
|
||||
|
||||
async function promptForInput() {
|
||||
const answers = await inquirer.prompt<Answers>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'isProduction',
|
||||
message: 'Is this for production?',
|
||||
choices: [
|
||||
{ name: 'Yes', value: true },
|
||||
{ name: 'No', value: false },
|
||||
],
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'polarOrganizationId',
|
||||
message: 'Enter your Polar organization ID:',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'polarApiKey',
|
||||
message: 'Enter your Polar API key:',
|
||||
validate: (input: string) => {
|
||||
if (!input) return 'API key is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = await promptForInput();
|
||||
|
||||
const polar = new Polar({
|
||||
accessToken: input.polarApiKey!,
|
||||
server: input.isProduction ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
async function getProducts() {
|
||||
const products = await polar.products.list({
|
||||
limit: 100,
|
||||
isArchived: false,
|
||||
sorting: ['price_amount'],
|
||||
});
|
||||
return products.result.items.filter((product) => {
|
||||
return product.metadata.custom !== true;
|
||||
});
|
||||
}
|
||||
|
||||
const isDry = process.argv.includes('--dry');
|
||||
const products = await getProducts();
|
||||
for (const price of PRICING) {
|
||||
if (price.price === 0) {
|
||||
const exists = products.find(
|
||||
(p) =>
|
||||
p.metadata?.eventsLimit === price.events &&
|
||||
p.recurringInterval === 'month',
|
||||
);
|
||||
if (exists) {
|
||||
console.log('Free product already exists:');
|
||||
console.log(' - ID:', exists.id);
|
||||
console.log(' - Name:', exists.name);
|
||||
} else {
|
||||
const product = await polar.products.create({
|
||||
organizationId: input.polarApiKey.includes('_oat_')
|
||||
? undefined
|
||||
: input.polarOrganizationId,
|
||||
name: `${formatEventsCount(price.events)} events per month (FREE)`,
|
||||
recurringInterval: 'month',
|
||||
prices: [
|
||||
{
|
||||
amountType: 'free',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
eventsLimit: price.events,
|
||||
},
|
||||
});
|
||||
console.log('Free product created:');
|
||||
console.log(' - ID:', product.id);
|
||||
console.log(' - Name:', product.name);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const productCreate: ProductCreate = {
|
||||
organizationId: input.polarApiKey.includes('_oat_')
|
||||
? undefined
|
||||
: input.polarOrganizationId,
|
||||
name: `${formatEventsCount(price.events)} events per month`,
|
||||
prices: [
|
||||
{
|
||||
priceAmount: price.price * 100,
|
||||
amountType: 'fixed',
|
||||
priceCurrency: 'usd',
|
||||
},
|
||||
],
|
||||
recurringInterval: 'month',
|
||||
metadata: {
|
||||
eventsLimit: price.events,
|
||||
},
|
||||
};
|
||||
|
||||
if (!isDry) {
|
||||
const monthlyProductExists = products.find(
|
||||
(p) =>
|
||||
p.metadata?.eventsLimit === price.events &&
|
||||
p.recurringInterval === 'month',
|
||||
);
|
||||
const yearlyProductExists = products.find(
|
||||
(p) =>
|
||||
p.metadata?.eventsLimit === price.events &&
|
||||
p.recurringInterval === 'year',
|
||||
);
|
||||
|
||||
if (monthlyProductExists) {
|
||||
console.log('Monthly product already exists:');
|
||||
console.log(' - ID:', monthlyProductExists.id);
|
||||
console.log(' - Name:', monthlyProductExists.name);
|
||||
console.log(' - Prices:', monthlyProductExists.prices);
|
||||
} else {
|
||||
// monthly
|
||||
const monthlyProduct = await polar.products.create(productCreate);
|
||||
console.log('Monthly product created:');
|
||||
console.log(' - ID:', monthlyProduct.id);
|
||||
console.log(' - Name:', monthlyProduct.name);
|
||||
console.log(' - Prices:', monthlyProduct.prices);
|
||||
console.log(' - Recurring Interval:', monthlyProduct.recurringInterval);
|
||||
console.log(' - Events Limit:', monthlyProduct.metadata?.eventsLimit);
|
||||
}
|
||||
|
||||
if (yearlyProductExists) {
|
||||
console.log('Yearly product already exists:');
|
||||
console.log(' - ID:', yearlyProductExists.id);
|
||||
console.log(' - Name:', yearlyProductExists.name);
|
||||
console.log(' - Prices:', yearlyProductExists.prices);
|
||||
} else {
|
||||
// yearly
|
||||
productCreate.name = `${productCreate.name} (yearly)`;
|
||||
productCreate.recurringInterval = 'year';
|
||||
if (
|
||||
productCreate.prices[0] &&
|
||||
'priceAmount' in productCreate.prices[0]
|
||||
) {
|
||||
productCreate.prices[0]!.priceAmount = price.price * 100 * 10;
|
||||
}
|
||||
const yearlyProduct = await polar.products.create(productCreate);
|
||||
console.log('Yearly product created:');
|
||||
console.log(' - ID:', yearlyProduct.id);
|
||||
console.log(' - Name:', yearlyProduct.name);
|
||||
console.log(' - Prices:', yearlyProduct.prices);
|
||||
console.log(' - Recurring Interval:', yearlyProduct.recurringInterval);
|
||||
console.log(' - Events Limit:', yearlyProduct.metadata?.eventsLimit);
|
||||
}
|
||||
}
|
||||
console.log('---');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
112
packages/payments/src/polar.ts
Normal file
112
packages/payments/src/polar.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// src/polar.ts
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
export {
|
||||
validateEvent as validatePolarEvent,
|
||||
WebhookVerificationError as PolarWebhookVerificationError,
|
||||
} from '@polar-sh/sdk/webhooks';
|
||||
|
||||
export type IPolarProduct = Awaited<ReturnType<typeof getProduct>>;
|
||||
export type IPolarPrice = IPolarProduct['prices'][number];
|
||||
|
||||
export const polar = new Polar({
|
||||
accessToken: process.env.POLAR_ACCESS_TOKEN!,
|
||||
server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
|
||||
});
|
||||
|
||||
export const getSuccessUrl = (
|
||||
baseUrl: string,
|
||||
organizationId: string,
|
||||
projectId?: string,
|
||||
) =>
|
||||
projectId
|
||||
? `${baseUrl}/${organizationId}/${projectId}/settings?tab=billing`
|
||||
: `${baseUrl}/${organizationId}`;
|
||||
|
||||
export async function getProducts() {
|
||||
const products = await polar.products.list({
|
||||
limit: 100,
|
||||
isArchived: false,
|
||||
sorting: ['price_amount'],
|
||||
});
|
||||
return products.result.items.filter((product) => {
|
||||
return product.metadata.custom !== true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProduct(id: string) {
|
||||
return polar.products.get({ id });
|
||||
}
|
||||
|
||||
export async function createPortal({
|
||||
customerId,
|
||||
}: {
|
||||
customerId: string;
|
||||
}) {
|
||||
return polar.customerSessions.create({
|
||||
customerId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCheckout({
|
||||
priceId,
|
||||
organizationId,
|
||||
projectId,
|
||||
user,
|
||||
ipAddress,
|
||||
}: {
|
||||
priceId: string;
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
user: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string;
|
||||
};
|
||||
ipAddress: string;
|
||||
}) {
|
||||
return polar.checkouts.create({
|
||||
productPriceId: priceId,
|
||||
successUrl: getSuccessUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
organizationId,
|
||||
projectId,
|
||||
),
|
||||
customerEmail: user.email,
|
||||
customerName: [user.firstName, user.lastName].filter(Boolean).join(' '),
|
||||
customerIpAddress: ipAddress,
|
||||
metadata: {
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelSubscription(subscriptionId: string) {
|
||||
return polar.subscriptions.update({
|
||||
id: subscriptionId,
|
||||
subscriptionUpdate: {
|
||||
cancelAtPeriodEnd: true,
|
||||
revoke: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function reactivateSubscription(subscriptionId: string) {
|
||||
return polar.subscriptions.update({
|
||||
id: subscriptionId,
|
||||
subscriptionUpdate: {
|
||||
cancelAtPeriodEnd: false,
|
||||
revoke: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function changeSubscription(subscriptionId: string, productId: string) {
|
||||
return polar.subscriptions.update({
|
||||
id: subscriptionId,
|
||||
subscriptionUpdate: {
|
||||
productId,
|
||||
},
|
||||
});
|
||||
}
|
||||
18
packages/payments/src/prices.ts
Normal file
18
packages/payments/src/prices.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type IPrice = {
|
||||
price: number;
|
||||
events: number;
|
||||
};
|
||||
|
||||
export const PRICING: IPrice[] = [
|
||||
{ price: 0, events: 5_000 },
|
||||
{ price: 5, events: 10_000 },
|
||||
{ price: 20, events: 100_000 },
|
||||
{ price: 30, events: 250_000 },
|
||||
{ price: 50, events: 500_000 },
|
||||
{ price: 90, events: 1_000_000 },
|
||||
{ price: 180, events: 2_500_000 },
|
||||
{ price: 250, events: 5_000_000 },
|
||||
{ price: 400, events: 10_000_000 },
|
||||
// { price: 650, events: 20_000_000 },
|
||||
// { price: 900, events: 30_000_000 },
|
||||
];
|
||||
12
packages/payments/tsconfig.json
Normal file
12
packages/payments/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@openpanel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user