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,11 +1,11 @@
import { defineConfig } from 'tsup';
import config from '@openpanel/tsconfig/tsup.config.json' assert {
type: 'json',
};
export default defineConfig({
...(config as any),
entry: ['src/cli.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});

View File

@@ -1,5 +1,4 @@
import { anyPass, assocPath, isEmpty, isNil, reject } from 'ramda';
import superjson from 'superjson';
export function toDots(
obj: Record<string, unknown>,
@@ -48,26 +47,6 @@ export function toObject(
export const strip = reject(anyPass([isEmpty, isNil]));
export function getSafeJson<T>(str: string): T | null {
try {
return JSON.parse(str);
} catch (e) {
return null;
}
}
export function getSuperJson<T>(str: string): T | null {
const json = getSafeJson<T>(str);
if (typeof json === 'object' && json !== null && 'json' in json) {
return superjson.parse<T>(str);
}
return json;
}
export function setSuperJson(str: Record<string, unknown>): string {
return superjson.stringify(str);
}
type AnyObject = Record<string, any>;
export function deepMergeObjects<T>(target: AnyObject, source: AnyObject): T {

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

21
packages/json/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import superjson from 'superjson';
export function getSafeJson<T>(str: string): T | null {
try {
return JSON.parse(str);
} catch (e) {
return null;
}
}
export function getSuperJson<T>(str: string): T | null {
const json = getSafeJson<T>(str);
if (typeof json === 'object' && json !== null && 'json' in json) {
return superjson.parse<T>(str);
}
return json;
}
export function setSuperJson(str: any): string {
return superjson.stringify(str);
}

View File

@@ -0,0 +1,17 @@
{
"name": "@openpanel/json",
"version": "0.0.1",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"superjson": "^1.13.3"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"@openpanel/validation": "workspace:*",
"@types/node": "20.14.8",
"typescript": "^5.2.2"
}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/server/*": ["./src/server/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,2 @@
export * from './src/polar';
export * from './src/prices';

View File

@@ -0,0 +1,22 @@
{
"name": "@openpanel/payments",
"version": "0.0.1",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@polar-sh/sdk": "^0.26.1"
},
"devDependencies": {
"@openpanel/db": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/inquirer": "^9.0.7",
"@types/inquirer-autocomplete-prompt": "^3.0.3",
"@types/node": "20.14.8",
"@types/react": "^18.2.0",
"inquirer": "^9.3.5",
"inquirer-autocomplete-prompt": "^3.0.1",
"typescript": "^5.2.2"
}
}

View File

@@ -0,0 +1,246 @@
import { db } from '@openpanel/db';
import { Polar } from '@polar-sh/sdk';
import type { ProductCreate } from '@polar-sh/sdk/models/components/productcreate';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { PRICING, getProducts, getSuccessUrl, polar } from '..';
import { formatEventsCount } from './create-products';
// Register the autocomplete prompt
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
type Interval = 'month' | 'year';
interface Answers {
isProduction: boolean;
organizationId: string;
userId: string;
interval: Interval;
price: number;
eventsLimit: number;
polarOrganizationId: string;
polarApiKey: string;
}
async function promptForInput() {
// Get all organizations first
const organizations = await db.organization.findMany({
select: {
id: true,
name: true,
},
});
const answers = await inquirer.prompt<Answers>([
{
type: 'list',
name: 'isProduction',
message: 'Is this for production?',
choices: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
},
{
type: 'string',
name: 'polarOrganizationId',
message: 'Enter your Polar organization ID:',
},
{
type: 'string',
name: 'polarApiKey',
message: 'Enter your Polar API key:',
validate: (input: string) => {
if (!input) return 'API key is required';
return true;
},
},
{
type: 'autocomplete',
name: 'organizationId',
message: 'Select organization:',
source: (answersSoFar: any, input = '') => {
return organizations
.filter(
(org) =>
org.name.toLowerCase().includes(input.toLowerCase()) ||
org.id.toLowerCase().includes(input.toLowerCase()),
)
.map((org) => ({
name: `${org.name} (${org.id})`,
value: org.id,
}));
},
},
{
type: 'autocomplete',
name: 'userId',
message: 'Select user:',
source: (answersSoFar: Answers, input = '') => {
return db.organization
.findFirst({
where: {
id: answersSoFar.organizationId,
},
include: {
members: {
select: {
role: true,
user: true,
},
},
},
})
.then((org) =>
org?.members
.filter(
(member) =>
member.user?.email
.toLowerCase()
.includes(input.toLowerCase()) ||
member.user?.firstName
?.toLowerCase()
.includes(input.toLowerCase()),
)
.map((member) => ({
name: `${
[member.user?.firstName, member.user?.lastName]
.filter(Boolean)
.join(' ') || 'No name'
} (${member.user?.email}) [${member.role}]`,
value: member.user?.id,
})),
);
},
},
{
type: 'list',
name: 'interval',
message: 'Select billing interval:',
choices: [
{ name: 'Monthly', value: 'month' },
{ name: 'Yearly', value: 'year' },
],
},
{
type: 'number',
name: 'price',
message: 'Enter price',
validate: (input: number) => {
if (!Number.isInteger(input)) return false;
if (input < 0) return false;
return true;
},
},
{
type: 'number',
name: 'eventsLimit',
message: 'Enter events limit:',
validate: (input: number) => {
if (!Number.isInteger(input)) return false;
if (input < 0) return false;
return true;
},
},
]);
return answers;
}
async function main() {
console.log('Creating custom pricing...');
const input = await promptForInput();
const polar = new Polar({
accessToken: input.polarApiKey!,
server: input.isProduction ? 'production' : 'sandbox',
});
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
id: true,
name: true,
projects: {
select: {
id: true,
},
},
},
});
const user = await db.user.findUniqueOrThrow({
where: {
id: input.userId,
},
});
console.log('\nReview the following settings:');
console.table({
...input,
organization: organization?.name,
email: user?.email,
name:
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || 'No name',
});
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message: 'Do you want to proceed?',
default: false,
},
]);
if (!confirmed) {
console.log('Operation canceled');
return;
}
const product = await polar.products.create({
organizationId: input.polarApiKey.includes('_oat_')
? undefined
: input.polarOrganizationId,
name: `Custom product for ${organization.name}`,
recurringInterval: 'month',
prices: [
{
amountType: 'fixed',
priceAmount: input.price * 100,
},
],
metadata: {
eventsLimit: input.eventsLimit,
organizationId: organization.id,
userId: user.id,
custom: true,
},
});
const checkoutLink = await polar.checkoutLinks.create({
productId: product.id,
allowDiscountCodes: false,
metadata: {
organizationId: organization.id,
userId: user.id,
},
successUrl: getSuccessUrl(
input.isProduction
? 'https://dashboard.openpanel.dev'
: 'http://localhost:3000',
organization.id,
organization.projects[0]?.id,
),
});
console.table(checkoutLink);
console.log('Custom pricing created successfully!');
}
main()
.catch(console.error)
.finally(() => db.$disconnect());

View File

@@ -0,0 +1,179 @@
import { Polar } from '@polar-sh/sdk';
import type { ProductCreate } from '@polar-sh/sdk/models/components/productcreate';
import inquirer from 'inquirer';
import { PRICING } from '../';
export function formatEventsCount(events: number) {
return new Intl.NumberFormat('en-gb', {
notation: 'compact',
}).format(events);
}
interface Answers {
isProduction: boolean;
polarOrganizationId: string;
polarApiKey: string;
}
async function promptForInput() {
const answers = await inquirer.prompt<Answers>([
{
type: 'list',
name: 'isProduction',
message: 'Is this for production?',
choices: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
},
{
type: 'string',
name: 'polarOrganizationId',
message: 'Enter your Polar organization ID:',
},
{
type: 'string',
name: 'polarApiKey',
message: 'Enter your Polar API key:',
validate: (input: string) => {
if (!input) return 'API key is required';
return true;
},
},
]);
return answers;
}
async function main() {
const input = await promptForInput();
const polar = new Polar({
accessToken: input.polarApiKey!,
server: input.isProduction ? 'production' : 'sandbox',
});
async function getProducts() {
const products = await polar.products.list({
limit: 100,
isArchived: false,
sorting: ['price_amount'],
});
return products.result.items.filter((product) => {
return product.metadata.custom !== true;
});
}
const isDry = process.argv.includes('--dry');
const products = await getProducts();
for (const price of PRICING) {
if (price.price === 0) {
const exists = products.find(
(p) =>
p.metadata?.eventsLimit === price.events &&
p.recurringInterval === 'month',
);
if (exists) {
console.log('Free product already exists:');
console.log(' - ID:', exists.id);
console.log(' - Name:', exists.name);
} else {
const product = await polar.products.create({
organizationId: input.polarApiKey.includes('_oat_')
? undefined
: input.polarOrganizationId,
name: `${formatEventsCount(price.events)} events per month (FREE)`,
recurringInterval: 'month',
prices: [
{
amountType: 'free',
},
],
metadata: {
eventsLimit: price.events,
},
});
console.log('Free product created:');
console.log(' - ID:', product.id);
console.log(' - Name:', product.name);
}
continue;
}
const productCreate: ProductCreate = {
organizationId: input.polarApiKey.includes('_oat_')
? undefined
: input.polarOrganizationId,
name: `${formatEventsCount(price.events)} events per month`,
prices: [
{
priceAmount: price.price * 100,
amountType: 'fixed',
priceCurrency: 'usd',
},
],
recurringInterval: 'month',
metadata: {
eventsLimit: price.events,
},
};
if (!isDry) {
const monthlyProductExists = products.find(
(p) =>
p.metadata?.eventsLimit === price.events &&
p.recurringInterval === 'month',
);
const yearlyProductExists = products.find(
(p) =>
p.metadata?.eventsLimit === price.events &&
p.recurringInterval === 'year',
);
if (monthlyProductExists) {
console.log('Monthly product already exists:');
console.log(' - ID:', monthlyProductExists.id);
console.log(' - Name:', monthlyProductExists.name);
console.log(' - Prices:', monthlyProductExists.prices);
} else {
// monthly
const monthlyProduct = await polar.products.create(productCreate);
console.log('Monthly product created:');
console.log(' - ID:', monthlyProduct.id);
console.log(' - Name:', monthlyProduct.name);
console.log(' - Prices:', monthlyProduct.prices);
console.log(' - Recurring Interval:', monthlyProduct.recurringInterval);
console.log(' - Events Limit:', monthlyProduct.metadata?.eventsLimit);
}
if (yearlyProductExists) {
console.log('Yearly product already exists:');
console.log(' - ID:', yearlyProductExists.id);
console.log(' - Name:', yearlyProductExists.name);
console.log(' - Prices:', yearlyProductExists.prices);
} else {
// yearly
productCreate.name = `${productCreate.name} (yearly)`;
productCreate.recurringInterval = 'year';
if (
productCreate.prices[0] &&
'priceAmount' in productCreate.prices[0]
) {
productCreate.prices[0]!.priceAmount = price.price * 100 * 10;
}
const yearlyProduct = await polar.products.create(productCreate);
console.log('Yearly product created:');
console.log(' - ID:', yearlyProduct.id);
console.log(' - Name:', yearlyProduct.name);
console.log(' - Prices:', yearlyProduct.prices);
console.log(' - Recurring Interval:', yearlyProduct.recurringInterval);
console.log(' - Events Limit:', yearlyProduct.metadata?.eventsLimit);
}
}
console.log('---');
}
}
main();

View File

@@ -0,0 +1,112 @@
// src/polar.ts
import { Polar } from '@polar-sh/sdk';
export {
validateEvent as validatePolarEvent,
WebhookVerificationError as PolarWebhookVerificationError,
} from '@polar-sh/sdk/webhooks';
export type IPolarProduct = Awaited<ReturnType<typeof getProduct>>;
export type IPolarPrice = IPolarProduct['prices'][number];
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
export const getSuccessUrl = (
baseUrl: string,
organizationId: string,
projectId?: string,
) =>
projectId
? `${baseUrl}/${organizationId}/${projectId}/settings?tab=billing`
: `${baseUrl}/${organizationId}`;
export async function getProducts() {
const products = await polar.products.list({
limit: 100,
isArchived: false,
sorting: ['price_amount'],
});
return products.result.items.filter((product) => {
return product.metadata.custom !== true;
});
}
export async function getProduct(id: string) {
return polar.products.get({ id });
}
export async function createPortal({
customerId,
}: {
customerId: string;
}) {
return polar.customerSessions.create({
customerId,
});
}
export async function createCheckout({
priceId,
organizationId,
projectId,
user,
ipAddress,
}: {
priceId: string;
organizationId: string;
projectId?: string;
user: {
id: string;
firstName: string | null;
lastName: string | null;
email: string;
};
ipAddress: string;
}) {
return polar.checkouts.create({
productPriceId: priceId,
successUrl: getSuccessUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL!,
organizationId,
projectId,
),
customerEmail: user.email,
customerName: [user.firstName, user.lastName].filter(Boolean).join(' '),
customerIpAddress: ipAddress,
metadata: {
organizationId,
userId: user.id,
},
});
}
export function cancelSubscription(subscriptionId: string) {
return polar.subscriptions.update({
id: subscriptionId,
subscriptionUpdate: {
cancelAtPeriodEnd: true,
revoke: null,
},
});
}
export function reactivateSubscription(subscriptionId: string) {
return polar.subscriptions.update({
id: subscriptionId,
subscriptionUpdate: {
cancelAtPeriodEnd: false,
revoke: null,
},
});
}
export function changeSubscription(subscriptionId: string, productId: string) {
return polar.subscriptions.update({
id: subscriptionId,
subscriptionUpdate: {
productId,
},
});
}

View File

@@ -0,0 +1,18 @@
export type IPrice = {
price: number;
events: number;
};
export const PRICING: IPrice[] = [
{ price: 0, events: 5_000 },
{ price: 5, events: 10_000 },
{ price: 20, events: 100_000 },
{ price: 30, events: 250_000 },
{ price: 50, events: 500_000 },
{ price: 90, events: 1_000_000 },
{ price: 180, events: 2_500_000 },
{ price: 250, events: 5_000_000 },
{ price: 400, events: 10_000_000 },
// { price: 650, events: 20_000_000 },
// { price: 900, events: 30_000_000 },
];

View File

@@ -0,0 +1,12 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -60,11 +60,16 @@ export type CronQueuePayloadPing = {
type: 'ping';
payload: undefined;
};
export type CronQueuePayloadProject = {
type: 'deleteProjects';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
| CronQueuePayloadFlushProfiles
| CronQueuePayloadPing;
| CronQueuePayloadPing
| CronQueuePayloadProject;
export type CronQueueType = CronQueuePayload['type'];

View File

@@ -1,5 +1,28 @@
import { getRedisCache } from './redis';
export async function getCache<T>(
key: string,
expireInSec: number,
fn: () => Promise<T>,
): Promise<T> {
const hit = await getRedisCache().get(key);
if (hit) {
return JSON.parse(hit, (_, value) => {
if (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(value)
) {
return new Date(value);
}
return value;
});
}
const data = await fn();
await getRedisCache().setex(key, expireInSec, JSON.stringify(data));
return data;
}
export function cacheable<T extends (...args: any) => any>(
fn: T,
expireInSec: number,
@@ -37,7 +60,15 @@ export function cacheable<T extends (...args: any) => any>(
const cached = await getRedisCache().get(key);
if (cached) {
try {
return JSON.parse(cached);
return JSON.parse(cached, (_, value) => {
if (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/.test(value)
) {
return new Date(value);
}
return value;
});
} catch (e) {
console.error('Failed to parse cache', e);
}

View File

@@ -1,3 +1,4 @@
export * from './redis';
export * from './cachable';
export * from './run-every';
export * from './publisher';

View File

@@ -6,9 +6,11 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/json": "workspace:*",
"ioredis": "^5.4.1"
},
"devDependencies": {
"@openpanel/db": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/node": "20.14.8",
"prisma": "^5.1.1",

View File

@@ -0,0 +1,86 @@
import { type Redis, getRedisPub, getRedisSub } from './redis';
import type { IServiceEvent, Notification } from '@openpanel/db';
import { getSuperJson, setSuperJson } from '@openpanel/json';
export type IPublishChannels = {
organization: {
subscription_updated: {
organizationId: string;
};
};
events: {
received: IServiceEvent;
saved: IServiceEvent;
};
notification: {
created: Notification;
};
};
export function getSubscribeChannel<Channel extends keyof IPublishChannels>(
channel: Channel,
type: keyof IPublishChannels[Channel],
) {
return `${channel}:${String(type)}`;
}
export function publishEvent<Channel extends keyof IPublishChannels>(
channel: Channel,
type: keyof IPublishChannels[Channel],
event: IPublishChannels[Channel][typeof type],
multi?: ReturnType<Redis['multi']>,
) {
const redis = multi ?? getRedisPub();
return redis.publish(getSubscribeChannel(channel, type), setSuperJson(event));
}
export function parsePublishedEvent<Channel extends keyof IPublishChannels>(
_channel: Channel,
_type: keyof IPublishChannels[Channel],
message: string,
): IPublishChannels[Channel][typeof _type] {
return getSuperJson<IPublishChannels[Channel][typeof _type]>(message)!;
}
export function subscribeToPublishedEvent<
Channel extends keyof IPublishChannels,
>(
channel: Channel,
type: keyof IPublishChannels[Channel],
callback: (event: IPublishChannels[Channel][typeof type]) => void,
) {
const subscribeChannel = getSubscribeChannel(channel, type);
getRedisSub().subscribe(subscribeChannel);
const message = (messageChannel: string, message: string) => {
if (subscribeChannel === messageChannel) {
const event = parsePublishedEvent(channel, type, message);
if (event) {
callback(event);
}
}
};
getRedisSub().on('message', message);
return () => {
getRedisSub().unsubscribe(subscribeChannel);
getRedisSub().off('message', message);
};
}
export function psubscribeToPublishedEvent(
pattern: string,
callback: (key: string) => void,
) {
getRedisSub().psubscribe(pattern);
const pmessage = (_: unknown, pattern: string, key: string) => callback(key);
getRedisSub().on('pmessage', pmessage);
return () => {
getRedisSub().punsubscribe(pattern);
getRedisSub().off('pmessage', pmessage);
};
}

View File

@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup';
import config from '@openpanel/tsconfig/tsup.config.json' assert {
type: 'json',
};
export default defineConfig({
...(config as any),
entry: ['index.ts', 'cdn.ts'],
format: ['cjs', 'esm', 'iife'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});

View File

@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup';
import config from '@openpanel/tsconfig/tsup.config.json' assert {
type: 'json',
};
export default defineConfig({
...(config as any),
entry: ['index.tsx', 'server.ts'],
external: ['react', 'next'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});

View File

@@ -1,9 +1,11 @@
import { defineConfig } from 'tsup';
import config from '@openpanel/tsconfig/tsup.config.json' assert {
type: 'json',
};
export default defineConfig({
...(config as any),
entry: ['index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'tsup';
import config from '@openpanel/tsconfig/tsup.config.json' assert {
type: 'json',
};
export default defineConfig(config as any);
export default defineConfig({
entry: ['index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});

View File

@@ -1,11 +1,11 @@
import { defineConfig } from 'tsup';
import config from '@openpanel/tsconfig/tsup.config.json' assert {
type: 'json',
};
export default defineConfig({
...(config as any),
entry: ['index.ts', 'src/tracker.ts'],
format: ['cjs', 'esm', 'iife'],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
minify: true,
});

View File

@@ -12,6 +12,7 @@
"@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/payments": "workspace:^",
"@openpanel/redis": "workspace:*",
"@openpanel/validation": "workspace:*",
"@seventy-seven/sdk": "0.0.0-beta.2",

View File

@@ -12,10 +12,10 @@ import { projectRouter } from './routers/project';
import { referenceRouter } from './routers/reference';
import { reportRouter } from './routers/report';
import { shareRouter } from './routers/share';
import { subscriptionRouter } from './routers/subscription';
import { ticketRouter } from './routers/ticket';
import { userRouter } from './routers/user';
import { createTRPCRouter } from './trpc';
/**
* This is the primary router for your server.
*
@@ -38,6 +38,7 @@ export const appRouter = createTRPCRouter({
notification: notificationRouter,
integration: integrationRouter,
auth: authRouter,
subscription: subscriptionRouter,
});
// export type definition of API

View File

@@ -32,9 +32,12 @@ import {
TABLE_NAMES,
chQuery,
createSqlBuilder,
db,
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
getOrganizationByProjectId,
getOrganizationByProjectIdCached,
getProfiles,
} from '@openpanel/db';
import type {
@@ -46,6 +49,7 @@ import type {
IGetChartDataInput,
IInterval,
} from '@openpanel/validation';
import { TRPCNotFoundError } from '../errors';
function getEventLegend(event: IChartEvent) {
return event.displayName || event.name;
@@ -268,9 +272,17 @@ export function getChartStartEndDate({
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>) {
return startDate && endDate
? { startDate: startDate, endDate: endDate }
: getDatesFromRange(range);
const ranges = getDatesFromRange(range);
if (startDate && endDate) {
return { startDate: startDate, endDate: endDate };
}
if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate };
}
return ranges;
}
export function getChartPrevStartEndDate({
@@ -492,12 +504,28 @@ export async function getChartSeries(input: IChartInputWithDates) {
}
export async function getChart(input: IChartInput) {
const organization = await getOrganizationByProjectIdCached(input.projectId);
if (!organization) {
throw TRPCNotFoundError(
`Organization not found by project id ${input.projectId} in getChart`,
);
}
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
...currentPeriod,
});
// If the current period end date is after the subscription chart end date, we need to use the subscription chart end date
if (
organization.subscriptionChartEndDate &&
new Date(currentPeriod.endDate) > organization.subscriptionChartEndDate
) {
currentPeriod.endDate = organization.subscriptionChartEndDate.toISOString();
}
const promises = [getChartSeries({ ...input, ...currentPeriod })];
if (input.previous) {

View File

@@ -2,16 +2,17 @@ import crypto from 'node:crypto';
import type { z } from 'zod';
import { stripTrailingSlash } from '@openpanel/common';
import type { ProjectType } from '@openpanel/db';
import { db, getId, getOrganizationBySlug, getUserById } from '@openpanel/db';
import type { IServiceUser, ProjectType } from '@openpanel/db';
import { zOnboardingProject } from '@openpanel/validation';
import { hashPassword } from '@openpanel/common/server';
import { addDays } from 'date-fns';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
async function createOrGetOrganization(
input: z.infer<typeof zOnboardingProject>,
userId: string,
user: IServiceUser,
) {
if (input.organizationId) {
return await getOrganizationBySlug(input.organizationId);
@@ -22,7 +23,9 @@ async function createOrGetOrganization(
data: {
id: await getId('organization', input.organization),
name: input.organization,
createdByUserId: userId,
createdByUserId: user.id,
subscriptionEndsAt: addDays(new Date(), 30),
subscriptionStatus: 'trialing',
},
});
}
@@ -72,10 +75,8 @@ export const onboardingRouter = createTRPCRouter({
if (input.app) types.push('app');
if (input.backend) types.push('backend');
const [organization, user] = await Promise.all([
createOrGetOrganization(input, ctx.session.userId),
getUserById(ctx.session.userId),
]);
const user = await getUserById(ctx.session.userId);
const organization = await createOrGetOrganization(input, user);
if (!organization?.id) {
throw new Error('Organization slug is missing');

View File

@@ -11,6 +11,7 @@ import {
import { stripTrailingSlash } from '@openpanel/common';
import { zProject } from '@openpanel/validation';
import { addDays, addHours } from 'date-fns';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -91,27 +92,58 @@ export const projectRouter = createTRPCRouter({
},
});
}),
remove: protectedProcedure
delete: protectedProcedure
.input(
z.object({
id: z.string(),
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: input.id,
projectId: input.projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
await db.project.delete({
await db.project.update({
where: {
id: input.id,
id: input.projectId,
},
data: {
deleteAt: addHours(new Date(), 24),
},
});
return true;
}),
cancelDeletion: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: input.projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
await db.project.update({
where: {
id: input.projectId,
},
data: {
deleteAt: null,
},
});
return true;
}),
});

View File

@@ -0,0 +1,170 @@
import {
db,
getOrganizationBillingEventsCountSerieCached,
getOrganizationBySlug,
} from '@openpanel/db';
import {
cancelSubscription,
changeSubscription,
createCheckout,
createPortal,
getProduct,
getProducts,
reactivateSubscription,
} from '@openpanel/payments';
import { zCheckout } from '@openpanel/validation';
import { getCache } from '@openpanel/redis';
import { subDays } from 'date-fns';
import { z } from 'zod';
import { TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const subscriptionRouter = createTRPCRouter({
getCurrent: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionProductId) {
return null;
}
return getProduct(organization.subscriptionProductId);
}),
checkout: protectedProcedure
.input(zCheckout)
.mutation(async ({ input, ctx }) => {
const [user, organization] = await Promise.all([
db.user.findFirstOrThrow({
where: {
id: ctx.session.user.id,
},
}),
db.organization.findFirstOrThrow({
where: {
id: input.organizationId,
},
}),
]);
if (
organization.subscriptionId &&
organization.subscriptionStatus === 'active'
) {
if (organization.subscriptionCanceledAt) {
await reactivateSubscription(organization.subscriptionId);
} else {
await changeSubscription(
organization.subscriptionId,
input.productId,
);
}
return null;
}
const checkout = await createCheckout({
priceId: input.productPriceId,
organizationId: input.organizationId,
projectId: input.projectId ?? undefined,
user,
ipAddress: ctx.req.ip,
});
return {
url: checkout.url,
};
}),
products: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
subscriptionPeriodEventsCount: true,
},
});
return (
await getCache('polar:products', 60 * 60 * 24, () => getProducts())
).map((product) => {
const eventsLimit = product.metadata.eventsLimit;
return {
...product,
disabled:
typeof eventsLimit === 'number' &&
organization.subscriptionPeriodEventsCount >= eventsLimit
? 'This product is not applicable since you have exceeded the limits for this subscription.'
: null,
};
});
}),
usage: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ input }) => {
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
include: {
projects: { select: { id: true } },
},
});
if (
organization.hasSubscription &&
organization.subscriptionStartsAt &&
organization.subscriptionEndsAt
) {
return getOrganizationBillingEventsCountSerieCached(organization, {
startDate: organization.subscriptionStartsAt,
endDate: organization.subscriptionEndsAt,
});
}
return getOrganizationBillingEventsCountSerieCached(organization, {
startDate: subDays(new Date(), 30),
endDate: new Date(),
});
}),
cancelSubscription: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionId) {
throw TRPCBadRequestError('Organization has no subscription');
}
const res = await cancelSubscription(organization.subscriptionId);
return res;
}),
portal: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionCustomerId) {
throw TRPCBadRequestError('Organization has no subscription');
}
const portal = await createPortal({
customerId: organization.subscriptionCustomerId,
});
return {
url: portal.customerPortalUrl,
};
}),
});

View File

@@ -340,3 +340,11 @@ export const zSignInShare = z.object({
shareId: z.string().min(1),
});
export type ISignInShare = z.infer<typeof zSignInShare>;
export const zCheckout = z.object({
productPriceId: z.string(),
organizationId: z.string(),
projectId: z.string().nullish(),
productId: z.string(),
});
export type ICheckout = z.infer<typeof zCheckout>;