diff --git a/apps/dashboard/app/(app)/settings/billing/client.tsx b/apps/dashboard/app/(app)/settings/billing/client.tsx index 6543c9eed52..2e62f99442a 100644 --- a/apps/dashboard/app/(app)/settings/billing/client.tsx +++ b/apps/dashboard/app/(app)/settings/billing/client.tsx @@ -25,6 +25,7 @@ type Props = { id: string; status: Stripe.Subscription.Status; trialUntil?: number; + cancelAt?: number; }; currentProductId?: string; products: Array<{ @@ -72,7 +73,9 @@ export const Client: React.FC = (props) => { const allowUpdate = props.subscription && ["active", "trialing"].includes(props.subscription.status); const allowCancel = - props.subscription && ["active", "trialing"].includes(props.subscription.status); + props.subscription && + ["active", "trialing"].includes(props.subscription.status) && + !props.subscription.cancelAt; const isFreeTier = !props.subscription || !["active", "trialing"].includes(props.subscription.status); const selectedProductIndex = allowUpdate @@ -88,6 +91,7 @@ export const Client: React.FC = (props) => { /> ) : null} + {isFreeTier ? : null} @@ -205,7 +209,7 @@ export const Client: React.FC = (props) => {
cancelSubscription.mutateAsync()} trigger={(onClick) => ( +
+ + ); +}; const SusbcriptionStatus: React.FC<{ status: Stripe.Subscription.Status; trialUntil?: number }> = ( props, ) => { diff --git a/apps/dashboard/app/(app)/settings/billing/page.tsx b/apps/dashboard/app/(app)/settings/billing/page.tsx index b2347dae4c5..090c0a238a5 100644 --- a/apps/dashboard/app/(app)/settings/billing/page.tsx +++ b/apps/dashboard/app/(app)/settings/billing/page.tsx @@ -174,6 +174,7 @@ export default async function BillingPage() { id: subscription.id, status: subscription.status, trialUntil: subscription.trial_end ? subscription.trial_end * 1000 : undefined, + cancelAt: subscription.cancel_at ? subscription.cancel_at * 1000 : undefined, } : undefined } diff --git a/apps/dashboard/app/api/webhooks/stripe/route.ts b/apps/dashboard/app/api/webhooks/stripe/route.ts new file mode 100644 index 00000000000..e14a270ecb7 --- /dev/null +++ b/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -0,0 +1,92 @@ +import { insertAuditLogs } from "@/lib/audit"; +import { type Quotas, db, eq, schema } from "@/lib/db"; +import { stripeEnv } from "@/lib/env"; +import Stripe from "stripe"; + +export const runtime = "nodejs"; + +export const POST = async (req: Request): Promise => { + const signature = req.headers.get("stripe-signature"); + if (!signature) { + throw new Error("Signature missing"); + } + + const e = stripeEnv(); + + if (!e) { + throw new Error("stripe env variables are not set up"); + } + + const stripe = new Stripe(stripeEnv()!.STRIPE_SECRET_KEY, { + apiVersion: "2023-10-16", + typescript: true, + }); + + const event = stripe.webhooks.constructEvent( + await req.text(), + signature, + e.STRIPE_WEBHOOK_SECRET, + ); + + switch (event.type) { + case "customer.subscription.deleted": { + const sub = event.data.object as Stripe.Subscription; + + const ws = await db.query.workspaces.findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.stripeSubscriptionId, sub.id), isNull(table.deletedAtM)), + with: { + auditLogBuckets: { + where: (table, { eq }) => eq(table.name, "unkey_mutations"), + }, + }, + }); + if (!ws) { + throw new Error("workspace does not exist"); + } + await db + .update(schema.workspaces) + .set({ + stripeSubscriptionId: null, + }) + .where(eq(schema.workspaces.id, ws.id)); + + const freeTierQuotas: Omit = { + requestsPerMonth: 150_000, + logsRetentionDays: 7, + auditLogsRetentionDays: 30, + team: false, + }; + await db + .insert(schema.quotas) + .values({ + workspaceId: ws.id, + ...freeTierQuotas, + }) + .onDuplicateKeyUpdate({ + set: freeTierQuotas, + }); + + await insertAuditLogs(db, ws.auditLogBuckets[0].id, { + workspaceId: ws.id, + actor: { + type: "system", + id: "stripe", + }, + event: "workspace.update", + description: "Cancelled subscription.", + resources: [], + context: { + location: "", + userAgent: undefined, + }, + }); + break; + } + + default: + console.error("Incoming stripe event, that should not be received", event.type); + break; + } + return new Response("OK"); +}; diff --git a/apps/dashboard/lib/audit.ts b/apps/dashboard/lib/audit.ts index 96c0b906638..9edc4074fdd 100644 --- a/apps/dashboard/lib/audit.ts +++ b/apps/dashboard/lib/audit.ts @@ -57,7 +57,7 @@ export type UnkeyAuditLog = { event: z.infer; description: string; actor: { - type: "user" | "key"; + type: "user" | "key" | "system"; name?: string; id: string; meta?: Record; diff --git a/apps/dashboard/lib/env.ts b/apps/dashboard/lib/env.ts index 81f1395cfbc..a8a7e896164 100644 --- a/apps/dashboard/lib/env.ts +++ b/apps/dashboard/lib/env.ts @@ -63,6 +63,7 @@ const stripeSchema = z.object({ STRIPE_SECRET_KEY: z.string(), // The product ids, comma separated, from lowest to highest pro plan STRIPE_PRODUCT_IDS_PRO: z.string().transform((s) => s.split(",")), + STRIPE_WEBHOOK_SECRET: z.string(), }); const stripeParsed = stripeSchema.safeParse(process.env); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 57f1863bab7..e74c12583db 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -53,6 +53,7 @@ import { updatePermission } from "./rbac/updatePermission"; import { updateRole } from "./rbac/updateRole"; import { cancelSubscription } from "./stripe/cancelSubscription"; import { createSubscription } from "./stripe/createSubscription"; +import { uncancelSubscription } from "./stripe/uncancelSubscription"; import { updateSubscription } from "./stripe/updateSubscription"; import { vercelRouter } from "./vercel"; import { changeWorkspaceName } from "./workspace/changeName"; @@ -105,6 +106,7 @@ export const router = t.router({ createSubscription, updateSubscription, cancelSubscription, + uncancelSubscription, }), vercel: vercelRouter, plain: t.router({ diff --git a/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts index 83e8bcc77a4..680c95ebdb9 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts @@ -1,5 +1,3 @@ -import { insertAuditLogs } from "@/lib/audit"; -import { type Quotas, db, eq, schema } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; import { TRPCError } from "@trpc/server"; import Stripe from "stripe"; @@ -28,45 +26,7 @@ export const cancelSubscription = t.procedure.use(auth).mutation(async ({ ctx }) }); } - await stripe.subscriptions.cancel(ctx.workspace.stripeSubscriptionId, { - prorate: true, - }); - - await db - .update(schema.workspaces) - .set({ - stripeSubscriptionId: null, - }) - .where(eq(schema.workspaces.id, ctx.workspace.id)); - - const freeTierQuotas: Omit = { - requestsPerMonth: 150_000, - logsRetentionDays: 7, - auditLogsRetentionDays: 30, - team: false, - }; - await db - .insert(schema.quotas) - .values({ - workspaceId: ctx.workspace.id, - ...freeTierQuotas, - }) - .onDuplicateKeyUpdate({ - set: freeTierQuotas, - }); - - await insertAuditLogs(db, ctx.workspace.auditLogBucket.id, { - workspaceId: ctx.workspace.id, - actor: { - type: "user", - id: ctx.user.id, - }, - event: "workspace.update", - description: "Cancelled subscription.", - resources: [], - context: { - location: ctx.audit.location, - userAgent: ctx.audit.userAgent, - }, + await stripe.subscriptions.update(ctx.workspace.stripeSubscriptionId, { + cancel_at_period_end: true, }); }); diff --git a/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts new file mode 100644 index 00000000000..b7b00d8c4fb --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts @@ -0,0 +1,32 @@ +import { stripeEnv } from "@/lib/env"; +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { auth, t } from "../../trpc"; +export const uncancelSubscription = t.procedure.use(auth).mutation(async ({ ctx }) => { + const e = stripeEnv(); + if (!e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Stripe is not set up" }); + } + + const stripe = new Stripe(e.STRIPE_SECRET_KEY, { + apiVersion: "2023-10-16", + typescript: true, + }); + + if (!ctx.workspace.stripeCustomerId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace doesn't have a stripe customer id.", + }); + } + if (!ctx.workspace.stripeSubscriptionId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace doesn't have a stripe subscrption id.", + }); + } + + await stripe.subscriptions.update(ctx.workspace.stripeSubscriptionId, { + cancel_at_period_end: false, + }); +}); diff --git a/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts index 24ff5f2463b..cdccda64520 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts @@ -78,6 +78,12 @@ export const updateSubscription = t.procedure proration_behavior: "always_invoice", }); + if (sub.cancel_at) { + await stripe.subscriptions.update(sub.id, { + cancel_at_period_end: false, + }); + } + await db .update(schema.workspaces) .set({ diff --git a/go/api/openapi.json b/go/api/openapi.json index e9be6233fb3..f68615ad338 100644 --- a/go/api/openapi.json +++ b/go/api/openapi.json @@ -50,13 +50,7 @@ } }, "type": "object", - "required": [ - "requestId", - "detail", - "status", - "title", - "type" - ] + "required": ["requestId", "detail", "status", "title", "type"] }, "NotFoundError": { "$ref": "#/components/schemas/BaseError" @@ -86,9 +80,7 @@ "type": "array" } }, - "required": [ - "errors" - ] + "required": ["errors"] } ] }, @@ -112,10 +104,7 @@ } }, "type": "object", - "required": [ - "message", - "location" - ] + "required": ["message", "location"] }, "V2LivenessResponseBody": { "additionalProperties": false, @@ -126,9 +115,7 @@ "type": "string" } }, - "required": [ - "message" - ], + "required": ["message"], "type": "object" }, "V2RatelimitSetOverrideRequestBody": { @@ -164,11 +151,7 @@ "minimum": 0 } }, - "required": [ - "identifier", - "limit", - "duration" - ], + "required": ["identifier", "limit", "duration"], "type": "object" }, "V2RatelimitSetOverrideResponseBody": { @@ -179,9 +162,7 @@ "type": "string" } }, - "required": [ - "overrideId" - ], + "required": ["overrideId"], "type": "object" }, "V2RatelimitGetOverrideRequestBody": { @@ -207,9 +188,7 @@ "maxLength": 255 } }, - "required": [ - "identifier" - ], + "required": ["identifier"], "type": "object" }, "V2RatelimitGetOverrideResponseBody": { @@ -246,13 +225,7 @@ "minimum": 0 } }, - "required": [ - "namespaceId", - "overrideId", - "duration", - "identifier", - "limit" - ], + "required": ["namespaceId", "overrideId", "duration", "identifier", "limit"], "type": "object" }, "V2RatelimitLimitRequestBody": { @@ -290,12 +263,7 @@ "minimum": 1 } }, - "required": [ - "namespace", - "identifier", - "limit", - "duration" - ], + "required": ["namespace", "identifier", "limit", "duration"], "type": "object" }, "V2RatelimitLimitResponseBody": { @@ -325,12 +293,7 @@ "type": "string" } }, - "required": [ - "limit", - "remaining", - "reset", - "success" - ], + "required": ["limit", "remaining", "reset", "success"], "type": "object" }, "V2RatelimitDeleteOverrideRequestBody": { @@ -356,9 +319,7 @@ "maxLength": 255 } }, - "required": [ - "identifier" - ], + "required": ["identifier"], "type": "object" }, "V2RatelimitDeleteOverrideResponseBody": { @@ -443,9 +404,7 @@ "description": "Error" } }, - "tags": [ - "ratelimit" - ] + "tags": ["ratelimit"] } }, "/v2/ratelimit.setOverride": { @@ -523,16 +482,12 @@ "description": "Error" } }, - "tags": [ - "ratelimit" - ] + "tags": ["ratelimit"] } }, "/v2/ratelimit.getOverride": { "post": { - "tags": [ - "ratelimit" - ], + "tags": ["ratelimit"], "operationId": "v2.ratelimit.getOverride", "requestBody": { "content": { @@ -610,9 +565,7 @@ }, "/v2/ratelimit.deleteOverride": { "post": { - "tags": [ - "ratelimit" - ], + "tags": ["ratelimit"], "operationId": "v2.ratelimit.deleteOverride", "requestBody": { "content": { @@ -725,9 +678,7 @@ } }, "summary": "Liveness check", - "tags": [ - "liveness" - ] + "tags": ["liveness"] } } } diff --git a/tools/migrate/migrate_subscription.ts b/tools/migrate/migrate_subscription.ts index 098ed770f2d..f857ba235d5 100644 --- a/tools/migrate/migrate_subscription.ts +++ b/tools/migrate/migrate_subscription.ts @@ -14,7 +14,7 @@ async function main() { await conn.ping(); const db = mysqlDrizzle(conn, { schema, mode: "default" }); - const workspaceId = "ws_39g5eLLQTX8bVdbsGK9Dke"; + const workspaceId = "ws_wB4SmWrYkhSbWE2rH61S6gMseWw"; const productId = "prod_Rtu3rLbjwprz7p"; const workspace = await db.query.workspaces.findFirst({