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,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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "eventsCount" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "subscriptionPeriodLimit" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "subscriptionInterval" TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "subscriptionPeriodEventsCountExceededAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "subscriptionCanceledAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "deleteAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects" ADD COLUMN "deleteAt" TIMESTAMP(3);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
21
packages/json/index.ts
Normal file
21
packages/json/index.ts
Normal 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);
|
||||
}
|
||||
17
packages/json/package.json
Normal file
17
packages/json/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
packages/json/tsconfig.json
Normal file
13
packages/json/tsconfig.json
Normal 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"]
|
||||
}
|
||||
2
packages/payments/index.ts
Normal file
2
packages/payments/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src/polar';
|
||||
export * from './src/prices';
|
||||
22
packages/payments/package.json
Normal file
22
packages/payments/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
246
packages/payments/scripts/create-custom-pricing.ts
Normal file
246
packages/payments/scripts/create-custom-pricing.ts
Normal 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());
|
||||
179
packages/payments/scripts/create-products.ts
Normal file
179
packages/payments/scripts/create-products.ts
Normal 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();
|
||||
112
packages/payments/src/polar.ts
Normal file
112
packages/payments/src/polar.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
18
packages/payments/src/prices.ts
Normal file
18
packages/payments/src/prices.ts
Normal 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 },
|
||||
];
|
||||
12
packages/payments/tsconfig.json
Normal file
12
packages/payments/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@openpanel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './redis';
|
||||
export * from './cachable';
|
||||
export * from './run-every';
|
||||
export * from './publisher';
|
||||
|
||||
@@ -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",
|
||||
|
||||
86
packages/redis/publisher.ts
Normal file
86
packages/redis/publisher.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
170
packages/trpc/src/routers/subscription.ts
Normal file
170
packages/trpc/src/routers/subscription.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user