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
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user