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

@@ -15,6 +15,7 @@
"@clickhouse/client": "^1.2.0",
"@openpanel/common": "workspace:*",
"@openpanel/constants": "workspace:*",
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/queue": "workspace:^",
"@openpanel/redis": "workspace:*",

View File

@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "subscriptionCreatedByUserId" TEXT,
ADD COLUMN "subscriptionCustomerId" TEXT,
ADD COLUMN "subscriptionEndsAt" TIMESTAMP(3),
ADD COLUMN "subscriptionId" TEXT,
ADD COLUMN "subscriptionPriceId" TEXT,
ADD COLUMN "subscriptionProductId" TEXT,
ADD COLUMN "subscriptionStatus" TEXT;
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_subscriptionCreatedByUserId_fkey" FOREIGN KEY ("subscriptionCreatedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "eventsCount" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `eventsCount` on the `organizations` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "organizations" DROP COLUMN "eventsCount",
ADD COLUMN "subscriptionPeriodEventsCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "subscriptionStartsAt" TIMESTAMP(3);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "subscriptionPeriodLimit" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "subscriptionInterval" TEXT;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `subscriptionPeriodLimit` on the `organizations` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "organizations" DROP COLUMN "subscriptionPeriodLimit",
ADD COLUMN "subscriptionPeriodEventsLimit" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "subscriptionPeriodEventsCountExceededAt" TIMESTAMP(3);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "subscriptionCanceledAt" TIMESTAMP(3);

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "organizations" ADD COLUMN "deleteAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "deleteAt" TIMESTAMP(3);

View File

@@ -0,0 +1,41 @@
-- DropForeignKey
ALTER TABLE "clients" DROP CONSTRAINT "clients_projectId_fkey";
-- DropForeignKey
ALTER TABLE "dashboards" DROP CONSTRAINT "dashboards_projectId_fkey";
-- DropForeignKey
ALTER TABLE "event_meta" DROP CONSTRAINT "event_meta_projectId_fkey";
-- DropForeignKey
ALTER TABLE "notification_rules" DROP CONSTRAINT "notification_rules_projectId_fkey";
-- DropForeignKey
ALTER TABLE "notifications" DROP CONSTRAINT "notifications_projectId_fkey";
-- DropForeignKey
ALTER TABLE "references" DROP CONSTRAINT "references_projectId_fkey";
-- DropForeignKey
ALTER TABLE "shares" DROP CONSTRAINT "shares_projectId_fkey";
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shares" ADD CONSTRAINT "shares_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "event_meta" ADD CONSTRAINT "event_meta_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "references" ADD CONSTRAINT "references_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_rules" ADD CONSTRAINT "notification_rules_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "reports" DROP CONSTRAINT "reports_projectId_fkey";
-- AddForeignKey
ALTER TABLE "reports" ADD CONSTRAINT "reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -36,9 +36,7 @@ model Organization {
projects Project[]
members Member[]
createdByUserId String?
createdBy User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id])
ProjectAccess ProjectAccess[]
Client Client[]
Dashboard Dashboard[]
@@ -46,6 +44,29 @@ model Organization {
integrations Integration[]
invites Invite[]
// Subscription
subscriptionId String?
subscriptionCustomerId String?
subscriptionPriceId String?
subscriptionProductId String?
/// [IPrismaSubscriptionStatus]
subscriptionStatus String?
subscriptionStartsAt DateTime?
subscriptionEndsAt DateTime?
subscriptionCanceledAt DateTime?
subscriptionCreatedByUserId String?
subscriptionCreatedBy User? @relation(name: "subscriptionCreatedBy", fields: [subscriptionCreatedByUserId], references: [id])
subscriptionPeriodEventsCount Int @default(0)
subscriptionPeriodEventsCountExceededAt DateTime?
subscriptionPeriodEventsLimit Int @default(0)
subscriptionInterval String?
// When deleteAt > now(), the organization will be deleted
deleteAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("organizations")
}
@@ -54,7 +75,8 @@ model User {
email String @unique
firstName String?
lastName String?
createdOrganizations Organization[]
createdOrganizations Organization[] @relation("organizationCreatedBy")
subscriptions Organization[] @relation("subscriptionCreatedBy")
membership Member[]
sentInvites Member[] @relation("invitedBy")
createdAt DateTime @default(now())
@@ -157,6 +179,9 @@ model Project {
notificationRules NotificationRule[]
notifications Notification[]
// When deleteAt > now(), the project will be deleted
deleteAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -204,7 +229,7 @@ model Client {
secret String?
type ClientType @default(write)
projectId String?
project Project? @relation(fields: [projectId], references: [id])
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
@@ -240,7 +265,7 @@ model Dashboard {
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
projectId String
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
reports Report[]
createdAt DateTime @default(now())
@@ -269,7 +294,7 @@ model Report {
unit String?
metric Metric @default(sum)
projectId String
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
previous Boolean @default(false)
criteria String?
funnelGroup String?
@@ -287,7 +312,7 @@ model Report {
model ShareOverview {
id String @unique
projectId String @unique
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
public Boolean @default(false)
@@ -305,7 +330,7 @@ model EventMeta {
color String?
icon String?
projectId String
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -320,7 +345,7 @@ model Reference {
description String?
date DateTime @default(now())
projectId String
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -338,7 +363,7 @@ model NotificationRule {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
projectId String
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
integrations Integration[]
sendToApp Boolean @default(false)
sendToEmail Boolean @default(false)
@@ -355,7 +380,7 @@ model NotificationRule {
model Notification {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
message String
isReadAt DateTime?

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';
}
}