From 168ebc34308bf169e975b4a52d75d8db7f9a2911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesv=C3=A4rd?= <1987198+lindesvard@users.noreply.github.com> Date: Wed, 26 Feb 2025 11:24:00 +0100 Subject: [PATCH] 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 --- apps/api/Dockerfile | 5 +- apps/api/package.json | 5 +- apps/api/scripts/test.ts | 84 +++++ apps/api/src/controllers/live.controller.ts | 217 ++++++------- .../api/src/controllers/webhook.controller.ts | 126 ++++++++ apps/api/src/index.ts | 4 + apps/api/src/routes/live.router.ts | 24 +- apps/api/src/routes/webhook.router.ts | 8 + apps/api/src/utils/parse-zod-query-string.ts | 2 +- apps/dashboard/Dockerfile | 1 + apps/dashboard/next.config.mjs | 6 - apps/dashboard/package.json | 2 + .../[projectId]/layout-menu.tsx | 124 +++++++- .../[projectId]/layout-sidebar.tsx | 5 +- .../retention/last-active-users/chart.tsx | 4 +- .../retention/rolling-active-users/chart.tsx | 4 +- .../users-retention-series/chart.tsx | 4 +- .../organization/organization/billing-faq.tsx | 68 +++++ .../organization/organization/billing.tsx | 271 ++++++++++++++++ .../organization/current-subscription.tsx | 278 +++++++++++++++++ .../{ => organization}/edit-organization.tsx | 50 ++- .../organization/organization.tsx | 15 + .../organization/organization/usage.tsx | 289 ++++++++++++++++++ .../settings/organization/page.tsx | 28 +- .../settings/projects/delete-project.tsx | 93 ++++++ .../[projectId]/settings/projects/page.tsx | 2 + apps/dashboard/src/components/page-tabs.tsx | 28 +- .../components/report-chart/area/chart.tsx | 1 - .../components/report-chart/common/axis.tsx | 13 +- .../report-chart/histogram/chart.tsx | 1 - .../components/report-chart/line/chart.tsx | 1 - .../report-chart/retention/chart.tsx | 2 - .../dashboard/src/components/ui/accordion.tsx | 2 +- apps/dashboard/src/components/ui/alert.tsx | 2 + apps/dashboard/src/components/ui/button.tsx | 4 +- apps/dashboard/src/components/ui/dialog.tsx | 7 +- .../dashboard/src/components/widget-table.tsx | 13 +- apps/dashboard/src/hooks/useNumerFormatter.ts | 16 +- apps/dashboard/src/hooks/useWS.ts | 2 +- apps/dashboard/src/modals/Confirm.tsx | 2 +- apps/public/components/pricing-slider.tsx | 45 ++- apps/public/components/sections/pricing.tsx | 2 +- apps/worker/Dockerfile | 2 + apps/worker/package.json | 1 + apps/worker/src/boot-cron.ts | 5 + apps/worker/src/jobs/cron.delete-projects.ts | 34 +++ apps/worker/src/jobs/cron.ts | 4 + apps/worker/src/jobs/events.ts | 45 +-- apps/worker/src/jobs/notification.ts | 6 +- apps/worker/src/jobs/sessions.ts | 73 ++++- package.json | 1 + packages/cli/tsup.config.ts | 10 +- packages/common/src/object.ts | 21 -- packages/db/package.json | 1 + .../20250101222359_payments/migration.sql | 11 + .../migration.sql | 2 + .../migration.sql | 10 + .../migration.sql | 2 + .../20250104194646_subs_v3/migration.sql | 2 + .../migration.sql | 9 + .../migration.sql | 2 + .../20250221204153_sub_wip/migration.sql | 2 + .../migration.sql | 5 + .../migration.sql | 41 +++ .../migration.sql | 5 + packages/db/prisma/schema.prisma | 49 ++- packages/db/src/buffers/bot-buffer-redis.ts | 4 +- packages/db/src/buffers/event-buffer-redis.ts | 24 +- .../db/src/buffers/profile-buffer-redis.ts | 11 +- packages/db/src/prisma-client.ts | 193 +++++++++++- .../db/src/services/organization.service.ts | 89 ++++-- packages/db/src/services/project.service.ts | 8 + packages/db/src/services/user.service.ts | 2 + packages/db/src/types.ts | 8 + packages/json/index.ts | 21 ++ packages/json/package.json | 17 ++ packages/json/tsconfig.json | 13 + packages/payments/index.ts | 2 + packages/payments/package.json | 22 ++ .../payments/scripts/create-custom-pricing.ts | 246 +++++++++++++++ packages/payments/scripts/create-products.ts | 179 +++++++++++ packages/payments/src/polar.ts | 112 +++++++ packages/payments/src/prices.ts | 18 ++ packages/payments/tsconfig.json | 12 + packages/queue/src/queues.ts | 7 +- packages/redis/cachable.ts | 33 +- packages/redis/index.ts | 1 + packages/redis/package.json | 2 + packages/redis/publisher.ts | 86 ++++++ packages/sdks/express/tsup.config.ts | 10 +- packages/sdks/nextjs/tsup.config.ts | 10 +- packages/sdks/react-native/tsup.config.ts | 12 +- packages/sdks/sdk/tsup.config.ts | 14 +- packages/sdks/web/tsup.config.ts | 10 +- packages/trpc/package.json | 1 + packages/trpc/src/root.ts | 3 +- packages/trpc/src/routers/chart.helpers.ts | 34 ++- packages/trpc/src/routers/onboarding.ts | 15 +- packages/trpc/src/routers/project.ts | 42 ++- packages/trpc/src/routers/subscription.ts | 170 +++++++++++ packages/validation/src/index.ts | 8 + pnpm-lock.yaml | 204 +++++++++++-- tooling/publish/tsconfig.json | 1 - tooling/typescript/base.json | 2 +- tooling/typescript/tsup.config.json | 9 - 105 files changed, 3395 insertions(+), 463 deletions(-) create mode 100644 apps/api/scripts/test.ts create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing-faq.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx rename apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/{ => organization}/edit-organization.tsx (55%) create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/organization.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/usage.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/delete-project.tsx create mode 100644 apps/worker/src/jobs/cron.delete-projects.ts create mode 100644 packages/db/prisma/migrations/20250101222359_payments/migration.sql create mode 100644 packages/db/prisma/migrations/20250104140340_add_events_count_on_org_level/migration.sql create mode 100644 packages/db/prisma/migrations/20250104192616_subscription_v2/migration.sql create mode 100644 packages/db/prisma/migrations/20250104194353_subscription_v2/migration.sql create mode 100644 packages/db/prisma/migrations/20250104194646_subs_v3/migration.sql create mode 100644 packages/db/prisma/migrations/20250219225132_rename_subscription_period_limit/migration.sql create mode 100644 packages/db/prisma/migrations/20250220215129_subscription_wip/migration.sql create mode 100644 packages/db/prisma/migrations/20250221204153_sub_wip/migration.sql create mode 100644 packages/db/prisma/migrations/20250225220926_delete_project/migration.sql create mode 100644 packages/db/prisma/migrations/20250225230336_cascade_delete_on_projet/migration.sql create mode 100644 packages/db/prisma/migrations/20250225230540_cascade_delete_2/migration.sql create mode 100644 packages/json/index.ts create mode 100644 packages/json/package.json create mode 100644 packages/json/tsconfig.json create mode 100644 packages/payments/index.ts create mode 100644 packages/payments/package.json create mode 100644 packages/payments/scripts/create-custom-pricing.ts create mode 100644 packages/payments/scripts/create-products.ts create mode 100644 packages/payments/src/polar.ts create mode 100644 packages/payments/src/prices.ts create mode 100644 packages/payments/tsconfig.json create mode 100644 packages/redis/publisher.ts create mode 100644 packages/trpc/src/routers/subscription.ts delete mode 100644 tooling/typescript/tsup.config.json diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 7977135e..e0f41d07 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index b6e4f713..832b50a1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/scripts/test.ts b/apps/api/scripts/test.ts new file mode 100644 index 00000000..b4929f60 --- /dev/null +++ b/apps/api/scripts/test.ts @@ -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); diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index eaf729e9..1a3da5e8 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -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(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(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(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()); } diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 7dbe6f81..abff1df2 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -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, + 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() + ); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5de46c23..3dc82550 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/routes/live.router.ts b/apps/api/src/routes/live.router.ts index 9bca7a2d..8c32f235 100644 --- a/apps/api/src/routes/live.router.ts +++ b/apps/api/src/routes/live.router.ts @@ -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(); }); diff --git a/apps/api/src/routes/webhook.router.ts b/apps/api/src/routes/webhook.router.ts index 3994b000..17d38318 100644 --- a/apps/api/src/routes/webhook.router.ts +++ b/apps/api/src/routes/webhook.router.ts @@ -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(); }; diff --git a/apps/api/src/utils/parse-zod-query-string.ts b/apps/api/src/utils/parse-zod-query-string.ts index c72683d3..b7e97685 100644 --- a/apps/api/src/utils/parse-zod-query-string.ts +++ b/apps/api/src/utils/parse-zod-query-string.ts @@ -1,4 +1,4 @@ -import { getSafeJson } from '@openpanel/common'; +import { getSafeJson } from '@openpanel/json'; export const parseQueryString = (obj: Record): any => { return Object.fromEntries( diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index c4f6053b..8fc570d7 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -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 diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 59bf5d9d..6553dbba 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -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', diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index e364f25a..88882aba 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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", diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx index 06902e9d..aee44a63 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx @@ -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 ( <> - + {process.env.SELF_HOSTED && ( + + +
+
Self-hosted
+
+
)} - > - -
-
Create report
-
- Visualize your events + {isTrial && subscriptionEndsAt && ( + + +
+
+ Free trial ends in{' '} + {differenceInDays(subscriptionEndsAt, new Date())} days +
+
+
+ )} + {isExpired && subscriptionEndsAt && ( + + +
+
Subscription expired
+
+ {differenceInDays(new Date(), subscriptionEndsAt)} days ago +
+
+
+ )} + {isCanceled && subscriptionEndsAt && ( + + +
+
Subscription canceled
+
+ {differenceInDays(new Date(), subscriptionEndsAt)} days ago +
+
+
+ )} + {isExceeded && subscriptionEndsAt && ( + + +
+
Events limit exceeded
+
+ {number.format(subscriptionPeriodEventsCount)} /{' '} + {number.format(subscriptionPeriodEventsLimit)} +
+
+
+ )} + + +
+
Create report
-
- - + + +
o.id === organizationId)!; useEffect(() => { setActive(false); @@ -76,7 +79,7 @@ export function LayoutSidebar({
- +
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx index 1b961649..ca1659bb 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx @@ -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 (
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx index 4fb8edcb..6b01d806 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx @@ -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 (
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx index 7e0905f2..8ecd28e6 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx @@ -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 (
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing-faq.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing-faq.tsx new file mode 100644 index 00000000..4ca731d7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing-faq.tsx @@ -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 ( + + + Usage + + + {questions.map((q) => ( + + + {q.question} + + +
+ {q.answer.map((a) => ( +

{a}

+ ))} +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing.tsx new file mode 100644 index 00000000..d8bb3238 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/billing.tsx @@ -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 ( +
+ +
+ ); + } + if (productsQuery.isError) { + return ( +
+ Issues loading all tiers +
+ ); + } + return ( + item.id} + columns={[ + { + name: 'Tier', + render(item) { + return
{item.name}
; + }, + className: 'w-full', + }, + { + name: 'Price', + render(item) { + const price = item.prices[0]; + if (!price) { + return null; + } + + if (price.amountType === 'free') { + return ( +
+
+ Free + +
+
+ ); + } + + if (price.amountType !== 'fixed') { + return null; + } + + return ( +
+
+ + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: price.priceCurrency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price.priceAmount / 100)} + {' / '} + {recurringInterval === 'year' ? 'year' : 'month'} + + +
+
+ ); + }, + }, + ]} + /> + ); + } + + return ( + <> + + + Billing +
+ + {recurringInterval === 'year' + ? 'Yearly (2 months free)' + : 'Monthly'} + + + setRecurringInterval(checked ? 'year' : 'month') + } + /> +
+
+ +
+ {renderBillingTable()} +
+

Do you need higher limits?

+

+ Reach out to{' '} + + hello@openpanel.dev + {' '} + and we'll help you out. +

+
+
+
+
+ { + setCustomerSessionToken(null); + if (!open) { + router.refresh(); + } + }} + > + + Subscription created + + We have registered your subscription. It'll be activated within a + couple of seconds. + + + + + + + + + + ); +} + +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 ( + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx new file mode 100644 index 00000000..bd6e4323 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx @@ -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 ( +
+ +
+ ); + } + if (productQuery.isError) { + return ( +
+ Issues loading all tiers +
+ ); + } + + if (!productQuery.data) { + return ( +
+ No subscription found +
+ ); + } + + const product = productQuery.data; + const price = product.prices[0]!; + return ( + <> +
+
+
Name
+
{product.name}
+
+ {price.amountType === 'fixed' ? ( + <> +
+
Price
+
+ {number.currency(price.priceAmount / 100)} +
+
+ + ) : ( + <> +
+
Price
+
FREE
+
+ + )} +
+
Billing Cycle
+
+ {price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'} +
+
+ {typeof product.metadata.eventsLimit === 'number' && ( +
+
Events per mount
+
+ {number.format(product.metadata.eventsLimit)} +
+
+ )} +
+
+ {organization.isWillBeCanceled || organization.isCanceled ? ( + + ) : ( + + )} +
+ + ); + } + + return ( +
+ + + Current Subscription +
+
+
+
+
+
+ + + {organization.isTrial && organization.subscriptionEndsAt && ( + + Free trial + + Your organization is on a free trial. It ends on{' '} + {format(organization.subscriptionEndsAt, 'PPP')} + + + )} + {organization.isExpired && organization.subscriptionEndsAt && ( + + Subscription expired + + Your subscription has expired. You can reactivate it by choosing + a new plan below. + + + It expired on {format(organization.subscriptionEndsAt, 'PPP')} + + + )} + {organization.isWillBeCanceled && ( + + Subscription canceled + + You have canceled your subscription. You can reactivate it by + choosing a new plan below. + + + It'll expire on{' '} + {format(organization.subscriptionEndsAt!, 'PPP')} + + + )} + {organization.isCanceled && ( + + Subscription canceled + + Your subscription was canceled on{' '} + {format(organization.subscriptionCanceledAt!, 'PPP')} + + + )} + {render()} + + + {organization.hasSubscription && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/edit-organization.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/edit-organization.tsx similarity index 55% rename from apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/edit-organization.tsx rename to apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/edit-organization.tsx index ce1d5f4a..8d11bc50 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/edit-organization.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/edit-organization.tsx @@ -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; interface EditOrganizationProps { - organization: Awaited>; + organization: IServiceOrganization; } export default function EditOrganization({ organization, @@ -41,29 +41,27 @@ export default function EditOrganization({ }); return ( -
-
{ - mutation.mutate(values); - })} - > - - - Details - - - - - - -
-
+
{ + mutation.mutate(values); + })} + > + + + Details + + + + + + +
); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/organization.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/organization.tsx new file mode 100644 index 00000000..dfdbeb16 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/organization.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/usage.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/usage.tsx new file mode 100644 index 00000000..bd1eeea7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/usage.tsx @@ -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 ( +
+
{title}
+
{value}
+
+ ); +} + +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) => ( + + + Usage + + {node} + + ); + + if (usageQuery.isLoading) { + return wrapper( +
+ +
, + ); + } + if (usageQuery.isError) { + return wrapper( +
+ Issues loading usage data +
, + ); + } + + 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( + <> +
+ {organization.hasSubscription ? ( + <> + + + + + + ) : ( + <> +
+ +
+
+ item.count)), + )} + /> +
+ + )} +
+
+ + ({ + date: new Date(item.day).getTime(), + count: item.count, + limit: subscriptionPeriodEventsLimit, + total: subscriptionPeriodEventsCount, + }))} + barSize={8} + > + + + + + + + } + cursor={{ + stroke: 'hsl(var(--def-400))', + fill: 'hsl(var(--def-200))', + }} + /> + {organization.hasSubscription && ( + <> + + 1000 + ? 'insideTop' + : 'insideBottom', + fontSize: 12, + }} + /> + + )} + + + + + + +
+ , + ); +} + +function Tooltip(props: any) { + const number = useNumber(); + const payload = props.payload?.[0]?.payload; + + if (!payload) { + return null; + } + return ( +
+
+ {formatDate(payload.date)} +
+ {payload.limit !== 0 && ( +
+
+
+
Your tier limit
+
+ {number.format(payload.limit)} +
+
+
+ )} + {payload.total !== 0 && ( +
+
+
+
+ Total events count +
+
+ {number.format(payload.total)} +
+
+
+ )} +
+
+
+
Events this day
+
+ {number.format(payload.count)} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx index 9543c676..f410a4bb 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx @@ -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({ Organization + {isBillingEnabled && ( + + Billing + + )} Members @@ -79,7 +89,17 @@ export default async function Page({ - {tab === 'org' && } + {tab === 'org' && } + {tab === 'billing' && isBillingEnabled && ( +
+
+ + + +
+ +
+ )} {tab === 'members' && } {tab === 'invites' && } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/delete-project.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/delete-project.tsx new file mode 100644 index 00000000..be188034 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/delete-project.tsx @@ -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 ( + + + Delete Project + + +

+ Deleting your project will remove it from your organization and all of + its data. It'll be permanently deleted after 24 hours. +

+ {project?.deleteAt && ( + + Project scheduled for deletion + + This project will be deleted on{' '} + + { + // 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', + ) + } + + . Any event associated with this project will be deleted. + + + )} +
+ {project?.deleteAt && ( + + )} + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx index 903a8530..37699219 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx @@ -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) { +
); diff --git a/apps/dashboard/src/components/page-tabs.tsx b/apps/dashboard/src/components/page-tabs.tsx index b5504279..a17e9b64 100644 --- a/apps/dashboard/src/components/page-tabs.tsx +++ b/apps/dashboard/src/components/page-tabs.tsx @@ -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 ( - + + {children} + + {isActive && ( + )} - href={href} - > - {children} - +
); } diff --git a/apps/dashboard/src/components/report-chart/area/chart.tsx b/apps/dashboard/src/components/report-chart/area/chart.tsx index 1645197b..518aa5f8 100644 --- a/apps/dashboard/src/components/report-chart/area/chart.tsx +++ b/apps/dashboard/src/components/report-chart/area/chart.tsx @@ -105,7 +105,6 @@ export function Chart({ data }: Props) { }, [series]); const yAxisProps = useYAxisProps({ - data: [data.metrics.max], hide: hideYAxis, }); const xAxisProps = useXAxisProps({ diff --git a/apps/dashboard/src/components/report-chart/common/axis.tsx b/apps/dashboard/src/components/report-chart/common/axis.tsx index c58a54db..ce0c8c3b 100644 --- a/apps/dashboard/src/components/report-chart/common/axis.tsx +++ b/apps/dashboard/src/components/report-chart/common/axis.tsx @@ -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)); diff --git a/apps/dashboard/src/components/report-chart/histogram/chart.tsx b/apps/dashboard/src/components/report-chart/histogram/chart.tsx index 1f3b5736..c7d91cf5 100644 --- a/apps/dashboard/src/components/report-chart/histogram/chart.tsx +++ b/apps/dashboard/src/components/report-chart/histogram/chart.tsx @@ -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({ diff --git a/apps/dashboard/src/components/report-chart/line/chart.tsx b/apps/dashboard/src/components/report-chart/line/chart.tsx index 9573e5ac..aa0f585e 100644 --- a/apps/dashboard/src/components/report-chart/line/chart.tsx +++ b/apps/dashboard/src/components/report-chart/line/chart.tsx @@ -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 ( diff --git a/apps/dashboard/src/components/report-chart/retention/chart.tsx b/apps/dashboard/src/components/report-chart/retention/chart.tsx index 0bcc901b..3ffd7604 100644 --- a/apps/dashboard/src/components/report-chart/retention/chart.tsx +++ b/apps/dashboard/src/components/report-chart/retention/chart.tsx @@ -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}%`, }); diff --git a/apps/dashboard/src/components/ui/accordion.tsx b/apps/dashboard/src/components/ui/accordion.tsx index 655134f2..32d1da5b 100644 --- a/apps/dashboard/src/components/ui/accordion.tsx +++ b/apps/dashboard/src/components/ui/accordion.tsx @@ -11,7 +11,7 @@ const AccordionItem = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/apps/dashboard/src/components/ui/alert.tsx b/apps/dashboard/src/components/ui/alert.tsx index 922f4231..c45b5d57 100644 --- a/apps/dashboard/src/components/ui/alert.tsx +++ b/apps/dashboard/src/components/ui/alert.tsx @@ -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: { diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index 6d34badd..5e85f4ea 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -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: diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx index 2f430e90..d4d85816 100644 --- a/apps/dashboard/src/components/ui/dialog.tsx +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -86,10 +86,7 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -101,7 +98,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/apps/dashboard/src/components/widget-table.tsx b/apps/dashboard/src/components/widget-table.tsx index 78415440..0d8391f1 100644 --- a/apps/dashboard/src/components/widget-table.tsx +++ b/apps/dashboard/src/components/widget-table.tsx @@ -4,6 +4,7 @@ interface Props { columns: { name: string; render: (item: T) => React.ReactNode; + className?: string; }[]; keyExtractor: (item: T) => string; data: T[]; @@ -41,7 +42,9 @@ export function WidgetTable({ {columns.map((column) => ( - {column.name} + + {column.name} + ))} @@ -49,10 +52,14 @@ export function WidgetTable({ {data.map((item) => ( {columns.map((column) => ( - {column.render(item)} + + {column.render(item)} + ))} ))} diff --git a/apps/dashboard/src/hooks/useNumerFormatter.ts b/apps/dashboard/src/hooks/useNumerFormatter.ts index c6c6f5e4..b8541142 100644 --- a/apps/dashboard/src/hooks/useNumerFormatter.ts +++ b/apps/dashboard/src/hooks/useNumerFormatter.ts @@ -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) => { diff --git a/apps/dashboard/src/hooks/useWS.ts b/apps/dashboard/src/hooks/useWS.ts index 4179b427..542b26b0 100644 --- a/apps/dashboard/src/hooks/useWS.ts +++ b/apps/dashboard/src/hooks/useWS.ts @@ -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?: { diff --git a/apps/dashboard/src/modals/Confirm.tsx b/apps/dashboard/src/modals/Confirm.tsx index 6abff680..57f5bdb6 100644 --- a/apps/dashboard/src/modals/Confirm.tsx +++ b/apps/dashboard/src/modals/Confirm.tsx @@ -20,7 +20,7 @@ export default function Confirm({ return ( -

{text}

+

{text}

-
+
diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index 595d8bd1..e7880b99 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -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 diff --git a/apps/worker/package.json b/apps/worker/package.json index e56d53c2..9d48d866 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -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:*", diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index 770747c2..e1ea6416 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -14,6 +14,11 @@ export async function bootCron() { type: 'salt', pattern: '0 0 * * *', }, + { + name: 'deleteProjects', + type: 'deleteProjects', + pattern: '0 * * * *', + }, { name: 'flush', type: 'flushEvents', diff --git a/apps/worker/src/jobs/cron.delete-projects.ts b/apps/worker/src/jobs/cron.delete-projects.ts new file mode 100644 index 00000000..9b73773f --- /dev/null +++ b/apps/worker/src/jobs/cron.delete-projects.ts @@ -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, + }); +} diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index 5c0057cc..10ce9a74 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -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) { case 'ping': { return await ping(); } + case 'deleteProjects': { + return await deleteProjects(); + } } } diff --git a/apps/worker/src/jobs/events.ts b/apps/worker/src/jobs/events.ts index 40fac0c1..ec573872 100644 --- a/apps/worker/src/jobs/events.ts +++ b/apps/worker/src/jobs/events.ts @@ -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) { - switch (job.data.type) { - case 'incomingEvent': { - return await incomingEvent(job as Job); - } - case 'createSessionEnd': { - try { - await updateEventsCount(job.data.payload.projectId); - } catch (e) { - job.log('Failed to update count'); - } - - return await createSessionEnd( - job as Job, - ); - } - } -} - -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); } diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts index e6795d23..ed076b38 100644 --- a/apps/worker/src/jobs/notification.ts +++ b/apps/worker/src/jobs/notification.ts @@ -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) { switch (job.data.type) { @@ -13,7 +13,7 @@ export async function notificationJob(job: Job) { const { notification } = job.data.payload; if (notification.sendToApp) { - getRedisPub().publish('notification', setSuperJson(notification)); + publishEvent('notification', 'created', notification); // empty for now return; } diff --git a/apps/worker/src/jobs/sessions.ts b/apps/worker/src/jobs/sessions.ts index 7a37941d..90b49700 100644 --- a/apps/worker/src/jobs/sessions.ts +++ b/apps/worker/src/jobs/sessions.ts @@ -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) { - 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); diff --git a/package.json b/package.json index 4c5bf278..17423178 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 25f856f0..628edb0a 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -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, }); diff --git a/packages/common/src/object.ts b/packages/common/src/object.ts index 374342b8..dd3a237e 100644 --- a/packages/common/src/object.ts +++ b/packages/common/src/object.ts @@ -1,5 +1,4 @@ import { anyPass, assocPath, isEmpty, isNil, reject } from 'ramda'; -import superjson from 'superjson'; export function toDots( obj: Record, @@ -48,26 +47,6 @@ export function toObject( export const strip = reject(anyPass([isEmpty, isNil])); -export function getSafeJson(str: string): T | null { - try { - return JSON.parse(str); - } catch (e) { - return null; - } -} - -export function getSuperJson(str: string): T | null { - const json = getSafeJson(str); - if (typeof json === 'object' && json !== null && 'json' in json) { - return superjson.parse(str); - } - return json; -} - -export function setSuperJson(str: Record): string { - return superjson.stringify(str); -} - type AnyObject = Record; export function deepMergeObjects(target: AnyObject, source: AnyObject): T { diff --git a/packages/db/package.json b/packages/db/package.json index dc5334a3..8a7cfffa 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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:*", diff --git a/packages/db/prisma/migrations/20250101222359_payments/migration.sql b/packages/db/prisma/migrations/20250101222359_payments/migration.sql new file mode 100644 index 00000000..8079bcd0 --- /dev/null +++ b/packages/db/prisma/migrations/20250101222359_payments/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20250104140340_add_events_count_on_org_level/migration.sql b/packages/db/prisma/migrations/20250104140340_add_events_count_on_org_level/migration.sql new file mode 100644 index 00000000..f11c2acf --- /dev/null +++ b/packages/db/prisma/migrations/20250104140340_add_events_count_on_org_level/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "eventsCount" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/db/prisma/migrations/20250104192616_subscription_v2/migration.sql b/packages/db/prisma/migrations/20250104192616_subscription_v2/migration.sql new file mode 100644 index 00000000..2e95cb22 --- /dev/null +++ b/packages/db/prisma/migrations/20250104192616_subscription_v2/migration.sql @@ -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); diff --git a/packages/db/prisma/migrations/20250104194353_subscription_v2/migration.sql b/packages/db/prisma/migrations/20250104194353_subscription_v2/migration.sql new file mode 100644 index 00000000..8b318be8 --- /dev/null +++ b/packages/db/prisma/migrations/20250104194353_subscription_v2/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "subscriptionPeriodLimit" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/db/prisma/migrations/20250104194646_subs_v3/migration.sql b/packages/db/prisma/migrations/20250104194646_subs_v3/migration.sql new file mode 100644 index 00000000..fe53c8af --- /dev/null +++ b/packages/db/prisma/migrations/20250104194646_subs_v3/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "subscriptionInterval" TEXT; diff --git a/packages/db/prisma/migrations/20250219225132_rename_subscription_period_limit/migration.sql b/packages/db/prisma/migrations/20250219225132_rename_subscription_period_limit/migration.sql new file mode 100644 index 00000000..8828b8cc --- /dev/null +++ b/packages/db/prisma/migrations/20250219225132_rename_subscription_period_limit/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20250220215129_subscription_wip/migration.sql b/packages/db/prisma/migrations/20250220215129_subscription_wip/migration.sql new file mode 100644 index 00000000..a010bb74 --- /dev/null +++ b/packages/db/prisma/migrations/20250220215129_subscription_wip/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "subscriptionPeriodEventsCountExceededAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20250221204153_sub_wip/migration.sql b/packages/db/prisma/migrations/20250221204153_sub_wip/migration.sql new file mode 100644 index 00000000..aea3ed7e --- /dev/null +++ b/packages/db/prisma/migrations/20250221204153_sub_wip/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "subscriptionCanceledAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20250225220926_delete_project/migration.sql b/packages/db/prisma/migrations/20250225220926_delete_project/migration.sql new file mode 100644 index 00000000..744c1863 --- /dev/null +++ b/packages/db/prisma/migrations/20250225220926_delete_project/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "organizations" ADD COLUMN "deleteAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "projects" ADD COLUMN "deleteAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20250225230336_cascade_delete_on_projet/migration.sql b/packages/db/prisma/migrations/20250225230336_cascade_delete_on_projet/migration.sql new file mode 100644 index 00000000..c53968ea --- /dev/null +++ b/packages/db/prisma/migrations/20250225230336_cascade_delete_on_projet/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20250225230540_cascade_delete_2/migration.sql b/packages/db/prisma/migrations/20250225230540_cascade_delete_2/migration.sql new file mode 100644 index 00000000..c2bcd698 --- /dev/null +++ b/packages/db/prisma/migrations/20250225230540_cascade_delete_2/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e893ac98..5e49e4e3 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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? diff --git a/packages/db/src/buffers/bot-buffer-redis.ts b/packages/db/src/buffers/bot-buffer-redis.ts index 9a1b4a51..723bcddf 100644 --- a/packages/db/src/buffers/bot-buffer-redis.ts +++ b/packages/db/src/buffers/bot-buffer-redis.ts @@ -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'; diff --git a/packages/db/src/buffers/event-buffer-redis.ts b/packages/db/src/buffers/event-buffer-redis.ts index 71a7c6f1..f568ed76 100644 --- a/packages/db/src/buffers/event-buffer-redis.ts +++ b/packages/db/src/buffers/event-buffer-redis.ts @@ -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, - ) { - try { - await (multi || getRedisPub()).publish( - channel, - setSuperJson( - transformEvent(event) as unknown as Record, - ), - ); - } 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(); diff --git a/packages/db/src/buffers/profile-buffer-redis.ts b/packages/db/src/buffers/profile-buffer-redis.ts index 7adafdcb..113e2df9 100644 --- a/packages/db/src/buffers/profile-buffer-redis.ts +++ b/packages/db/src/buffers/profile-buffer-redis.ts @@ -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(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) diff --git a/packages/db/src/prisma-client.ts b/packages/db/src/prisma-client.ts index f887cc6b..9e5e1b5d 100644 --- a/packages/db/src/prisma-client.ts +++ b/packages/db/src/prisma-client.ts @@ -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 | undefined; + prisma: ReturnType; }; export const db = globalForPrisma.prisma ?? getPrismaClient(); -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db; +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = db; +} diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts index 9e7bc9ea..afca597a 100644 --- a/packages/db/src/services/organization.service.ts +++ b/packages/db/src/services/organization.service.ts @@ -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; +import { createSqlBuilder } from '../sql-builder'; +import type { IServiceProject } from './project.service'; +export type IServiceOrganization = Awaited< + ReturnType +>; 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(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, +); diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index d727699c..5980b948 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -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; +}; diff --git a/packages/db/src/services/user.service.ts b/packages/db/src/services/user.service.ts index 4b7d7d7b..197bca54 100644 --- a/packages/db/src/services/user.service.ts +++ b/packages/db/src/services/user.service.ts @@ -1,5 +1,7 @@ import { db } from '../prisma-client'; +export type IServiceUser = Awaited>; + export async function getUserById(id: string) { return db.user.findUniqueOrThrow({ where: { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index e6b70476..8d6db586 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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'; } } diff --git a/packages/json/index.ts b/packages/json/index.ts new file mode 100644 index 00000000..6a3ec631 --- /dev/null +++ b/packages/json/index.ts @@ -0,0 +1,21 @@ +import superjson from 'superjson'; + +export function getSafeJson(str: string): T | null { + try { + return JSON.parse(str); + } catch (e) { + return null; + } +} + +export function getSuperJson(str: string): T | null { + const json = getSafeJson(str); + if (typeof json === 'object' && json !== null && 'json' in json) { + return superjson.parse(str); + } + return json; +} + +export function setSuperJson(str: any): string { + return superjson.stringify(str); +} diff --git a/packages/json/package.json b/packages/json/package.json new file mode 100644 index 00000000..3e6db346 --- /dev/null +++ b/packages/json/package.json @@ -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" + } +} diff --git a/packages/json/tsconfig.json b/packages/json/tsconfig.json new file mode 100644 index 00000000..23a69125 --- /dev/null +++ b/packages/json/tsconfig.json @@ -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"] +} diff --git a/packages/payments/index.ts b/packages/payments/index.ts new file mode 100644 index 00000000..fc78ce44 --- /dev/null +++ b/packages/payments/index.ts @@ -0,0 +1,2 @@ +export * from './src/polar'; +export * from './src/prices'; diff --git a/packages/payments/package.json b/packages/payments/package.json new file mode 100644 index 00000000..ca1ebac8 --- /dev/null +++ b/packages/payments/package.json @@ -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" + } +} diff --git a/packages/payments/scripts/create-custom-pricing.ts b/packages/payments/scripts/create-custom-pricing.ts new file mode 100644 index 00000000..693882b1 --- /dev/null +++ b/packages/payments/scripts/create-custom-pricing.ts @@ -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([ + { + 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()); diff --git a/packages/payments/scripts/create-products.ts b/packages/payments/scripts/create-products.ts new file mode 100644 index 00000000..f542a0c6 --- /dev/null +++ b/packages/payments/scripts/create-products.ts @@ -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([ + { + 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(); diff --git a/packages/payments/src/polar.ts b/packages/payments/src/polar.ts new file mode 100644 index 00000000..4fb2bb17 --- /dev/null +++ b/packages/payments/src/polar.ts @@ -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>; +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, + }, + }); +} diff --git a/packages/payments/src/prices.ts b/packages/payments/src/prices.ts new file mode 100644 index 00000000..686a2f6f --- /dev/null +++ b/packages/payments/src/prices.ts @@ -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 }, +]; diff --git a/packages/payments/tsconfig.json b/packages/payments/tsconfig.json new file mode 100644 index 00000000..a291eef2 --- /dev/null +++ b/packages/payments/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@openpanel/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["."], + "exclude": ["node_modules"] +} diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 682d2392..7234e87d 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -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']; diff --git a/packages/redis/cachable.ts b/packages/redis/cachable.ts index 3017ab19..dd814de4 100644 --- a/packages/redis/cachable.ts +++ b/packages/redis/cachable.ts @@ -1,5 +1,28 @@ import { getRedisCache } from './redis'; +export async function getCache( + key: string, + expireInSec: number, + fn: () => Promise, +): Promise { + 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 any>( fn: T, expireInSec: number, @@ -37,7 +60,15 @@ export function cacheable 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); } diff --git a/packages/redis/index.ts b/packages/redis/index.ts index bb0415e0..eae3c08a 100644 --- a/packages/redis/index.ts +++ b/packages/redis/index.ts @@ -1,3 +1,4 @@ export * from './redis'; export * from './cachable'; export * from './run-every'; +export * from './publisher'; diff --git a/packages/redis/package.json b/packages/redis/package.json index d4c11af6..978de71b 100644 --- a/packages/redis/package.json +++ b/packages/redis/package.json @@ -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", diff --git a/packages/redis/publisher.ts b/packages/redis/publisher.ts new file mode 100644 index 00000000..fed6c4b9 --- /dev/null +++ b/packages/redis/publisher.ts @@ -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: Channel, + type: keyof IPublishChannels[Channel], +) { + return `${channel}:${String(type)}`; +} + +export function publishEvent( + channel: Channel, + type: keyof IPublishChannels[Channel], + event: IPublishChannels[Channel][typeof type], + multi?: ReturnType, +) { + const redis = multi ?? getRedisPub(); + return redis.publish(getSubscribeChannel(channel, type), setSuperJson(event)); +} + +export function parsePublishedEvent( + _channel: Channel, + _type: keyof IPublishChannels[Channel], + message: string, +): IPublishChannels[Channel][typeof _type] { + return getSuperJson(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); + }; +} diff --git a/packages/sdks/express/tsup.config.ts b/packages/sdks/express/tsup.config.ts index 741f5d96..541734ed 100644 --- a/packages/sdks/express/tsup.config.ts +++ b/packages/sdks/express/tsup.config.ts @@ -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, }); diff --git a/packages/sdks/nextjs/tsup.config.ts b/packages/sdks/nextjs/tsup.config.ts index 92058ef8..8672acfc 100644 --- a/packages/sdks/nextjs/tsup.config.ts +++ b/packages/sdks/nextjs/tsup.config.ts @@ -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, }); diff --git a/packages/sdks/react-native/tsup.config.ts b/packages/sdks/react-native/tsup.config.ts index a7a3f5e7..6ec80f8c 100644 --- a/packages/sdks/react-native/tsup.config.ts +++ b/packages/sdks/react-native/tsup.config.ts @@ -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, }); diff --git a/packages/sdks/sdk/tsup.config.ts b/packages/sdks/sdk/tsup.config.ts index 2624fc70..6ec80f8c 100644 --- a/packages/sdks/sdk/tsup.config.ts +++ b/packages/sdks/sdk/tsup.config.ts @@ -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, +}); diff --git a/packages/sdks/web/tsup.config.ts b/packages/sdks/web/tsup.config.ts index aaf83a9f..6475a73d 100644 --- a/packages/sdks/web/tsup.config.ts +++ b/packages/sdks/web/tsup.config.ts @@ -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, }); diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 28a946dc..8002a3e1 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -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", diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index c1b4adef..18610e15 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -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 diff --git a/packages/trpc/src/routers/chart.helpers.ts b/packages/trpc/src/routers/chart.helpers.ts index 5d608a18..941ecdc2 100644 --- a/packages/trpc/src/routers/chart.helpers.ts +++ b/packages/trpc/src/routers/chart.helpers.ts @@ -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) { - 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) { diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts index 89458dd5..37f6b95e 100644 --- a/packages/trpc/src/routers/onboarding.ts +++ b/packages/trpc/src/routers/onboarding.ts @@ -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, - 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'); diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts index 59b6a4ed..ac710a8b 100644 --- a/packages/trpc/src/routers/project.ts +++ b/packages/trpc/src/routers/project.ts @@ -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; }), }); diff --git a/packages/trpc/src/routers/subscription.ts b/packages/trpc/src/routers/subscription.ts new file mode 100644 index 00000000..8c21aee3 --- /dev/null +++ b/packages/trpc/src/routers/subscription.ts @@ -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, + }; + }), +}); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 7103154b..8ab44dd8 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -340,3 +340,11 @@ export const zSignInShare = z.object({ shareId: z.string().min(1), }); export type ISignInShare = z.infer; + +export const zCheckout = z.object({ + productPriceId: z.string(), + organizationId: z.string(), + projectId: z.string().nullish(), + productId: z.string(), +}); +export type ICheckout = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19fba90a..d93d8d9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,15 @@ importers: '@openpanel/integrations': specifier: workspace:^ version: link:../../packages/integrations + '@openpanel/json': + specifier: workspace:* + version: link:../../packages/json '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger + '@openpanel/payments': + specifier: workspace:* + version: link:../../packages/payments '@openpanel/queue': specifier: workspace:* version: link:../../packages/queue @@ -87,6 +93,9 @@ importers: fastify-metrics: specifier: ^11.0.0 version: 11.0.0(fastify@4.26.1) + fastify-raw-body: + specifier: ^4.2.1 + version: 4.3.0 ico-to-png: specifier: ^0.2.1 version: 0.2.1 @@ -155,8 +164,8 @@ importers: specifier: ^9.0.8 version: 9.0.8 '@types/ws': - specifier: ^8.5.10 - version: 8.5.10 + specifier: ^8.5.14 + version: 8.5.14 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -193,6 +202,9 @@ importers: '@openpanel/integrations': specifier: workspace:^ version: link:../../packages/integrations + '@openpanel/json': + specifier: workspace:* + version: link:../../packages/json '@openpanel/nextjs': specifier: 1.0.3 version: 1.0.3(next@14.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -467,6 +479,9 @@ importers: specifier: ^3.22.4 version: 3.22.4 devDependencies: + '@openpanel/payments': + specifier: workspace:* + version: link:../../packages/payments '@openpanel/trpc': specifier: workspace:* version: link:../../packages/trpc @@ -651,6 +666,9 @@ importers: '@openpanel/integrations': specifier: workspace:^ version: link:../../packages/integrations + '@openpanel/json': + specifier: workspace:* + version: link:../../packages/json '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger @@ -880,6 +898,9 @@ importers: '@openpanel/constants': specifier: workspace:* version: link:../constants + '@openpanel/json': + specifier: workspace:* + version: link:../json '@openpanel/logger': specifier: workspace:* version: link:../logger @@ -1001,6 +1022,25 @@ importers: specifier: ^5.2.2 version: 5.3.3 + packages/json: + dependencies: + superjson: + specifier: ^1.13.3 + version: 1.13.3 + devDependencies: + '@openpanel/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@openpanel/validation': + specifier: workspace:* + version: link:../validation + '@types/node': + specifier: 20.14.8 + version: 20.14.8 + typescript: + specifier: ^5.2.2 + version: 5.6.3 + packages/logger: dependencies: '@hyperdx/node-opentelemetry': @@ -1023,6 +1063,40 @@ importers: specifier: ^5.2.2 version: 5.3.3 + packages/payments: + dependencies: + '@polar-sh/sdk': + specifier: ^0.26.1 + version: 0.26.1(zod@3.23.8) + devDependencies: + '@openpanel/db': + specifier: workspace:* + version: link:../db + '@openpanel/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/inquirer': + specifier: ^9.0.7 + version: 9.0.7 + '@types/inquirer-autocomplete-prompt': + specifier: ^3.0.3 + version: 3.0.3 + '@types/node': + specifier: 20.14.8 + version: 20.14.8 + '@types/react': + specifier: ^18.2.0 + version: 18.3.12 + inquirer: + specifier: ^9.3.5 + version: 9.3.6 + inquirer-autocomplete-prompt: + specifier: ^3.0.1 + version: 3.0.1(inquirer@9.3.6) + typescript: + specifier: ^5.2.2 + version: 5.6.3 + packages/queue: dependencies: '@openpanel/db': @@ -1050,10 +1124,16 @@ importers: packages/redis: dependencies: + '@openpanel/json': + specifier: workspace:* + version: link:../json ioredis: specifier: ^5.4.1 version: 5.4.1 devDependencies: + '@openpanel/db': + specifier: workspace:* + version: link:../db '@openpanel/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -1224,6 +1304,9 @@ importers: '@openpanel/integrations': specifier: workspace:^ version: link:../integrations + '@openpanel/payments': + specifier: workspace:^ + version: link:../payments '@openpanel/redis': specifier: workspace:* version: link:../redis @@ -4023,6 +4106,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@polar-sh/sdk@0.26.1': + resolution: {integrity: sha512-OEaxiNJaxpeNi7LANHR5S71BAyORk6W0lwkfHcrGyMGS9VDdgXnZjB8QZ3tFSXbQvt3yZdHShX6pPC8xOxNvFw==} + peerDependencies: + zod: '>= 3' + '@prisma/client@5.9.1': resolution: {integrity: sha512-caSOnG4kxcSkhqC/2ShV7rEoWwd3XrftokxJqOCMVvia4NYV/TPtJlS9C2os3Igxw/Qyxumj9GBQzcStzECvtQ==} engines: {node: '>=16.13'} @@ -5975,6 +6063,12 @@ packages: '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/inquirer-autocomplete-prompt@3.0.3': + resolution: {integrity: sha512-OQCW09mEECgvhcppbQRgZSmWskWv58l+WwyUvWB1oxTu3CZj8keYSDZR9U8owUzJ5Zeux5kacN9iVPJLXcoLXg==} + + '@types/inquirer@9.0.7': + resolution: {integrity: sha512-Q0zyBupO6NxGRZut/JdmqYKOnN95Eg5V8Csg3PGKkP+FnvsUZx1jAyK7fztIszxxMuoBA6E3KXWvdZVXIpx60g==} + '@types/is-stream@1.1.0': resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==} @@ -6041,9 +6135,6 @@ packages: '@types/mysql@2.15.22': resolution: {integrity: sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==} - '@types/node@20.14.12': - resolution: {integrity: sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==} - '@types/node@20.14.8': resolution: {integrity: sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==} @@ -6128,6 +6219,9 @@ packages: '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/through@0.0.33': + resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -6152,8 +6246,8 @@ packages: '@types/ws@7.4.7': resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} - '@types/ws@8.5.10': - resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + '@types/ws@8.5.14': + resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -6252,6 +6346,10 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + ansi-fragments@0.2.1: resolution: {integrity: sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==} @@ -7704,6 +7802,10 @@ packages: fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + fastify-raw-body@4.3.0: + resolution: {integrity: sha512-F4o8ZIMVx4YoxGfwrZys6wyjl40gF3Yv6AWWRy62ozFAyZBSS831/uyyCAqKYw3tR73g180ryG98yih6To1PUQ==} + engines: {node: '>= 10'} + fastify@4.26.1: resolution: {integrity: sha512-tznA/G55dsxzM5XChBfcvVSloG2ejeeotfPPJSFaWmHyCDVGMpvf3nRNbsCb/JTBF9RmQFBfuujWt3Nphjesng==} @@ -7735,6 +7837,10 @@ packages: fetch-retry@4.1.1: resolution: {integrity: sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==} + figures@5.0.0: + resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} + engines: {node: '>=14'} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -8268,6 +8374,12 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + inquirer-autocomplete-prompt@3.0.1: + resolution: {integrity: sha512-DQBXwX2fVQPVUzu4v4lGgtNgyjcX2+rTyphb2MeSOQh3xUayKAfHAF4y0KgsMi06m6ZiR3xIOdzMZMfQgX2m9w==} + engines: {node: '>=16'} + peerDependencies: + inquirer: ^9.1.0 + inquirer@9.3.6: resolution: {integrity: sha512-riK/iQB2ctwkpWYgjjWIRv3MBLt2gzb2Sj0JNQNbyTXgyXsLWcDPJ5WS5ZDTCx7BRFnJsARtYh+58fjP5M2Y0Q==} engines: {node: '>=18'} @@ -8494,6 +8606,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + is-valid-path@0.1.1: resolution: {integrity: sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==} engines: {node: '>=0.10.0'} @@ -10644,6 +10760,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + run-async@3.0.0: resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} engines: {node: '>=0.12.0'} @@ -10937,6 +11057,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -15588,6 +15711,11 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@polar-sh/sdk@0.26.1(zod@3.23.8)': + dependencies: + standardwebhooks: 1.0.0 + zod: 3.23.8 + '@prisma/client@5.9.1(prisma@5.9.1)': optionalDependencies: prisma: 5.9.1 @@ -18167,6 +18295,15 @@ snapshots: '@types/http-errors@2.0.4': {} + '@types/inquirer-autocomplete-prompt@3.0.3': + dependencies: + '@types/inquirer': 9.0.7 + + '@types/inquirer@9.0.7': + dependencies: + '@types/through': 0.0.33 + rxjs: 7.8.1 + '@types/is-stream@1.1.0': dependencies: '@types/node': 20.14.8 @@ -18189,7 +18326,7 @@ snapshots: '@types/jsonwebtoken@9.0.6': dependencies: - '@types/node': 20.14.12 + '@types/node': 20.14.8 '@types/keygrip@1.0.6': {} @@ -18246,10 +18383,6 @@ snapshots: dependencies: '@types/node': 20.14.8 - '@types/node@20.14.12': - dependencies: - undici-types: 5.26.5 - '@types/node@20.14.8': dependencies: undici-types: 5.26.5 @@ -18288,7 +18421,7 @@ snapshots: '@types/react-dom@18.2.19': dependencies: - '@types/react': 18.2.56 + '@types/react': 18.3.12 '@types/react-dom@18.3.1': dependencies: @@ -18299,11 +18432,11 @@ snapshots: '@types/d3-geo': 2.0.7 '@types/d3-zoom': 2.0.7 '@types/geojson': 7946.0.14 - '@types/react': 18.2.56 + '@types/react': 18.3.12 '@types/react-syntax-highlighter@15.5.11': dependencies: - '@types/react': 18.2.56 + '@types/react': 18.3.12 '@types/react@18.2.56': dependencies: @@ -18318,7 +18451,7 @@ snapshots: '@types/request-ip@0.0.41': dependencies: - '@types/node': 20.14.12 + '@types/node': 20.14.8 '@types/retry@0.12.0': {} @@ -18351,6 +18484,10 @@ snapshots: dependencies: '@types/node': 20.14.8 + '@types/through@0.0.33': + dependencies: + '@types/node': 20.14.8 + '@types/triple-beam@1.3.5': {} '@types/tsscmp@1.0.2': {} @@ -18369,9 +18506,9 @@ snapshots: dependencies: '@types/node': 20.14.8 - '@types/ws@8.5.10': + '@types/ws@8.5.14': dependencies: - '@types/node': 20.14.12 + '@types/node': 20.14.8 '@types/yargs-parser@21.0.3': {} @@ -18479,6 +18616,8 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@6.2.1: {} + ansi-fragments@0.2.1: dependencies: colorette: 1.4.0 @@ -20352,6 +20491,12 @@ snapshots: fastify-plugin@4.5.1: {} + fastify-raw-body@4.3.0: + dependencies: + fastify-plugin: 4.5.1 + raw-body: 2.5.2 + secure-json-parse: 2.7.0 + fastify@4.26.1: dependencies: '@fastify/ajv-compiler': 3.5.0 @@ -20414,6 +20559,11 @@ snapshots: fetch-retry@4.1.1: {} + figures@5.0.0: + dependencies: + escape-string-regexp: 5.0.0 + is-unicode-supported: 1.3.0 + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -21076,6 +21226,15 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + inquirer-autocomplete-prompt@3.0.1(inquirer@9.3.6): + dependencies: + ansi-escapes: 6.2.1 + figures: 5.0.0 + inquirer: 9.3.6 + picocolors: 1.1.1 + run-async: 2.4.1 + rxjs: 7.8.1 + inquirer@9.3.6: dependencies: '@inquirer/figures': 1.0.4 @@ -21280,6 +21439,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@1.3.0: {} + is-valid-path@0.1.1: dependencies: is-invalid-path: 0.1.0 @@ -24133,6 +24294,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.12.0 fsevents: 2.3.3 + run-async@2.4.1: {} + run-async@3.0.0: {} run-parallel@1.2.0: @@ -24484,6 +24647,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.1: {} diff --git a/tooling/publish/tsconfig.json b/tooling/publish/tsconfig.json index 7cc67ac2..fb0dc1a3 100644 --- a/tooling/publish/tsconfig.json +++ b/tooling/publish/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "@openpanel/tsconfig/base.json", "compilerOptions": { - "module": "CommonJS", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, "include": ["."], diff --git a/tooling/typescript/base.json b/tooling/typescript/base.json index e3d719ee..59a801da 100644 --- a/tooling/typescript/base.json +++ b/tooling/typescript/base.json @@ -11,7 +11,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/tooling/typescript/tsup.config.json b/tooling/typescript/tsup.config.json deleted file mode 100644 index 290dc84b..00000000 --- a/tooling/typescript/tsup.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "entry": ["index.ts"], - "format": ["cjs", "esm"], - "dts": true, - "splitting": false, - "sourcemap": false, - "clean": true, - "minify": true -}