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

@@ -1,26 +1,21 @@
import type {
Invite,
Organization,
Prisma,
ProjectAccess,
User,
} from '../prisma-client';
import { cacheable } from '@openpanel/redis';
import { escape } from 'sqlstring';
import { chQuery, formatClickhouseDate } from '../clickhouse/client';
import type { Invite, Prisma, ProjectAccess, User } from '../prisma-client';
import { db } from '../prisma-client';
export type IServiceOrganization = ReturnType<typeof transformOrganization>;
import { createSqlBuilder } from '../sql-builder';
import type { IServiceProject } from './project.service';
export type IServiceOrganization = Awaited<
ReturnType<typeof db.organization.findUniqueOrThrow>
>;
export type IServiceInvite = Invite;
export type IServiceMember = Prisma.MemberGetPayload<{
include: { user: true };
}> & { access: ProjectAccess[] };
export type IServiceProjectAccess = ProjectAccess;
export function transformOrganization(org: Organization) {
return {
id: org.id,
slug: org.id,
name: org.name,
createdAt: org.createdAt,
};
export function transformOrganization<T>(org: T) {
return org;
}
export async function getOrganizations(userId: string | null) {
@@ -43,7 +38,7 @@ export async function getOrganizations(userId: string | null) {
}
export function getOrganizationBySlug(slug: string) {
return db.organization.findUnique({
return db.organization.findUniqueOrThrow({
where: {
id: slug,
},
@@ -67,6 +62,11 @@ export async function getOrganizationByProjectId(projectId: string) {
return transformOrganization(project.organization);
}
export const getOrganizationByProjectIdCached = cacheable(
getOrganizationByProjectId,
60 * 60 * 24,
);
export async function getInvites(organizationId: string) {
return db.invite.findMany({
where: {
@@ -182,3 +182,58 @@ export async function connectUserToOrganization({
return member;
}
/**
* Get the total number of events during the
* current subscription period for an organization
*/
export async function getOrganizationBillingEventsCount(
organization: IServiceOrganization & { projects: IServiceProject[] },
) {
// Dont count events if the organization has no subscription
// Since we only use this for billing purposes
if (
!organization.subscriptionCurrentPeriodStart ||
!organization.subscriptionCurrentPeriodEnd
) {
return 0;
}
const { sb, getSql } = createSqlBuilder();
sb.select.count = 'COUNT(*) AS count';
sb.where.projectIds = `project_id IN (${organization.projects.map((project) => escape(project.id)).join(',')})`;
sb.where.createdAt = `BETWEEN ${formatClickhouseDate(organization.subscriptionCurrentPeriodStart)} AND ${formatClickhouseDate(organization.subscriptionCurrentPeriodEnd)}`;
const res = await chQuery<{ count: number }>(getSql());
return res[0]?.count;
}
export async function getOrganizationBillingEventsCountSerie(
organization: IServiceOrganization & { projects: { id: string }[] },
{
startDate,
endDate,
}: {
startDate: Date;
endDate: Date;
},
) {
const interval = 'day';
const { sb, getSql } = createSqlBuilder();
sb.select.count = 'COUNT(*) AS count';
sb.select.day = `toDate(toStartOf${interval.slice(0, 1).toUpperCase() + interval.slice(1)}(created_at)) AS ${interval}`;
sb.groupBy.day = interval;
sb.orderBy.day = `${interval} WITH FILL FROM toDate(${escape(formatClickhouseDate(startDate, true))}) TO toDate(${escape(formatClickhouseDate(endDate, true))}) STEP INTERVAL 1 ${interval.toUpperCase()}`;
sb.where.projectIds = `project_id IN (${organization.projects.map((project) => escape(project.id)).join(',')})`;
sb.where.createdAt = `${interval} BETWEEN ${escape(formatClickhouseDate(startDate, true))} AND ${escape(formatClickhouseDate(endDate, true))}`;
const res = await chQuery<{ count: number; day: string }>(getSql());
return res;
}
export const getOrganizationBillingEventsCountSerieCached = cacheable(
getOrganizationBillingEventsCountSerie,
60 * 10,
);

View File

@@ -1,4 +1,5 @@
import { cacheable } from '@openpanel/redis';
import { TABLE_NAMES, chQuery } from '../clickhouse/client';
import type { Prisma, Project } from '../prisma-client';
import { db } from '../prisma-client';
@@ -99,3 +100,10 @@ export async function getProjects({
return projects;
}
export const getProjectEventsCount = async (projectId: string) => {
const res = await chQuery<{ count: number }>(
`SELECT count(*) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)}`,
);
return res[0]?.count;
};

View File

@@ -1,5 +1,7 @@
import { db } from '../prisma-client';
export type IServiceUser = Awaited<ReturnType<typeof getUserById>>;
export async function getUserById(id: string) {
return db.user.findUniqueOrThrow({
where: {