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

@@ -12,10 +12,10 @@ import { projectRouter } from './routers/project';
import { referenceRouter } from './routers/reference';
import { reportRouter } from './routers/report';
import { shareRouter } from './routers/share';
import { subscriptionRouter } from './routers/subscription';
import { ticketRouter } from './routers/ticket';
import { userRouter } from './routers/user';
import { createTRPCRouter } from './trpc';
/**
* This is the primary router for your server.
*
@@ -38,6 +38,7 @@ export const appRouter = createTRPCRouter({
notification: notificationRouter,
integration: integrationRouter,
auth: authRouter,
subscription: subscriptionRouter,
});
// export type definition of API

View File

@@ -32,9 +32,12 @@ import {
TABLE_NAMES,
chQuery,
createSqlBuilder,
db,
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
getOrganizationByProjectId,
getOrganizationByProjectIdCached,
getProfiles,
} from '@openpanel/db';
import type {
@@ -46,6 +49,7 @@ import type {
IGetChartDataInput,
IInterval,
} from '@openpanel/validation';
import { TRPCNotFoundError } from '../errors';
function getEventLegend(event: IChartEvent) {
return event.displayName || event.name;
@@ -268,9 +272,17 @@ export function getChartStartEndDate({
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>) {
return startDate && endDate
? { startDate: startDate, endDate: endDate }
: getDatesFromRange(range);
const ranges = getDatesFromRange(range);
if (startDate && endDate) {
return { startDate: startDate, endDate: endDate };
}
if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate };
}
return ranges;
}
export function getChartPrevStartEndDate({
@@ -492,12 +504,28 @@ export async function getChartSeries(input: IChartInputWithDates) {
}
export async function getChart(input: IChartInput) {
const organization = await getOrganizationByProjectIdCached(input.projectId);
if (!organization) {
throw TRPCNotFoundError(
`Organization not found by project id ${input.projectId} in getChart`,
);
}
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
...currentPeriod,
});
// If the current period end date is after the subscription chart end date, we need to use the subscription chart end date
if (
organization.subscriptionChartEndDate &&
new Date(currentPeriod.endDate) > organization.subscriptionChartEndDate
) {
currentPeriod.endDate = organization.subscriptionChartEndDate.toISOString();
}
const promises = [getChartSeries({ ...input, ...currentPeriod })];
if (input.previous) {

View File

@@ -2,16 +2,17 @@ import crypto from 'node:crypto';
import type { z } from 'zod';
import { stripTrailingSlash } from '@openpanel/common';
import type { ProjectType } from '@openpanel/db';
import { db, getId, getOrganizationBySlug, getUserById } from '@openpanel/db';
import type { IServiceUser, ProjectType } from '@openpanel/db';
import { zOnboardingProject } from '@openpanel/validation';
import { hashPassword } from '@openpanel/common/server';
import { addDays } from 'date-fns';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
async function createOrGetOrganization(
input: z.infer<typeof zOnboardingProject>,
userId: string,
user: IServiceUser,
) {
if (input.organizationId) {
return await getOrganizationBySlug(input.organizationId);
@@ -22,7 +23,9 @@ async function createOrGetOrganization(
data: {
id: await getId('organization', input.organization),
name: input.organization,
createdByUserId: userId,
createdByUserId: user.id,
subscriptionEndsAt: addDays(new Date(), 30),
subscriptionStatus: 'trialing',
},
});
}
@@ -72,10 +75,8 @@ export const onboardingRouter = createTRPCRouter({
if (input.app) types.push('app');
if (input.backend) types.push('backend');
const [organization, user] = await Promise.all([
createOrGetOrganization(input, ctx.session.userId),
getUserById(ctx.session.userId),
]);
const user = await getUserById(ctx.session.userId);
const organization = await createOrGetOrganization(input, user);
if (!organization?.id) {
throw new Error('Organization slug is missing');

View File

@@ -11,6 +11,7 @@ import {
import { stripTrailingSlash } from '@openpanel/common';
import { zProject } from '@openpanel/validation';
import { addDays, addHours } from 'date-fns';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -91,27 +92,58 @@ export const projectRouter = createTRPCRouter({
},
});
}),
remove: protectedProcedure
delete: protectedProcedure
.input(
z.object({
id: z.string(),
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: input.id,
projectId: input.projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
await db.project.delete({
await db.project.update({
where: {
id: input.id,
id: input.projectId,
},
data: {
deleteAt: addHours(new Date(), 24),
},
});
return true;
}),
cancelDeletion: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: input.projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
await db.project.update({
where: {
id: input.projectId,
},
data: {
deleteAt: null,
},
});
return true;
}),
});

View File

@@ -0,0 +1,170 @@
import {
db,
getOrganizationBillingEventsCountSerieCached,
getOrganizationBySlug,
} from '@openpanel/db';
import {
cancelSubscription,
changeSubscription,
createCheckout,
createPortal,
getProduct,
getProducts,
reactivateSubscription,
} from '@openpanel/payments';
import { zCheckout } from '@openpanel/validation';
import { getCache } from '@openpanel/redis';
import { subDays } from 'date-fns';
import { z } from 'zod';
import { TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const subscriptionRouter = createTRPCRouter({
getCurrent: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionProductId) {
return null;
}
return getProduct(organization.subscriptionProductId);
}),
checkout: protectedProcedure
.input(zCheckout)
.mutation(async ({ input, ctx }) => {
const [user, organization] = await Promise.all([
db.user.findFirstOrThrow({
where: {
id: ctx.session.user.id,
},
}),
db.organization.findFirstOrThrow({
where: {
id: input.organizationId,
},
}),
]);
if (
organization.subscriptionId &&
organization.subscriptionStatus === 'active'
) {
if (organization.subscriptionCanceledAt) {
await reactivateSubscription(organization.subscriptionId);
} else {
await changeSubscription(
organization.subscriptionId,
input.productId,
);
}
return null;
}
const checkout = await createCheckout({
priceId: input.productPriceId,
organizationId: input.organizationId,
projectId: input.projectId ?? undefined,
user,
ipAddress: ctx.req.ip,
});
return {
url: checkout.url,
};
}),
products: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
subscriptionPeriodEventsCount: true,
},
});
return (
await getCache('polar:products', 60 * 60 * 24, () => getProducts())
).map((product) => {
const eventsLimit = product.metadata.eventsLimit;
return {
...product,
disabled:
typeof eventsLimit === 'number' &&
organization.subscriptionPeriodEventsCount >= eventsLimit
? 'This product is not applicable since you have exceeded the limits for this subscription.'
: null,
};
});
}),
usage: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ input }) => {
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
include: {
projects: { select: { id: true } },
},
});
if (
organization.hasSubscription &&
organization.subscriptionStartsAt &&
organization.subscriptionEndsAt
) {
return getOrganizationBillingEventsCountSerieCached(organization, {
startDate: organization.subscriptionStartsAt,
endDate: organization.subscriptionEndsAt,
});
}
return getOrganizationBillingEventsCountSerieCached(organization, {
startDate: subDays(new Date(), 30),
endDate: new Date(),
});
}),
cancelSubscription: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionId) {
throw TRPCBadRequestError('Organization has no subscription');
}
const res = await cancelSubscription(organization.subscriptionId);
return res;
}),
portal: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionCustomerId) {
throw TRPCBadRequestError('Organization has no subscription');
}
const portal = await createPortal({
customerId: organization.subscriptionCustomerId,
});
return {
url: portal.customerPortalUrl,
};
}),
});