Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 56 additions & 12 deletions apps/dashboard/app/(app)/settings/billing/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Props = {
id: string;
status: Stripe.Subscription.Status;
trialUntil?: number;
cancelAt?: number;
};
currentProductId?: string;
products: Array<{
Expand Down Expand Up @@ -72,7 +73,9 @@ export const Client: React.FC<Props> = (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
Expand All @@ -88,6 +91,7 @@ export const Client: React.FC<Props> = (props) => {
/>
) : null}

<CancelAlert cancelAt={props.subscription?.cancelAt} />
{isFreeTier ? <FreeTierAlert /> : null}
<Usage current={props.usage.current} max={props.usage.max} />

Expand Down Expand Up @@ -124,11 +128,10 @@ export const Client: React.FC<Props> = (props) => {
{props.subscription ? (
<Confirm
title={`${i > selectedProductIndex ? "Upgrade" : "Downgrade"} to ${p.name}`}
description={`Changing to ${
p.name
} updates your request quota to ${formatNumber(
p.quota.requestsPerMonth,
)} per month immediately.`}
description={`Changing to ${p.name
} updates your request quota to ${formatNumber(
p.quota.requestsPerMonth,
)} per month immediately.`}
onConfirm={async () =>
updateSubscription.mutateAsync({
oldProductId: props.currentProductId!,
Expand All @@ -144,11 +147,10 @@ export const Client: React.FC<Props> = (props) => {
) : (
<Confirm
title={`Upgrade to ${p.name}`}
description={`Changing to ${
p.name
} updates your request quota to ${formatNumber(
p.quota.requestsPerMonth,
)} per month immediately.`}
description={`Changing to ${p.name
} updates your request quota to ${formatNumber(
p.quota.requestsPerMonth,
)} per month immediately.`}
onConfirm={() => createSubscription.mutateAsync({ productId: p.id })}
fineprint={
props.hasPreviousSubscriptions
Expand Down Expand Up @@ -205,7 +207,7 @@ export const Client: React.FC<Props> = (props) => {
<div className="w-full flex justify-end">
<Confirm
title="Cancel plan"
description="Canceling your plan will immediately downgrade your workspace to the free tier. Prorated credits are applied to your account and you can resume the subscription any time."
description="Canceling your plan will downgrade your workspace to the free tier at the end of the current period. You can resume your subscription until then."
onConfirm={() => cancelSubscription.mutateAsync()}
trigger={(onClick) => (
<Button variant="outline" color="danger" size="lg" onClick={onClick}>
Expand Down Expand Up @@ -237,6 +239,48 @@ const FreeTierAlert: React.FC = () => {
);
};

const CancelAlert: React.FC<{ cancelAt?: number; }> = (props) => {
const router = useRouter();
const uncancelSubscription = trpc.stripe.uncancelSubscription.useMutation({
onSuccess: () => {
router.refresh();
toast.info("Subscription resumed");
},
onError: (err) => {
toast.error(err.message);
},
});

if (!props.cancelAt) {
return null;
}
return (
<SettingCard
title="Cancellation scheduled"
description={
<p>
Your subscription ends in
<span className="text-accent-12"> {ms(props.cancelAt - Date.now(), { long: true })}</span>{" "}
on <span className="text-accent-12">{new Date(props.cancelAt).toLocaleDateString()}</span>
.
</p>
}
border="both"
className="border-warning-7 bg-warning-2"
>
<div className="flex w-full justify-end">
<Button
variant="primary"
loading={uncancelSubscription.isLoading}
disabled={uncancelSubscription.isLoading}
onClick={() => uncancelSubscription.mutate()}
>
Resubscribe
</Button>
</div>
</SettingCard>
);
};
const SusbcriptionStatus: React.FC<{ status: Stripe.Subscription.Status; trialUntil?: number }> = (
props,
) => {
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/app/(app)/settings/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
92 changes: 92 additions & 0 deletions apps/dashboard/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> => {
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,
);
Comment thread
chronark marked this conversation as resolved.

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<Quotas, "workspaceId"> = {
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");
};
2 changes: 1 addition & 1 deletion apps/dashboard/lib/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export type UnkeyAuditLog = {
event: z.infer<typeof unkeyAuditLogEvents>;
description: string;
actor: {
type: "user" | "key";
type: "user" | "key" | "system";
name?: string;
id: string;
meta?: Record<string, string | number | boolean | null>;
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,6 +106,7 @@ export const router = t.router({
createSubscription,
updateSubscription,
cancelSubscription,
uncancelSubscription,
}),
vercel: vercelRouter,
plain: t.router({
Expand Down
44 changes: 2 additions & 42 deletions apps/dashboard/lib/trpc/routers/stripe/cancelSubscription.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Quotas, "workspaceId"> = {
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,
});
});
32 changes: 32 additions & 0 deletions apps/dashboard/lib/trpc/routers/stripe/uncancelSubscription.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
6 changes: 6 additions & 0 deletions apps/dashboard/lib/trpc/routers/stripe/updateSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading