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,6 +1,6 @@
import { type Redis, getRedisCache, runEvery } from '@openpanel/redis';
import { type Redis, getRedisCache } from '@openpanel/redis';
import { getSafeJson } from '@openpanel/common';
import { getSafeJson } from '@openpanel/json';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import type { IClickhouseBotEvent } from '../services/event.service';
import { BaseBuffer } from './base-buffer';

View File

@@ -1,8 +1,9 @@
import { getSafeJson, setSuperJson } from '@openpanel/common';
import { getSafeJson, setSuperJson } from '@openpanel/json';
import {
type Redis,
getRedisCache,
getRedisPub,
publishEvent,
runEvery,
} from '@openpanel/redis';
import { ch } from '../clickhouse/client';
@@ -260,29 +261,12 @@ return "OK"
if (!_multi) {
await multi.exec();
}
await this.publishEvent('event:received', event);
await publishEvent('events', 'received', transformEvent(event), multi);
} catch (error) {
this.logger.error('Failed to add event to Redis buffer', { error });
}
}
private async publishEvent(
channel: string,
event: IClickhouseEvent,
multi?: ReturnType<Redis['multi']>,
) {
try {
await (multi || getRedisPub()).publish(
channel,
setSuperJson(
transformEvent(event) as unknown as Record<string, unknown>,
),
);
} catch (error) {
this.logger.warn('Failed to publish event', { error });
}
}
private async getEligableSessions({ minEventsInSession = 2 }) {
const sessionsSorted = await getRedisCache().eval(
this.processSessionsScript,
@@ -429,7 +413,7 @@ return "OK"
// (E) Publish "saved" events.
const pubMulti = getRedisPub().multi();
for (const event of eventsToClickhouse) {
await this.publishEvent('event:saved', event, pubMulti);
await publishEvent('events', 'saved', transformEvent(event), pubMulti);
}
await pubMulti.exec();

View File

@@ -1,6 +1,6 @@
import { deepMergeObjects } from '@openpanel/common';
import { getSafeJson } from '@openpanel/json';
import type { ILogger } from '@openpanel/logger';
// import { getSafeJson } from '@openpanel/json';
import { type Redis, getRedisCache } from '@openpanel/redis';
import shallowEqual from 'fast-deep-equal';
import { omit } from 'ramda';
@@ -8,15 +8,6 @@ import { TABLE_NAMES, ch, chQuery } from '../clickhouse/client';
import type { IClickhouseProfile } from '../services/profile.service';
import { BaseBuffer } from './base-buffer';
// TODO: Use @openpanel/json when polar is merged
function getSafeJson<T>(str: string): T | null {
try {
return JSON.parse(str);
} catch (e) {
return null;
}
}
export class ProfileBuffer extends BaseBuffer {
private batchSize = process.env.PROFILE_BUFFER_BATCH_SIZE
? Number.parseInt(process.env.PROFILE_BUFFER_BATCH_SIZE, 10)

View File

@@ -1,11 +1,31 @@
import { createLogger } from '@openpanel/logger';
import { PrismaClient } from '@prisma/client';
import { type Organization, PrismaClient } from '@prisma/client';
import { readReplicas } from '@prisma/extension-read-replicas';
export * from '@prisma/client';
const logger = createLogger({ name: 'db' });
const isWillBeCanceled = (
organization: Pick<
Organization,
'subscriptionStatus' | 'subscriptionCanceledAt' | 'subscriptionEndsAt'
>,
) =>
organization.subscriptionStatus === 'active' &&
organization.subscriptionCanceledAt &&
organization.subscriptionEndsAt;
const isCanceled = (
organization: Pick<
Organization,
'subscriptionStatus' | 'subscriptionCanceledAt'
>,
) =>
organization.subscriptionStatus === 'canceled' &&
organization.subscriptionCanceledAt &&
organization.subscriptionCanceledAt < new Date();
const getPrismaClient = () => {
const prisma = new PrismaClient({
log: ['error'],
@@ -32,15 +52,182 @@ const getPrismaClient = () => {
return query(args);
},
},
})
.$extends({
result: {
organization: {
subscriptionStatus: {
needs: { subscriptionStatus: true, subscriptionCanceledAt: true },
compute(org) {
return org.subscriptionStatus || 'trialing';
},
},
hasSubscription: {
needs: { subscriptionStatus: true, subscriptionEndsAt: true },
compute(org) {
if (
[null, 'canceled', 'trialing'].includes(org.subscriptionStatus)
) {
return false;
}
return true;
},
},
slug: {
needs: { id: true },
compute(org) {
return org.id;
},
},
subscriptionChartEndDate: {
needs: {
subscriptionEndsAt: true,
subscriptionPeriodEventsCountExceededAt: true,
},
compute(org) {
if (
org.subscriptionEndsAt &&
org.subscriptionPeriodEventsCountExceededAt
) {
return org.subscriptionEndsAt >
org.subscriptionPeriodEventsCountExceededAt
? org.subscriptionPeriodEventsCountExceededAt
: org.subscriptionEndsAt;
}
if (org.subscriptionEndsAt) {
return org.subscriptionEndsAt;
}
return new Date();
},
},
isTrial: {
needs: { subscriptionStatus: true, subscriptionEndsAt: true },
compute(org) {
const isSubscriptionInFuture =
org.subscriptionEndsAt && org.subscriptionEndsAt > new Date();
return (
(org.subscriptionStatus === 'trialing' ||
org.subscriptionStatus === null) &&
isSubscriptionInFuture
);
},
},
isCanceled: {
needs: { subscriptionStatus: true, subscriptionCanceledAt: true },
compute(org) {
return isCanceled(org);
},
},
isWillBeCanceled: {
needs: {
subscriptionStatus: true,
subscriptionCanceledAt: true,
subscriptionEndsAt: true,
},
compute(org) {
return isWillBeCanceled(org);
},
},
isExpired: {
needs: {
subscriptionEndsAt: true,
subscriptionStatus: true,
subscriptionCanceledAt: true,
},
compute(org) {
if (isCanceled(org)) {
return false;
}
if (isWillBeCanceled(org)) {
return false;
}
return (
org.subscriptionEndsAt && org.subscriptionEndsAt < new Date()
);
},
},
isExceeded: {
needs: {
subscriptionPeriodEventsCount: true,
subscriptionPeriodEventsLimit: true,
},
compute(org) {
return (
org.subscriptionPeriodEventsCount >
org.subscriptionPeriodEventsLimit
);
},
},
subscriptionCurrentPeriodStart: {
needs: { subscriptionStartsAt: true, subscriptionInterval: true },
compute(org) {
if (!org.subscriptionStartsAt) return org.subscriptionStartsAt;
if (org.subscriptionInterval === 'year') {
const startDay = org.subscriptionStartsAt.getUTCDate();
const now = new Date();
return new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
startDay,
0,
0,
0,
0,
),
);
}
return org.subscriptionStartsAt;
},
},
subscriptionCurrentPeriodEnd: {
needs: {
subscriptionStartsAt: true,
subscriptionEndsAt: true,
subscriptionInterval: true,
},
compute(org) {
if (!org.subscriptionStartsAt) return org.subscriptionEndsAt;
if (org.subscriptionInterval === 'year') {
const startDay = org.subscriptionStartsAt.getUTCDate();
const now = new Date();
return new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth() + 1,
startDay - 1,
0,
0,
0,
0,
),
);
}
return org.subscriptionEndsAt;
},
},
},
},
});
return prisma;
};
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof getPrismaClient> | undefined;
prisma: ReturnType<typeof getPrismaClient>;
};
export const db = globalForPrisma.prisma ?? getPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}

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: {

View File

@@ -19,5 +19,13 @@ declare global {
type IPrismaClickhouseEvent = IClickhouseEvent;
type IPrismaClickhouseProfile = IClickhouseProfile;
type IPrismaClickhouseBotEvent = IClickhouseBotEvent;
type IPrismaSubscriptionStatus =
| 'incomplete'
| 'incomplete_expired'
| 'trialing'
| 'active'
| 'past_due'
| 'canceled'
| 'unpaid';
}
}