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

@@ -30,11 +30,13 @@ COPY apps/api/package.json ./apps/api/
COPY packages/db/package.json packages/db/
COPY packages/trpc/package.json packages/trpc/
COPY packages/auth/package.json packages/auth/
COPY packages/json/package.json packages/json/
COPY packages/email/package.json packages/email/
COPY packages/queue/package.json packages/queue/
COPY packages/redis/package.json packages/redis/
COPY packages/logger/package.json packages/logger/
COPY packages/common/package.json packages/common/
COPY packages/payments/package.json packages/payments/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
@@ -91,12 +93,13 @@ COPY --from=build /app/apps/api ./apps/api
COPY --from=build /app/packages/db ./packages/db
COPY --from=build /app/packages/auth ./packages/auth
COPY --from=build /app/packages/trpc ./packages/trpc
COPY --from=build /app/packages/auth ./packages/auth
COPY --from=build /app/packages/json ./packages/json
COPY --from=build /app/packages/email ./packages/email
COPY --from=build /app/packages/queue ./packages/queue
COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation

View File

@@ -21,7 +21,9 @@
"@openpanel/common": "workspace:*",
"@openpanel/db": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*",
@@ -30,6 +32,7 @@
"bcrypt": "^5.1.1",
"fastify": "^4.25.2",
"fastify-metrics": "^11.0.0",
"fastify-raw-body": "^4.2.1",
"ico-to-png": "^0.2.1",
"jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1",
@@ -54,7 +57,7 @@
"@types/source-map-support": "^0.5.10",
"@types/sqlstring": "^2.3.2",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"@types/ws": "^8.5.14",
"js-yaml": "^4.1.0",
"tsup": "^7.2.0",
"typescript": "^5.2.2"

84
apps/api/scripts/test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { type IClickhouseEvent, ch, createEvent } from '@openpanel/db';
import { formatClickhouseDate } from '@openpanel/db';
import { v4 as uuid } from 'uuid';
async function main() {
const startDate = new Date('2025-01-01T00:00:00Z');
const endDate = new Date();
const eventsPerDay = 25000;
const variance = 3000;
// Event names to randomly choose from
const eventNames = ['click', 'purchase', 'signup', 'login', 'screen_view'];
// Loop through each day
for (
let currentDate = startDate;
currentDate <= endDate;
currentDate.setDate(currentDate.getDate() + 1)
) {
const events: IClickhouseEvent[] = [];
// Calculate random number of events for this day
const dailyEvents =
eventsPerDay + Math.floor(Math.random() * variance * 2) - variance;
// Create events for the day
for (let i = 0; i < dailyEvents; i++) {
const eventTime = new Date(currentDate);
// Distribute events throughout the day
eventTime.setHours(Math.floor(Math.random() * 24));
eventTime.setMinutes(Math.floor(Math.random() * 60));
eventTime.setSeconds(Math.floor(Math.random() * 60));
events.push({
id: uuid(),
name: eventNames[Math.floor(Math.random() * eventNames.length)]!,
device_id: `device_${Math.floor(Math.random() * 1000)}`,
profile_id: `profile_${Math.floor(Math.random() * 1000)}`,
project_id: 'testing',
session_id: `session_${Math.floor(Math.random() * 10000)}`,
properties: {
hash: 'test-hash',
'query.utm_source': 'test',
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
__user_agent: 'Mozilla/5.0 (Test)',
},
created_at: formatClickhouseDate(eventTime),
country: 'US',
city: 'New York',
region: 'NY',
longitude: -74.006,
latitude: 40.7128,
os: 'macOS',
os_version: '13.0',
browser: 'Chrome',
browser_version: '120.0',
device: 'desktop',
brand: 'Apple',
model: 'MacBook Pro',
duration: Math.floor(Math.random() * 300),
path: `/page-${Math.floor(Math.random() * 20)}`,
origin: 'https://example.com',
referrer: 'https://google.com',
referrer_name: 'Google',
referrer_type: 'search',
imported_at: null,
sdk_name: 'test-script',
sdk_version: '1.0.0',
});
}
await ch.insert({
table: 'events',
values: events,
format: 'JSONEachRow',
});
// Log progress
console.log(
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`,
);
}
}
main().catch(console.error);

View File

@@ -1,72 +1,35 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { SocketStream } from '@fastify/websocket';
import type { FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type * as WebSocket from 'ws';
import type { WebSocket } from 'ws';
import { getSuperJson } from '@openpanel/common';
import type { IServiceEvent, Notification } from '@openpanel/db';
import {
TABLE_NAMES,
eventBuffer,
getEvents,
getProfileByIdCached,
transformMinimalEvent,
} from '@openpanel/db';
import { getRedisCache, getRedisPub, getRedisSub } from '@openpanel/redis';
import { setSuperJson } from '@openpanel/json';
import {
psubscribeToPublishedEvent,
subscribeToPublishedEvent,
} from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc';
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
type WebSocketConnection = SocketStream & {
socket: WebSocket & {
on(event: 'close', listener: () => void): void;
send(data: string): void;
close(): void;
};
};
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
}
export async function testVisitors(
req: FastifyRequest<{
Params: {
projectId: string;
};
}>,
reply: FastifyReply,
) {
const events = await getEvents(
`SELECT * FROM ${TABLE_NAMES.events} LIMIT 500`,
);
const event = events[Math.floor(Math.random() * events.length)];
if (!event) {
return reply.status(404).send('No event found');
}
event.projectId = req.params.projectId;
getRedisPub().publish('event:received', superjson.stringify(event));
getRedisCache().set(
`live:event:${event.projectId}:${Math.random() * 1000}`,
'',
'EX',
10,
);
reply.status(202).send(event);
}
export async function testEvents(
req: FastifyRequest<{
Params: {
projectId: string;
};
}>,
reply: FastifyReply,
) {
const events = await getEvents(
`SELECT * FROM ${TABLE_NAMES.events} LIMIT 500`,
);
const event = events[Math.floor(Math.random() * events.length)];
if (!event) {
return reply.status(404).send('No event found');
}
getRedisPub().publish('event:saved', superjson.stringify(event));
reply.status(202).send(event);
}
export function wsVisitors(
connection: {
socket: WebSocket;
},
connection: WebSocketConnection,
req: FastifyRequest<{
Params: {
projectId: string;
@@ -75,60 +38,46 @@ export function wsVisitors(
) {
const { params } = req;
getRedisSub().subscribe('event:received');
getRedisSub().psubscribe('__keyevent@0__:expired');
const message = (channel: string, message: string) => {
if (channel === 'event:received') {
const event = getSuperJson<IServiceEvent>(message);
if (event?.projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
connection.socket.send(String(count));
});
}
}
};
const pmessage = (pattern: string, channel: string, message: string) => {
if (!message.startsWith('live:visitor:')) {
return null;
}
const [projectId] = getLiveEventInfo(message);
if (projectId && projectId === params.projectId) {
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
if (event?.projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
connection.socket.send(String(count));
});
}
};
});
getRedisSub().on('message', message);
getRedisSub().on('pmessage', pmessage);
const punsubscribe = psubscribeToPublishedEvent(
'__keyevent@0__:expired',
(key) => {
const [projectId] = getLiveEventInfo(key);
if (projectId && projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
connection.socket.send(String(count));
});
}
},
);
connection.socket.on('close', () => {
getRedisSub().unsubscribe('event:saved');
getRedisSub().punsubscribe('__keyevent@0__:expired');
getRedisSub().off('message', message);
getRedisSub().off('pmessage', pmessage);
unsubscribe();
punsubscribe();
});
}
export async function wsProjectEvents(
connection: {
socket: WebSocket;
},
connection: WebSocketConnection,
req: FastifyRequest<{
Params: {
projectId: string;
};
Querystring: {
token?: string;
type?: string;
type?: 'saved' | 'received';
};
}>,
) {
const { params, query } = req;
const type = query.type || 'saved';
const subscribeToEvent = `event:${type}`;
if (!['saved', 'received'].includes(type)) {
connection.socket.send('Invalid type');
@@ -148,12 +97,11 @@ export async function wsProjectEvents(
projectId: params.projectId,
});
getRedisSub().subscribe(subscribeToEvent);
const message = async (channel: string, message: string) => {
if (channel === subscribeToEvent) {
const event = getSuperJson<IServiceEvent>(message);
if (event?.projectId === params.projectId) {
const unsubscribe = subscribeToPublishedEvent(
'events',
type,
async (event) => {
if (event.projectId === params.projectId) {
const profile = await getProfileByIdCached(
event.profileId,
event.projectId,
@@ -169,31 +117,21 @@ export async function wsProjectEvents(
),
);
}
}
};
},
);
getRedisSub().on('message', message as any);
connection.socket.on('close', () => {
getRedisSub().unsubscribe(subscribeToEvent);
getRedisSub().off('message', message as any);
});
connection.socket.on('close', () => unsubscribe());
}
export async function wsProjectNotifications(
connection: {
socket: WebSocket;
},
connection: WebSocketConnection,
req: FastifyRequest<{
Params: {
projectId: string;
};
Querystring: {
token?: string;
};
}>,
) {
const { params, query } = req;
const { params } = req;
const userId = req.session?.userId;
if (!userId) {
@@ -202,8 +140,6 @@ export async function wsProjectNotifications(
return;
}
const subscribeToEvent = 'notification';
const access = await getProjectAccess({
userId,
projectId: params.projectId,
@@ -215,21 +151,54 @@ export async function wsProjectNotifications(
return;
}
getRedisSub().subscribe(subscribeToEvent);
const message = async (channel: string, message: string) => {
if (channel === subscribeToEvent) {
const notification = getSuperJson<Notification>(message);
if (notification?.projectId === params.projectId) {
const unsubscribe = subscribeToPublishedEvent(
'notification',
'created',
(notification) => {
if (notification.projectId === params.projectId) {
connection.socket.send(superjson.stringify(notification));
}
}
};
},
);
getRedisSub().on('message', message as any);
connection.socket.on('close', () => {
getRedisSub().unsubscribe(subscribeToEvent);
getRedisSub().off('message', message as any);
});
connection.socket.on('close', () => unsubscribe());
}
export async function wsOrganizationEvents(
connection: WebSocketConnection,
req: FastifyRequest<{
Params: {
organizationId: string;
};
}>,
) {
const { params } = req;
const userId = req.session?.userId;
if (!userId) {
connection.socket.send('No active session');
connection.socket.close();
return;
}
const access = await getOrganizationAccess({
userId,
organizationId: params.organizationId,
});
if (!access) {
connection.socket.send('No access');
connection.socket.close();
return;
}
const unsubscribe = subscribeToPublishedEvent(
'organization',
'subscription_updated',
(message) => {
connection.socket.send(setSuperJson(message));
},
);
connection.socket.on('close', () => unsubscribe());
}

View File

@@ -5,6 +5,12 @@ import {
sendSlackNotification,
slackInstaller,
} from '@openpanel/integrations/src/slack';
import {
PolarWebhookVerificationError,
getProduct,
validatePolarEvent,
} from '@openpanel/payments';
import { publishEvent } from '@openpanel/redis';
import { zSlackAuthResponse } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
@@ -102,3 +108,123 @@ export async function slackWebhook(
return reply.status(500).header('Content-Type', 'text/html').send(html);
}
}
export async function polarWebhook(
request: FastifyRequest<{
Querystring: unknown;
}>,
reply: FastifyReply,
) {
try {
const event = validatePolarEvent(
request.rawBody!,
request.headers as Record<string, string>,
process.env.POLAR_WEBHOOK_SECRET ?? '',
);
switch (event.type) {
case 'order.created': {
const metadata = z
.object({
organizationId: z.string(),
})
.parse(event.data.metadata);
if (event.data.billingReason === 'subscription_cycle') {
await db.organization.update({
where: {
id: metadata.organizationId,
},
data: {
subscriptionPeriodEventsCount: 0,
},
});
}
break;
}
case 'subscription.updated': {
const metadata = z
.object({
organizationId: z.string(),
userId: z.string(),
})
.parse(event.data.metadata);
const product = await getProduct(event.data.productId);
const eventsLimit = product.metadata?.eventsLimit;
const subscriptionPeriodEventsLimit =
typeof eventsLimit === 'number' ? eventsLimit : undefined;
if (!subscriptionPeriodEventsLimit) {
request.log.warn('No events limit found for product', { product });
}
// If we get a cancel event and we cant find it we should ignore it
// Since we only have one subscription per organization but you can have several in polar
// we dont want to override the existing subscription with a canceled one
// TODO: might be other events that we should handle like this?!
if (event.data.status === 'canceled') {
const orgSubscription = await db.organization.findFirst({
where: {
subscriptionCustomerId: event.data.customer.id,
subscriptionId: event.data.id,
subscriptionStatus: 'active',
},
});
if (!orgSubscription) {
return reply.status(202).send('OK');
}
}
await db.organization.update({
where: {
id: metadata.organizationId,
},
data: {
subscriptionId: event.data.id,
subscriptionCustomerId: event.data.customer.id,
subscriptionPriceId: event.data.priceId,
subscriptionProductId: event.data.productId,
subscriptionStatus: event.data.status,
subscriptionStartsAt: event.data.currentPeriodStart,
subscriptionCanceledAt: event.data.canceledAt,
subscriptionEndsAt:
event.data.status === 'canceled'
? event.data.cancelAtPeriodEnd
? event.data.currentPeriodEnd
: event.data.canceledAt
: event.data.currentPeriodEnd,
subscriptionCreatedByUserId: metadata.userId,
subscriptionInterval: event.data.recurringInterval,
subscriptionPeriodEventsLimit,
},
});
await publishEvent('organization', 'subscription_updated', {
organizationId: metadata.organizationId,
});
break;
}
}
reply.status(202).send('OK');
} catch (error) {
if (error instanceof PolarWebhookVerificationError) {
request.log.error('Polar webhook error', { error });
reply.status(403).send('');
}
throw error;
}
}
function isToday(date: Date) {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
}

View File

@@ -92,6 +92,10 @@ const startServer = async () => {
};
});
await fastify.register(import('fastify-raw-body'), {
global: false,
});
fastify.addHook('preHandler', ipHook);
fastify.addHook('preHandler', timestampHook);
fastify.addHook('preHandler', fixHook);

View File

@@ -2,35 +2,31 @@ import * as controller from '@/controllers/live.controller';
import fastifyWS from '@fastify/websocket';
import type { FastifyPluginCallback } from 'fastify';
// TODO: `as any` is a workaround since it starts to break after changed module resolution to bundler
// which is needed for @polar/sdk (dont have time to resolve this now)
const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
fastify.route({
method: 'GET',
url: '/visitors/test/:projectId',
handler: controller.testVisitors,
});
fastify.route({
method: 'GET',
url: '/events/test/:projectId',
handler: controller.testEvents,
});
fastify.register(fastifyWS);
fastify.register((fastify, _, done) => {
fastify.get(
'/organization/:organizationId',
{ websocket: true },
controller.wsOrganizationEvents as any,
);
fastify.get(
'/visitors/:projectId',
{ websocket: true },
controller.wsVisitors,
controller.wsVisitors as any,
);
fastify.get(
'/events/:projectId',
{ websocket: true },
controller.wsProjectEvents,
controller.wsProjectEvents as any,
);
fastify.get(
'/notifications/:projectId',
{ websocket: true },
controller.wsProjectNotifications,
controller.wsProjectNotifications as any,
);
done();
});

View File

@@ -7,6 +7,14 @@ const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => {
url: '/slack',
handler: controller.slackWebhook,
});
fastify.route({
method: 'POST',
url: '/polar',
handler: controller.polarWebhook,
config: {
rawBody: true,
},
});
done();
};

View File

@@ -1,4 +1,4 @@
import { getSafeJson } from '@openpanel/common';
import { getSafeJson } from '@openpanel/json';
export const parseQueryString = (obj: Record<string, any>): any => {
return Object.fromEntries(

View File

@@ -34,6 +34,7 @@ COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/dashboard/package.json apps/dashboard/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/json/package.json packages/json/package.json
COPY packages/redis/package.json packages/redis/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/common/package.json packages/common/package.json

View File

@@ -1,12 +1,6 @@
// @ts-expect-error
import { PrismaPlugin } from '@prisma/nextjs-monorepo-workaround-plugin';
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
await import('./src/env.mjs');
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',

View File

@@ -19,6 +19,7 @@
"@openpanel/constants": "workspace:^",
"@openpanel/db": "workspace:^",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/nextjs": "1.0.3",
"@openpanel/queue": "workspace:^",
"@openpanel/sdk-info": "workspace:^",
@@ -114,6 +115,7 @@
"devDependencies": {
"@openpanel/trpc": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@openpanel/payments": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",

View File

@@ -4,13 +4,16 @@ import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import {
BanknoteIcon,
ChartLineIcon,
DollarSignIcon,
GanttChartIcon,
Globe2Icon,
LayersIcon,
LayoutPanelTopIcon,
PlusIcon,
ScanEyeIcon,
ServerIcon,
UsersIcon,
WallpaperIcon,
} from 'lucide-react';
@@ -18,7 +21,9 @@ import type { LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { ProjectLink } from '@/components/links';
import type { IServiceDashboards } from '@openpanel/db';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
import { differenceInDays, format } from 'date-fns';
function LinkWithIcon({
href,
@@ -52,25 +57,114 @@ function LinkWithIcon({
interface LayoutMenuProps {
dashboards: IServiceDashboards;
organization: IServiceOrganization;
}
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
export default function LayoutMenu({
dashboards,
organization,
}: LayoutMenuProps) {
const number = useNumber();
const {
isTrial,
isExpired,
isExceeded,
isCanceled,
subscriptionEndsAt,
subscriptionPeriodEventsCount,
subscriptionPeriodEventsLimit,
} = organization;
return (
<>
<ProjectLink
href={'/reports'}
className={cn(
'border rounded p-2 row items-center gap-2 hover:bg-def-200 mb-4',
<div className="col border rounded mb-2 divide-y">
{process.env.SELF_HOSTED && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row items-center gap-2 pointer-events-none',
)}
>
<ServerIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Self-hosted</div>
</div>
</ProjectLink>
)}
>
<ChartLineIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Create report</div>
<div className="text-sm text-muted-foreground">
Visualize your events
{isTrial && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row items-center gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">
Free trial ends in{' '}
{differenceInDays(subscriptionEndsAt, new Date())} days
</div>
</div>
</ProjectLink>
)}
{isExpired && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Subscription expired</div>
<div className="text-sm opacity-80">
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
</div>
</div>
</ProjectLink>
)}
{isCanceled && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-red-600',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Subscription canceled</div>
<div className="text-sm opacity-80">
{differenceInDays(new Date(), subscriptionEndsAt)} days ago
</div>
</div>
</ProjectLink>
)}
{isExceeded && subscriptionEndsAt && (
<ProjectLink
href={'/settings/organization?tab=billing'}
className={cn(
'rounded p-2 row gap-2 hover:bg-def-200 text-destructive',
)}
>
<BanknoteIcon size={20} />
<div className="flex-1 col gap-0.5">
<div className="font-medium">Events limit exceeded</div>
<div className="text-sm opacity-80">
{number.format(subscriptionPeriodEventsCount)} /{' '}
{number.format(subscriptionPeriodEventsLimit)}
</div>
</div>
</ProjectLink>
)}
<ProjectLink
href={'/reports'}
className={cn('rounded p-2 row gap-2 hover:bg-def-200')}
>
<ChartLineIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Create report</div>
</div>
</div>
<PlusIcon size={16} className="text-muted-foreground" />
</ProjectLink>
<PlusIcon size={16} className="text-muted-foreground" />
</ProjectLink>
</div>
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
<LinkWithIcon
icon={LayoutPanelTopIcon}

View File

@@ -14,6 +14,7 @@ import type {
getProjectsByOrganizationId,
} from '@openpanel/db';
import { useAppParams } from '@/hooks/useAppParams';
import Link from 'next/link';
import LayoutMenu from './layout-menu';
import LayoutProjectSelector from './layout-project-selector';
@@ -31,6 +32,8 @@ export function LayoutSidebar({
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
const { organizationId } = useAppParams();
const organization = organizations.find((o) => o.id === organizationId)!;
useEffect(() => {
setActive(false);
@@ -76,7 +79,7 @@ export function LayoutSidebar({
<SettingsToggle />
</div>
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
<LayoutMenu dashboards={dashboards} />
<LayoutMenu dashboards={dashboards} organization={organization} />
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="h-8 w-full bg-gradient-to-t from-card to-card/0" />

View File

@@ -42,9 +42,7 @@ function Tooltip(props: any) {
const Chart = ({ data }: Props) => {
const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps({
data: data.map((d) => d.users),
});
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>

View File

@@ -59,9 +59,7 @@ const Chart = ({ data }: Props) => {
mau: data.monthly.find((m) => m.date === d.date)?.users,
}));
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps({
data: data.monthly.map((d) => d.users),
});
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>

View File

@@ -57,9 +57,7 @@ function Tooltip({ payload }: any) {
const Chart = ({ data }: Props) => {
const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps({
data: data.map((d) => d.retention),
});
const yAxisProps = useYAxisProps();
return (
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>

View File

@@ -0,0 +1,68 @@
'use client';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
const questions = [
{
question: "What's the free tier?",
answer: [
'You get 5000 events per month for free. This is mostly for you to try out OpenPanel but also for solo developers or people who want to try out OpenPanel without committing to a paid plan.',
],
},
{
question: 'What happens if my site exceeds the limit?',
answer: [
"You will not see any new events in OpenPanel until your next billing period. If this happens 2 months in a row, we'll advice you to upgrade your plan.",
],
},
{
question: 'What happens if I cancel my subscription?',
answer: [
'If you cancel your subscription, you will still have access to OpenPanel until the end of your current billing period. You can reactivate your subscription at any time.',
'After your current billing period ends, you will not get access to new data.',
"NOTE: If your account has been inactive for 3 months, we'll delete your events.",
],
},
{
question: 'How do I change my billing information?',
answer: [
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
],
},
];
export function BillingFaq() {
return (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center"
>
{questions.map((q) => (
<AccordionItem value={q.question} key={q.question}>
<AccordionTrigger className="text-left px-4">
{q.question}
</AccordionTrigger>
<AccordionContent>
<div className="col gap-2 p-4 pt-2">
{q.answer.map((a) => (
<p key={a}>{a}</p>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Widget>
);
}

View File

@@ -0,0 +1,271 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import useWS from '@/hooks/useWS';
import { api } from '@/trpc/client';
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarPrice } from '@openpanel/payments';
import { Loader2Icon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useQueryState } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function Billing({ organization }: Props) {
const router = useRouter();
const { projectId } = useAppParams();
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
'customer_session_token',
);
const productsQuery = api.subscription.products.useQuery({
organizationId: organization.id,
});
useWS(`/live/organization/${organization.id}`, (event) => {
router.refresh();
});
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
);
const products = useMemo(() => {
return (productsQuery.data || []).filter(
(product) => product.recurringInterval === recurringInterval,
);
}, [productsQuery.data, recurringInterval]);
useEffect(() => {
if (organization.subscriptionInterval) {
setRecurringInterval(
organization.subscriptionInterval as 'year' | 'month',
);
}
}, [organization.subscriptionInterval]);
function renderBillingTable() {
if (productsQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productsQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
return (
<WidgetTable
className="w-full max-w-full [&_td]:text-left"
data={products}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Tier',
render(item) {
return <div className="font-medium">{item.name}</div>;
},
className: 'w-full',
},
{
name: 'Price',
render(item) {
const price = item.prices[0];
if (!price) {
return null;
}
if (price.amountType === 'free') {
return (
<div className="row gap-2 whitespace-nowrap">
<div className="items-center text-right justify-end gap-4 flex-1 row">
<span>Free</span>
<CheckoutButton
disabled={item.disabled}
key={price.id}
price={price}
organization={organization}
projectId={projectId}
/>
</div>
</div>
);
}
if (price.amountType !== 'fixed') {
return null;
}
return (
<div className="row gap-2 whitespace-nowrap">
<div className="items-center text-right justify-end gap-4 flex-1 row">
<span>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: price.priceCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price.priceAmount / 100)}
{' / '}
{recurringInterval === 'year' ? 'year' : 'month'}
</span>
<CheckoutButton
disabled={item.disabled}
key={price.id}
price={price}
organization={organization}
projectId={projectId}
/>
</div>
</div>
);
},
},
]}
/>
);
}
return (
<>
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Billing</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{recurringInterval === 'year'
? 'Yearly (2 months free)'
: 'Monthly'}
</span>
<Switch
checked={recurringInterval === 'year'}
onCheckedChange={(checked) =>
setRecurringInterval(checked ? 'year' : 'month')
}
/>
</div>
</WidgetHead>
<WidgetBody>
<div className="-m-4">
{renderBillingTable()}
<div className="text-center p-4 border-t">
<p>Do you need higher limits? </p>
<p>
Reach out to{' '}
<a
className="underline font-medium"
href="mailto:hello@openpanel.dev"
>
hello@openpanel.dev
</a>{' '}
and we'll help you out.
</p>
</div>
</div>
</WidgetBody>
</Widget>
<Dialog
open={!!customerSessionToken}
onOpenChange={(open) => {
setCustomerSessionToken(null);
if (!open) {
router.refresh();
}
}}
>
<DialogContent>
<DialogTitle>Subscription created</DialogTitle>
<DialogDescription>
We have registered your subscription. It'll be activated within a
couple of seconds.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>OK</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function CheckoutButton({
price,
organization,
projectId,
disabled,
}: {
price: IPolarPrice;
organization: IServiceOrganization;
projectId: string;
disabled?: string | null;
}) {
const isCurrentPrice = organization.subscriptionPriceId === price.id;
const checkout = api.subscription.checkout.useMutation({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
});
const isCanceled =
organization.subscriptionStatus === 'active' &&
isCurrentPrice &&
organization.subscriptionCanceledAt;
const isActive =
organization.subscriptionStatus === 'active' && isCurrentPrice;
return (
<Tooltiper
content={disabled}
tooltipClassName="max-w-xs"
side="left"
disabled={!disabled}
>
<Button
disabled={disabled !== null || (isActive && !isCanceled)}
key={price.id}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
loading={checkout.isLoading}
className="w-28"
variant={isActive ? 'outline' : 'default'}
>
{isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate'}
</Button>
</Tooltiper>
);
}

View File

@@ -0,0 +1,278 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import { useNumber } from '@/hooks/useNumerFormatter';
import useWS from '@/hooks/useWS';
import { showConfirm } from '@/modals';
import Confirm from '@/modals/Confirm';
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarPrice } from '@openpanel/payments';
import { format } from 'date-fns';
import { Loader2Icon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useQueryState } from 'nuqs';
import { product } from 'ramda';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function CurrentSubscription({ organization }: Props) {
const router = useRouter();
const { projectId } = useAppParams();
const number = useNumber();
const [customerSessionToken, setCustomerSessionToken] = useQueryState(
'customer_session_token',
);
const productQuery = api.subscription.getCurrent.useQuery({
organizationId: organization.id,
});
const cancelSubscription = api.subscription.cancelSubscription.useMutation({
onSuccess(res) {
toast.success('Subscription cancelled', {
description: 'It might take a few seconds to update',
});
},
onError(error) {
toast.error(error.message);
},
});
const portalMutation = api.subscription.portal.useMutation({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
}
},
});
const checkout = api.subscription.checkout.useMutation({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
});
useWS(`/live/organization/${organization.id}`, () => {
productQuery.refetch();
});
function render() {
if (productQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (!productQuery.data) {
return (
<div className="center-center p-8 font-medium">
No subscription found
</div>
);
}
const product = productQuery.data;
const price = product.prices[0]!;
return (
<>
<div className="gap-4 col">
<div className="row justify-between">
<div>Name</div>
<div className="text-right font-medium">{product.name}</div>
</div>
{price.amountType === 'fixed' ? (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">
{number.currency(price.priceAmount / 100)}
</div>
</div>
</>
) : (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">FREE</div>
</div>
</>
)}
<div className="row justify-between">
<div>Billing Cycle</div>
<div className="text-right font-medium">
{price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'}
</div>
</div>
{typeof product.metadata.eventsLimit === 'number' && (
<div className="row justify-between">
<div>Events per mount</div>
<div className="text-right font-medium font-mono">
{number.format(product.metadata.eventsLimit)}
</div>
</div>
)}
</div>
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isLoading}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isLoading}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
organizationId: organization.id,
});
},
});
}}
>
Cancel subscription
</Button>
)}
</div>
</>
);
}
return (
<div className="col gap-2 md:w-72 shrink-0">
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Current Subscription</span>
<div className="flex items-center gap-2">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
</div>
</div>
</WidgetHead>
<WidgetBody className="col gap-8">
{organization.isTrial && organization.subscriptionEndsAt && (
<Alert variant="warning">
<AlertTitle>Free trial</AlertTitle>
<AlertDescription>
Your organization is on a free trial. It ends on{' '}
{format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isExpired && organization.subscriptionEndsAt && (
<Alert variant="destructive">
<AlertTitle>Subscription expired</AlertTitle>
<AlertDescription>
Your subscription has expired. You can reactivate it by choosing
a new plan below.
</AlertDescription>
<AlertDescription>
It expired on {format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isWillBeCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
You have canceled your subscription. You can reactivate it by
choosing a new plan below.
</AlertDescription>
<AlertDescription className="font-medium">
It'll expire on{' '}
{format(organization.subscriptionEndsAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
Your subscription was canceled on{' '}
{format(organization.subscriptionCanceledAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{render()}
</WidgetBody>
</Widget>
{organization.hasSubscription && (
<button
className="text-center mt-2 w-2/3 hover:underline self-center"
type="button"
onClick={() =>
portalMutation.mutate({
organizationId: organization.id,
})
}
>
Manage your subscription with
<span className="font-medium ml-1">Polar Customer Portal</span>
</button>
)}
</div>
);
}

View File

@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getOrganizationBySlug } from '@openpanel/db';
import type { IServiceOrganization } from '@openpanel/db';
const validator = z.object({
id: z.string().min(2),
@@ -18,7 +18,7 @@ const validator = z.object({
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: Awaited<ReturnType<typeof getOrganizationBySlug>>;
organization: IServiceOrganization;
}
export default function EditOrganization({
organization,
@@ -41,29 +41,27 @@ export default function EditOrganization({
});
return (
<section className="max-w-screen-sm">
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="flex items-end gap-2">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetBody>
</Widget>
</form>
</section>
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="flex items-end gap-2">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import type { IServiceOrganization } from '@openpanel/db';
import EditOrganization from './edit-organization';
interface OrganizationProps {
organization: IServiceOrganization;
}
export default function Organization({ organization }: OrganizationProps) {
return (
<section className="max-w-screen-sm col gap-8">
<EditOrganization organization={organization} />
</section>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import {
useXAxisProps,
useYAxisProps,
} from '@/components/report-chart/common/axis';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useNumber } from '@/hooks/useNumerFormatter';
import { api } from '@/trpc/client';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { sum } from '@openpanel/common';
import type { IServiceOrganization } from '@openpanel/db';
import { Loader2Icon } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Tooltip as RechartTooltip,
ReferenceLine,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type Props = {
organization: IServiceOrganization;
};
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
<div className="text-muted-foreground truncate">{title}</div>
<div className="font-mono text-xl font-bold truncate">{value}</div>
</div>
);
}
export default function Usage({ organization }: Props) {
const number = useNumber();
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps({});
const usageQuery = api.subscription.usage.useQuery({
organizationId: organization.id,
});
const wrapper = (node: React.ReactNode) => (
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span>
</WidgetHead>
<WidgetBody>{node}</WidgetBody>
</Widget>
);
if (usageQuery.isLoading) {
return wrapper(
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>,
);
}
if (usageQuery.isError) {
return wrapper(
<div className="center-center p-8 font-medium">
Issues loading usage data
</div>,
);
}
const subscriptionPeriodEventsLimit = organization.hasSubscription
? organization.subscriptionPeriodEventsLimit
: 0;
const subscriptionPeriodEventsCount = organization.hasSubscription
? organization.subscriptionPeriodEventsCount
: 0;
const domain = [
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
...usageQuery.data.map((item) => item.count),
),
] as [number, number];
domain[1] += domain[1] * 0.05;
return wrapper(
<>
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4">
{organization.hasSubscription ? (
<>
<Card
title="Period"
value={
organization.subscriptionCurrentPeriodStart &&
organization.subscriptionCurrentPeriodEnd
? `${formatDate(organization.subscriptionCurrentPeriodStart)}-${formatDate(organization.subscriptionCurrentPeriodEnd)}`
: '🤷‍♂️'
}
/>
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card
title="Left to use"
value={
subscriptionPeriodEventsLimit === 0
? '👀'
: number.formatWithUnit(
(1 -
subscriptionPeriodEventsCount /
subscriptionPeriodEventsLimit) *
100,
'%',
)
}
/>
</>
) : (
<>
<div className="col-span-2">
<Card title="Subscription" value={'No active subscription'} />
</div>
<div className="col-span-2">
<Card
title="Events from last 30 days"
value={number.format(
sum(usageQuery.data.map((item) => item.count)),
)}
/>
</div>
</>
)}
</div>
<div className="aspect-video max-h-[300px] w-full p-4">
<ResponsiveContainer>
<BarChart
data={usageQuery.data.map((item) => ({
date: new Date(item.day).getTime(),
count: item.count,
limit: subscriptionPeriodEventsLimit,
total: subscriptionPeriodEventsCount,
}))}
barSize={8}
>
<defs>
<linearGradient id="usage" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<RechartTooltip
content={<Tooltip />}
cursor={{
stroke: 'hsl(var(--def-400))',
fill: 'hsl(var(--def-200))',
}}
/>
{organization.hasSubscription && (
<>
<ReferenceLine
y={subscriptionPeriodEventsLimit}
stroke={getChartColor(1)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.5}
strokeLinecap="round"
label={{
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
fill: getChartColor(1),
position: 'insideTopRight',
fontSize: 12,
}}
/>
<ReferenceLine
y={subscriptionPeriodEventsCount}
stroke={getChartColor(2)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.5}
strokeLinecap="round"
label={{
value: `Your events count (${number.format(subscriptionPeriodEventsCount)})`,
fill: getChartColor(2),
position:
subscriptionPeriodEventsCount > 1000
? 'insideTop'
: 'insideBottom',
fontSize: 12,
}}
/>
</>
)}
<Bar
dataKey="count"
stroke={getChartColor(0)}
strokeWidth={0.5}
fill={'url(#usage)'}
isAnimationActive={false}
/>
<XAxis {...xAxisProps} dataKey="date" />
<YAxis
{...yAxisProps}
domain={domain}
interval={0}
ticks={[
0,
subscriptionPeriodEventsLimit * 0.25,
subscriptionPeriodEventsLimit * 0.5,
subscriptionPeriodEventsLimit * 0.75,
subscriptionPeriodEventsLimit,
]}
/>
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</>,
);
}
function Tooltip(props: any) {
const number = useNumber();
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">
{formatDate(payload.date)}
</div>
{payload.limit !== 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Your tier limit</div>
<div className="text-lg font-semibold text-chart-1">
{number.format(payload.limit)}
</div>
</div>
</div>
)}
{payload.total !== 0 && (
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-2" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">
Total events count
</div>
<div className="text-lg font-semibold text-chart-2">
{number.format(payload.total)}
</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full bg-chart-0" />
<div className="col gap-1">
<div className="text-sm text-muted-foreground">Events this day</div>
<div className="text-lg font-semibold text-chart-0">
{number.format(payload.count)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -6,11 +6,15 @@ import { notFound } from 'next/navigation';
import { parseAsStringEnum } from 'nuqs/server';
import { auth } from '@openpanel/auth/nextjs';
import { db } from '@openpanel/db';
import { db, transformOrganization } from '@openpanel/db';
import EditOrganization from './edit-organization';
import InvitesServer from './invites';
import MembersServer from './members';
import Billing from './organization/billing';
import { BillingFaq } from './organization/billing-faq';
import CurrentSubscription from './organization/current-subscription';
import Organization from './organization/organization';
import Usage from './organization/usage';
interface PageProps {
params: {
@@ -23,7 +27,8 @@ export default async function Page({
params: { organizationSlug: organizationId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['org', 'members', 'invites'])
const isBillingEnabled = !process.env.SELF_HOSTED;
const tab = parseAsStringEnum(['org', 'billing', 'members', 'invites'])
.withDefault('org')
.parseServerSide(searchParams.tab);
const session = await auth();
@@ -71,6 +76,11 @@ export default async function Page({
<PageTabsLink href={'?tab=org'} isActive={tab === 'org'}>
Organization
</PageTabsLink>
{isBillingEnabled && (
<PageTabsLink href={'?tab=billing'} isActive={tab === 'billing'}>
Billing
</PageTabsLink>
)}
<PageTabsLink href={'?tab=members'} isActive={tab === 'members'}>
Members
</PageTabsLink>
@@ -79,7 +89,17 @@ export default async function Page({
</PageTabsLink>
</PageTabs>
{tab === 'org' && <EditOrganization organization={organization} />}
{tab === 'org' && <Organization organization={organization} />}
{tab === 'billing' && isBillingEnabled && (
<div className="flex flex-col-reverse md:flex-row gap-8 max-w-screen-lg">
<div className="col gap-8 w-full">
<Billing organization={organization} />
<Usage organization={organization} />
<BillingFaq />
</div>
<CurrentSubscription organization={organization} />
</div>
)}
{tab === 'members' && <MembersServer organizationId={organizationId} />}
{tab === 'invites' && <InvitesServer organizationId={organizationId} />}
</Padding>

View File

@@ -0,0 +1,93 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { showConfirm } from '@/modals';
import { api, handleError } from '@/trpc/client';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { useQueryClient } from '@tanstack/react-query';
import { router } from '@trpc/server';
import { addHours, format, startOfHour } from 'date-fns';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
type Props = { project: IServiceProjectWithClients };
export default function DeleteProject({ project }: Props) {
const router = useRouter();
const mutation = api.project.delete.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
router.refresh();
},
});
const cancelDeletionMutation = api.project.cancelDeletion.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
router.refresh();
},
});
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Delete Project</span>
</WidgetHead>
<WidgetBody className="col gap-4">
<p>
Deleting your project will remove it from your organization and all of
its data. It'll be permanently deleted after 24 hours.
</p>
{project?.deleteAt && (
<Alert variant="destructive">
<AlertTitle>Project scheduled for deletion</AlertTitle>
<AlertDescription>
This project will be deleted on{' '}
<span className="font-medium">
{
// add 1 hour and round to the nearest hour
// Since we run cron once an hour
format(
startOfHour(addHours(project.deleteAt, 1)),
'yyyy-MM-dd HH:mm:ss',
)
}
</span>
. Any event associated with this project will be deleted.
</AlertDescription>
</Alert>
)}
<div className="row gap-4 justify-end">
{project?.deleteAt && (
<Button
variant="outline"
onClick={() => {
cancelDeletionMutation.mutate({ projectId: project.id });
}}
>
Cancel deletion
</Button>
)}
<Button
disabled={!!project?.deleteAt}
variant="destructive"
onClick={() => {
showConfirm({
title: 'Delete Project',
text: 'Are you sure you want to delete this project?',
onConfirm: () => {
mutation.mutate({ projectId: project.id });
},
});
}}
>
Delete Project
</Button>
</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -8,6 +8,7 @@ import {
} from '@openpanel/db';
import { notFound } from 'next/navigation';
import DeleteProject from './delete-project';
import EditProjectDetails from './edit-project-details';
import EditProjectFilters from './edit-project-filters';
import ProjectClients from './project-clients';
@@ -34,6 +35,7 @@ export default async function Page({ params: { projectId } }: PageProps) {
<EditProjectDetails project={project} />
<EditProjectFilters project={project} />
<ProjectClients project={project} />
<DeleteProject project={project} />
</div>
</Padding>
);

View File

@@ -1,5 +1,9 @@
'use client';
import { cn } from '@/utils/cn';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { useState } from 'react';
export function PageTabs({
children,
@@ -27,15 +31,23 @@ export function PageTabsLink({
isActive?: boolean;
}) {
return (
<Link
className={cn(
'inline-block opacity-100 transition-transform hover:translate-y-[-1px]',
isActive ? 'opacity-100' : 'opacity-50',
<div className="relative">
<Link
className={cn(
'inline-block opacity-100 transition-transform hover:translate-y-[-1px]',
isActive ? 'opacity-100' : 'opacity-50',
)}
href={href}
>
{children}
</Link>
{isActive && (
<motion.div
className="rounded-full absolute -bottom-1 left-0 right-0 h-0.5 bg-primary"
layoutId={'page-tabs-link'}
/>
)}
href={href}
>
{children}
</Link>
</div>
);
}

View File

@@ -105,7 +105,6 @@ export function Chart({ data }: Props) {
}, [series]);
const yAxisProps = useYAxisProps({
data: [data.metrics.max],
hide: hideYAxis,
});
const xAxisProps = useXAxisProps({

View File

@@ -22,12 +22,7 @@ export function getYAxisWidth(value: string | undefined | null) {
return charLength * value.length + charLength;
}
export const useYAxisProps = ({
data,
hide,
tickFormatter,
}: {
data: number[];
export const useYAxisProps = (options?: {
hide?: boolean;
tickFormatter?: (value: number) => string;
}) => {
@@ -38,12 +33,14 @@ export const useYAxisProps = ({
return {
...AXIS_FONT_PROPS,
width: hide ? 0 : width,
width: options?.hide ? 0 : width,
axisLine: false,
tickLine: false,
allowDecimals: false,
tickFormatter: (value: number) => {
const tick = tickFormatter ? tickFormatter(value) : number.short(value);
const tick = options?.tickFormatter
? options.tickFormatter(value)
: number.short(value);
const newWidth = getYAxisWidth(tick);
ref.current.push(newWidth);
setWidthDebounced(Math.max(...ref.current));

View File

@@ -49,7 +49,6 @@ export function Chart({ data }: Props) {
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const yAxisProps = useYAxisProps({
data: [data.metrics.max],
hide: hideYAxis,
});
const xAxisProps = useXAxisProps({

View File

@@ -110,7 +110,6 @@ export function Chart({ data }: Props) {
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
const yAxisProps = useYAxisProps({
data: [data.metrics.max],
hide: hideYAxis,
});
return (

View File

@@ -15,7 +15,6 @@ import {
} from 'recharts';
import { average, round } from '@openpanel/common';
import { fix } from 'mathjs';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { useReportChartContext } from '../context';
import { RetentionTooltip } from './tooltip';
@@ -33,7 +32,6 @@ export function Chart({ data }: Props) {
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
const yAxisProps = useYAxisProps({
data: [100],
hide: hideYAxis,
tickFormatter: (value) => `${value}%`,
});

View File

@@ -11,7 +11,7 @@ const AccordionItem = React.forwardRef<
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
className={cn('border-b [&:last-child]:border-b-0', className)}
{...props}
/>
));

View File

@@ -11,6 +11,8 @@ const alertVariants = cva(
default: 'bg-card text-foreground',
destructive:
'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'bg-orange-400/10 border-orange-400 text-orange-600 dark:border-orange-400 [&>svg]:text-orange-400',
},
},
defaultVariants: {

View File

@@ -10,12 +10,12 @@ import Link from 'next/link';
import * as React from 'react';
const buttonVariants = cva(
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:translate-y-[-1px]',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
cta: 'bg-highlight text-white hover:bg-highlight',
cta: 'bg-highlight text-white hover:bg-highlight/80',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:

View File

@@ -86,10 +86,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
className={cn('text-lg font-semibold tracking-tight', className)}
{...props}
/>
));
@@ -101,7 +98,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn(' text-muted-foreground', className)}
className={cn(' text-muted-foreground mt-2', className)}
{...props}
/>
));

View File

@@ -4,6 +4,7 @@ interface Props<T> {
columns: {
name: string;
render: (item: T) => React.ReactNode;
className?: string;
}[];
keyExtractor: (item: T) => string;
data: T[];
@@ -41,7 +42,9 @@ export function WidgetTable<T>({
<WidgetTableHead>
<tr>
{columns.map((column) => (
<th key={column.name}>{column.name}</th>
<th key={column.name} className={cn(column.className)}>
{column.name}
</th>
))}
</tr>
</WidgetTableHead>
@@ -49,10 +52,14 @@ export function WidgetTable<T>({
{data.map((item) => (
<tr
key={keyExtractor(item)}
className="border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4"
className={
'border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4'
}
>
{columns.map((column) => (
<td key={column.name}>{column.render(item)}</td>
<td key={column.name} className={cn(column.className)}>
{column.render(item)}
</td>
))}
</tr>
))}

View File

@@ -26,11 +26,25 @@ export const shortNumber =
}).format(value);
};
export const formatCurrency =
(locale: string) =>
(amount: number, currency = 'USD') => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
export function useNumber() {
const locale = 'en-gb';
const locale = 'en-US';
const format = formatNumber(locale);
const short = shortNumber(locale);
const currency = formatCurrency(locale);
return {
currency,
format,
short,
shortWithUnit: (value: number | null | undefined, unit?: string | null) => {

View File

@@ -4,7 +4,7 @@ import debounce from 'lodash.debounce';
import { use, useEffect, useMemo, useState } from 'react';
import useWebSocket from 'react-use-websocket';
import { getSuperJson } from '@openpanel/common';
import { getSuperJson } from '@openpanel/json';
type UseWSOptions = {
debounce?: {

View File

@@ -20,7 +20,7 @@ export default function Confirm({
return (
<ModalContent>
<ModalHeader title={title} />
<p>{text}</p>
<p className="text-lg -mt-2">{text}</p>
<ButtonContainer>
<Button
variant="outline"

View File

@@ -1,6 +1,7 @@
'use client';
import NumberFlow from '@number-flow/react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
import { Slider } from './ui/slider';
@@ -14,8 +15,6 @@ const PRICING = [
{ 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 },
];
export function PricingSlider() {
@@ -31,7 +30,7 @@ export function PricingSlider() {
step={1}
tooltip={
match
? `${formatNumber(match.events)} events`
? `${formatNumber(match.events)} events per month`
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
}
onValueChange={(value) => setIndex(value[0])}
@@ -39,20 +38,36 @@ export function PricingSlider() {
{match ? (
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}}
locales={'en-US'}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}}
locales={'en-US'}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
</div>
<span
className={cn(
'text-sm text-muted-foreground italic opacity-100',
match.price === 0 && 'opacity-0',
)}
>
+ VAT if applicable
</span>
</div>
) : (
<div>Contact us hello@openpanel.dev</div>
<div className="text-lg">
Contact us at{' '}
<a className="underline" href="mailto:hello@openpanel.dev">
hello@openpanel.dev
</a>{' '}
to get a custom quote.
</div>
)}
</>
);

View File

@@ -59,7 +59,7 @@ export function Pricing() {
</Button>
</div>
<div className="col justify-between pt-14">
<div className="col justify-between pt-14 gap-4">
<PricingSlider />
<div className="text-sm text-muted-foreground">

View File

@@ -27,6 +27,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/worker/package.json ./apps/worker/
# Packages
COPY packages/db/package.json ./packages/db/
COPY packages/json/package.json ./packages/json/
COPY packages/redis/package.json ./packages/redis/
COPY packages/queue/package.json ./packages/queue/
COPY packages/logger/package.json ./packages/logger/
@@ -70,6 +71,7 @@ COPY --from=build /app/apps/worker ./apps/worker
# Packages
COPY --from=build /app/packages/db ./packages/db
COPY --from=build /app/packages/json ./packages/json
COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/queue ./packages/queue

View File

@@ -14,6 +14,7 @@
"@openpanel/common": "workspace:*",
"@openpanel/db": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@
"migrate": "pnpm -r --filter db run migrate",
"migrate:deploy": "pnpm -r --filter db run migrate:deploy",
"dev": "pnpm -r --parallel testing",
"dev:public": "pnpm -r --filter public dev",
"format": "biome format .",
"format:fix": "biome format --write .",
"lint": "biome check .",

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,
};
}),
});

Some files were not shown because too many files have changed in this diff Show More