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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
170
packages/trpc/src/routers/subscription.ts
Normal file
170
packages/trpc/src/routers/subscription.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user