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

@@ -14,6 +14,11 @@ export async function bootCron() {
type: 'salt',
pattern: '0 0 * * *',
},
{
name: 'deleteProjects',
type: 'deleteProjects',
pattern: '0 * * * *',
},
{
name: 'flush',
type: 'flushEvents',

View File

@@ -0,0 +1,34 @@
import { logger } from '@/utils/logger';
import { generateSalt } from '@openpanel/common/server';
import { TABLE_NAMES, ch, chQuery, db } from '@openpanel/db';
import { escape } from 'sqlstring';
export async function deleteProjects() {
const projects = await db.project.findMany({
where: {
deleteAt: {
lte: new Date(),
},
},
});
if (projects.length === 0) {
return;
}
for (const project of projects) {
await db.project.delete({
where: {
id: project.id,
},
});
}
await ch.command({
query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
});
logger.info(`Deleted ${projects.length} projects`, {
projects,
});
}

View File

@@ -3,6 +3,7 @@ import type { Job } from 'bullmq';
import { eventBuffer, profileBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import { deleteProjects } from './cron.delete-projects';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
@@ -20,5 +21,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'ping': {
return await ping();
}
case 'deleteProjects': {
return await deleteProjects();
}
}
}

View File

@@ -1,55 +1,12 @@
import type { Job } from 'bullmq';
import { escape } from 'sqlstring';
import { TABLE_NAMES, chQuery, db } from '@openpanel/db';
import type {
EventsQueuePayload,
EventsQueuePayloadCreateSessionEnd,
EventsQueuePayloadIncomingEvent,
} from '@openpanel/queue';
import { cacheable } from '@openpanel/redis';
import { createSessionEnd } from './events.create-session-end';
import { incomingEvent } from './events.incoming-event';
export async function eventsJob(job: Job<EventsQueuePayload>) {
switch (job.data.type) {
case 'incomingEvent': {
return await incomingEvent(job as Job<EventsQueuePayloadIncomingEvent>);
}
case 'createSessionEnd': {
try {
await updateEventsCount(job.data.payload.projectId);
} catch (e) {
job.log('Failed to update count');
}
return await createSessionEnd(
job as Job<EventsQueuePayloadCreateSessionEnd>,
);
}
}
}
const getProjectEventsCount = cacheable(async function getProjectEventsCount(
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;
}, 60 * 60);
async function updateEventsCount(projectId: string) {
const count = await getProjectEventsCount(projectId);
if (count) {
await db.project.update({
where: {
id: projectId,
},
data: {
eventsCount: count,
},
});
}
return await incomingEvent(job as Job<EventsQueuePayloadIncomingEvent>);
}

View File

@@ -1,11 +1,11 @@
import type { Job } from 'bullmq';
import { setSuperJson } from '@openpanel/common';
import { db } from '@openpanel/db';
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
import { setSuperJson } from '@openpanel/json';
import type { NotificationQueuePayload } from '@openpanel/queue';
import { getRedisPub } from '@openpanel/redis';
import { getRedisPub, publishEvent } from '@openpanel/redis';
export async function notificationJob(job: Job<NotificationQueuePayload>) {
switch (job.data.type) {
@@ -13,7 +13,7 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
const { notification } = job.data.payload;
if (notification.sendToApp) {
getRedisPub().publish('notification', setSuperJson(notification));
publishEvent('notification', 'created', notification);
// empty for now
return;
}

View File

@@ -2,8 +2,79 @@ import type { Job } from 'bullmq';
import type { SessionsQueuePayload } from '@openpanel/queue';
import { logger } from '@/utils/logger';
import {
db,
getOrganizationBillingEventsCount,
getOrganizationByProjectIdCached,
getProjectEventsCount,
} from '@openpanel/db';
import { cacheable } from '@openpanel/redis';
import { createSessionEnd } from './events.create-session-end';
export async function sessionsJob(job: Job<SessionsQueuePayload>) {
return await createSessionEnd(job);
const res = await createSessionEnd(job);
try {
await updateEventsCount(job.data.payload.projectId);
} catch (e) {
logger.error('Failed to update events count', e);
}
return res;
}
const updateEventsCount = cacheable(async function updateEventsCount(
projectId: string,
) {
const organization = await db.organization.findFirst({
where: {
projects: {
some: {
id: projectId,
},
},
},
include: {
projects: true,
},
});
if (!organization) {
return;
}
const organizationEventsCount =
await getOrganizationBillingEventsCount(organization);
const projectEventsCount = await getProjectEventsCount(projectId);
if (projectEventsCount) {
await db.project.update({
where: {
id: projectId,
},
data: {
eventsCount: projectEventsCount,
},
});
}
if (organizationEventsCount) {
await db.organization.update({
where: {
id: organization.id,
},
data: {
subscriptionPeriodEventsCount: organizationEventsCount,
subscriptionPeriodEventsCountExceededAt:
organizationEventsCount >
organization.subscriptionPeriodEventsLimit &&
!organization.subscriptionPeriodEventsCountExceededAt
? new Date()
: organizationEventsCount <=
organization.subscriptionPeriodEventsLimit
? null
: organization.subscriptionPeriodEventsCountExceededAt,
},
});
await getOrganizationByProjectIdCached.clear(projectId);
}
}, 60 * 60);