From 3bd4597d0ccf3c52a0f1c5883ff7729f52bfc16b Mon Sep 17 00:00:00 2001 From: msukkari Date: Fri, 14 Feb 2025 13:50:18 -0800 Subject: [PATCH 1/3] add stripe subscription status and webhook --- .../migration.sql | 6 ++ packages/db/prisma/schema.prisma | 9 +- packages/web/src/actions.ts | 3 + .../web/src/app/api/(server)/stripe/route.ts | 94 +++++++++++++++++++ packages/web/src/lib/environment.ts | 1 + 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/migrations/20250214214601_add_stripe_subscription_status_to_org/migration.sql create mode 100644 packages/web/src/app/api/(server)/stripe/route.ts diff --git a/packages/db/prisma/migrations/20250214214601_add_stripe_subscription_status_to_org/migration.sql b/packages/db/prisma/migrations/20250214214601_add_stripe_subscription_status_to_org/migration.sql new file mode 100644 index 00000000..32bc8ef9 --- /dev/null +++ b/packages/db/prisma/migrations/20250214214601_add_stripe_subscription_status_to_org/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "StripeSubscriptionStatus" AS ENUM ('ACTIVE', 'INACTIVE'); + +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "stripeLastUpdatedAt" TIMESTAMP(3), +ADD COLUMN "stripeSubscriptionStatus" "StripeSubscriptionStatus"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8c7e421d..acc853ec 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -26,6 +26,11 @@ enum ConnectionSyncStatus { FAILED } +enum StripeSubscriptionStatus { + ACTIVE + INACTIVE +} + model Repo { id Int @id @default(autoincrement()) name String @@ -115,7 +120,9 @@ model Org { repos Repo[] secrets Secret[] - stripeCustomerId String? + stripeCustomerId String? + stripeSubscriptionStatus StripeSubscriptionStatus? + stripeLastUpdatedAt DateTime? /// List of pending invites to this organization invites Invite[] diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 351a30d7..32a6db27 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -20,6 +20,7 @@ import { getStripe } from "@/lib/stripe" import { getUser } from "@/data/user"; import { Session } from "next-auth"; import { STRIPE_PRODUCT_ID } from "@/lib/environment"; +import { StripeSubscriptionStatus } from "@sourcebot/db"; import Stripe from "stripe"; const ajv = new Ajv({ validateFormats: false, @@ -103,6 +104,8 @@ export const createOrg = (name: string, domain: string, stripeCustomerId?: strin name, domain, stripeCustomerId, + stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, + stripeLastUpdatedAt: new Date(), members: { create: { role: "OWNER", diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts new file mode 100644 index 00000000..630a443c --- /dev/null +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -0,0 +1,94 @@ +import { headers } from 'next/headers'; +import { NextRequest } from 'next/server'; +import Stripe from 'stripe'; +import { prisma } from '@/prisma'; +import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment'; +import { getStripe } from '@/lib/stripe'; +import { StripeSubscriptionStatus } from '@sourcebot/db'; +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = headers().get('stripe-signature'); + + if (!signature) { + return new Response('No signature', { status: 400 }); + } + + try { + const stripe = getStripe(); + const event = stripe.webhooks.constructEvent( + body, + signature, + STRIPE_WEBHOOK_SECRET! + ); + + if (event.type === 'customer.subscription.deleted') { + const subscription = event.data.object as Stripe.Subscription; + const customerId = subscription.customer as string; + + const org = await prisma.org.findFirst({ + where: { + stripeCustomerId: customerId + } + }); + + if (!org) { + return new Response('Org not found', { status: 404 }); + } + + await prisma.org.update({ + where: { + id: org.id + }, + data: { + stripeSubscriptionStatus: StripeSubscriptionStatus.INACTIVE, + stripeLastUpdatedAt: new Date() + } + }); + console.log(`Org ${org.id} subscription status updated to INACTIVE`); + + return new Response(JSON.stringify({ received: true }), { + status: 200 + }); + } else if (event.type === 'customer.subscription.created') { + const subscription = event.data.object as Stripe.Subscription; + const customerId = subscription.customer as string; + + const org = await prisma.org.findFirst({ + where: { + stripeCustomerId: customerId + } + }); + + if (!org) { + return new Response('Org not found', { status: 404 }); + } + + await prisma.org.update({ + where: { + id: org.id + }, + data: { + stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, + stripeLastUpdatedAt: new Date() + } + }); + console.log(`Org ${org.id} subscription status updated to ACTIVE`); + + return new Response(JSON.stringify({ received: true }), { + status: 200 + }); + } else { + console.log(`Received unknown event type: ${event.type}`); + return new Response(JSON.stringify({ received: true }), { + status: 202 + }); + } + + } catch (err) { + console.error('Error processing webhook:', err); + return new Response( + 'Webhook error: ' + (err as Error).message, + { status: 400 } + ); + } +} diff --git a/packages/web/src/lib/environment.ts b/packages/web/src/lib/environment.ts index c8195cc6..730dc885 100644 --- a/packages/web/src/lib/environment.ts +++ b/packages/web/src/lib/environment.ts @@ -16,3 +16,4 @@ export const AUTH_URL = getEnv(process.env.AUTH_URL)!; export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY); export const STRIPE_PRODUCT_ID = getEnv(process.env.STRIPE_PRODUCT_ID); +export const STRIPE_WEBHOOK_SECRET = getEnv(process.env.STRIPE_WEBHOOK_SECRET); From 5897dfdb501c540db05ac9f1c3255c21feef86b4 Mon Sep 17 00:00:00 2001 From: msukkari Date: Fri, 14 Feb 2025 14:25:50 -0800 Subject: [PATCH 2/3] add inactive org repo cleanup logic --- packages/backend/src/repoManager.ts | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index f576d2dd..40814bf3 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -1,7 +1,7 @@ import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createLogger } from "./logger.js"; -import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus } from "@sourcebot/db"; +import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { AppContext, Settings } from "./types.js"; import { captureEvent } from "./posthog.js"; @@ -106,8 +106,33 @@ export class RepoManager implements IRepoManager { } }); - for (const repo of reposWithNoConnections) { - this.logger.info(`Garbage collecting repo with no connections: ${repo.id}`); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const inactiveOrgs = await this.db.org.findMany({ + where: { + stripeSubscriptionStatus: StripeSubscriptionStatus.INACTIVE, + stripeLastUpdatedAt: { + lt: sevenDaysAgo + } + } + }); + + const inactiveOrgIds = inactiveOrgs.map(org => org.id); + + const inactiveOrgRepos = await this.db.repo.findMany({ + where: { + orgId: { + in: inactiveOrgIds + } + } + }); + + if (inactiveOrgIds.length > 0 && inactiveOrgRepos.length > 0) { + console.log(`Garbage collecting ${inactiveOrgs.length} inactive orgs: ${inactiveOrgIds.join(', ')}`); + } + + const reposToDelete = [...reposWithNoConnections, ...inactiveOrgRepos]; + for (const repo of reposToDelete) { + this.logger.info(`Garbage collecting repo: ${repo.id}`); // delete cloned repo const repoPath = getRepoPath(repo, this.ctx); @@ -129,7 +154,7 @@ export class RepoManager implements IRepoManager { await this.db.repo.deleteMany({ where: { id: { - in: reposWithNoConnections.map(repo => repo.id) + in: reposToDelete.map(repo => repo.id) } } }); From ec684e4912edd439f3b7b3aad623e4f967b9efd6 Mon Sep 17 00:00:00 2001 From: msukkari Date: Sat, 15 Feb 2025 09:56:46 -0800 Subject: [PATCH 3/3] mark reactivated org connections for sync --- packages/web/src/app/api/(server)/stripe/route.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 630a443c..5a2b0cad 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -4,7 +4,7 @@ import Stripe from 'stripe'; import { prisma } from '@/prisma'; import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment'; import { getStripe } from '@/lib/stripe'; -import { StripeSubscriptionStatus } from '@sourcebot/db'; +import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; export async function POST(req: NextRequest) { const body = await req.text(); const signature = headers().get('stripe-signature'); @@ -74,6 +74,16 @@ export async function POST(req: NextRequest) { }); console.log(`Org ${org.id} subscription status updated to ACTIVE`); + // mark all of this org's connections for sync, since their repos may have been previously garbage collected + await prisma.connection.updateMany({ + where: { + orgId: org.id + }, + data: { + syncStatus: ConnectionSyncStatus.SYNC_NEEDED + } + }); + return new Response(JSON.stringify({ received: true }), { status: 200 });