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:
Carl-Gerhard Lindesvärd
2025-02-26 11:24:00 +01:00
committed by GitHub
parent 86bf9dd064
commit 168ebc3430
105 changed files with 3395 additions and 463 deletions

View File

@@ -0,0 +1,2 @@
export * from './src/polar';
export * from './src/prices';

View 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"
}
}

View 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());

View 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();

View 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,
},
});
}

View 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 },
];

View File

@@ -0,0 +1,12 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}