mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
subscription routes
This commit is contained in:
@@ -26,10 +26,7 @@ export async function createSubscription(data: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSubscriptionByUserId(userId: number) {
|
export async function getSubscriptionByUserId(userId: number) {
|
||||||
const [subscription] = await db
|
const [subscription] = await db.select().from(Subscription).where(eq(Subscription.userId, userId));
|
||||||
.select()
|
|
||||||
.from(Subscription)
|
|
||||||
.where(eq(Subscription.userId, userId));
|
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +48,7 @@ export async function updateSubscription(
|
|||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
trialEnd: Date;
|
trialEnd: Date;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}>
|
}>,
|
||||||
) {
|
) {
|
||||||
const [subscription] = await db
|
const [subscription] = await db
|
||||||
.update(Subscription)
|
.update(Subscription)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { stripe } from "../stripe/client";
|
import { getOrganisationMembers, getOrganisationsByUserId } from "../db/queries/organisations";
|
||||||
import { getOrganisationsByUserId, getOrganisationMembers } from "../db/queries/organisations";
|
|
||||||
import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions";
|
import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions";
|
||||||
import { getUserById } from "../db/queries/users";
|
import { getUserById } from "../db/queries/users";
|
||||||
|
import { stripe } from "../stripe/client";
|
||||||
|
|
||||||
export async function updateSeatCount(userId: number) {
|
export async function updateSeatCount(userId: number) {
|
||||||
const user = await getUserById(userId);
|
const user = await getUserById(userId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getOrganisationMemberRole,
|
getOrganisationMemberRole,
|
||||||
getUserById,
|
getUserById,
|
||||||
} from "../../db/queries";
|
} from "../../db/queries";
|
||||||
|
import { updateSeatCount } from "../../lib/seats";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function organisationAddMember(req: AuthedRequest) {
|
export default async function organisationAddMember(req: AuthedRequest) {
|
||||||
@@ -40,5 +41,10 @@ export default async function organisationAddMember(req: AuthedRequest) {
|
|||||||
|
|
||||||
const member = await createOrganisationMember(organisationId, userId, role);
|
const member = await createOrganisationMember(organisationId, userId, role);
|
||||||
|
|
||||||
|
// update seat count if the requester is the owner
|
||||||
|
if (requesterMember.role === "owner") {
|
||||||
|
await updateSeatCount(req.userId);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json(member);
|
return Response.json(member);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { OrgRemoveMemberRequestSchema } from "@sprint/shared";
|
import { OrgRemoveMemberRequestSchema } from "@sprint/shared";
|
||||||
import type { AuthedRequest } from "../../auth/middleware";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
|
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
|
||||||
|
import { updateSeatCount } from "../../lib/seats";
|
||||||
import { errorResponse, parseJsonBody } from "../../validation";
|
import { errorResponse, parseJsonBody } from "../../validation";
|
||||||
|
|
||||||
export default async function organisationRemoveMember(req: AuthedRequest) {
|
export default async function organisationRemoveMember(req: AuthedRequest) {
|
||||||
@@ -34,5 +35,10 @@ export default async function organisationRemoveMember(req: AuthedRequest) {
|
|||||||
|
|
||||||
await removeOrganisationMember(organisationId, userId);
|
await removeOrganisationMember(organisationId, userId);
|
||||||
|
|
||||||
|
// update seat count if the requester is the owner
|
||||||
|
if (requesterMember.role === "owner") {
|
||||||
|
await updateSeatCount(req.userId);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { BunRequest } from "bun";
|
||||||
|
import { withAuth, withCors, withCSRF } from "../../auth/middleware";
|
||||||
|
import { getOrganisationMembers, getOrganisationsByUserId } from "../../db/queries/organisations";
|
||||||
|
import { getUserById } from "../../db/queries/users";
|
||||||
|
import { STRIPE_PRICE_ANNUAL, STRIPE_PRICE_MONTHLY, stripe } from "../../stripe/client";
|
||||||
|
import { errorResponse } from "../../validation";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
|
||||||
|
|
||||||
|
async function handler(req: BunRequest) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { billingPeriod } = body as { billingPeriod: "monthly" | "annual" | undefined };
|
||||||
|
|
||||||
|
if (!billingPeriod) {
|
||||||
|
return errorResponse("missing required fields", "VALIDATION_ERROR", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse("user not found", "NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate seat quantity across all owned organisations
|
||||||
|
const organisations = await getOrganisationsByUserId(userId);
|
||||||
|
const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner");
|
||||||
|
|
||||||
|
let totalMembers = 0;
|
||||||
|
for (const org of ownedOrgs) {
|
||||||
|
const members = await getOrganisationMembers(org.Organisation.id);
|
||||||
|
totalMembers += members.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Math.max(1, totalMembers - 4);
|
||||||
|
const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY;
|
||||||
|
|
||||||
|
// build customer data - use username as email if no email field exists
|
||||||
|
const customerEmail = user.username.includes("@")
|
||||||
|
? user.username
|
||||||
|
: `${user.username}@localhost.local`;
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
customer_email: customerEmail,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: quantity,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: "subscription",
|
||||||
|
success_url: `${BASE_URL}/plans?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${BASE_URL}/plans?canceled=true`,
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
userId: userId.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: userId.toString(),
|
||||||
|
priceId: priceId,
|
||||||
|
quantity: quantity.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ url: session.url }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("checkout session error:", error);
|
||||||
|
return errorResponse("failed to create checkout session", "CHECKOUT_ERROR", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withCors(withAuth(withCSRF(handler)));
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { BunRequest } from "bun";
|
||||||
|
import { withAuth, withCors, withCSRF } from "../../auth/middleware";
|
||||||
|
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
|
||||||
|
import { stripe } from "../../stripe/client";
|
||||||
|
import { errorResponse } from "../../validation";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
|
||||||
|
|
||||||
|
async function handler(req: BunRequest) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const subscription = await getSubscriptionByUserId(userId);
|
||||||
|
if (!subscription?.stripeCustomerId) {
|
||||||
|
return errorResponse("no active subscription found", "NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: subscription.stripeCustomerId,
|
||||||
|
return_url: `${BASE_URL}/plans`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ url: portalSession.url }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("portal session error:", error);
|
||||||
|
return errorResponse("failed to create portal session", "PORTAL_ERROR", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withCors(withAuth(withCSRF(handler)));
|
||||||
25
packages/backend/src/routes/subscription/get.ts
Normal file
25
packages/backend/src/routes/subscription/get.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { BunRequest } from "bun";
|
||||||
|
import { withAuth, withCors } from "../../auth/middleware";
|
||||||
|
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
|
||||||
|
import { errorResponse } from "../../validation";
|
||||||
|
|
||||||
|
async function handler(req: BunRequest) {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const subscription = await getSubscriptionByUserId(userId);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ subscription }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("fetch subscription error:", error);
|
||||||
|
return errorResponse("failed to fetch subscription", "FETCH_ERROR", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withCors(withAuth(handler));
|
||||||
179
packages/backend/src/routes/subscription/webhook.ts
Normal file
179
packages/backend/src/routes/subscription/webhook.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { BunRequest } from "bun";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import {
|
||||||
|
createPayment,
|
||||||
|
createSubscription,
|
||||||
|
getSubscriptionByStripeId,
|
||||||
|
updateSubscription,
|
||||||
|
} from "../../db/queries/subscriptions";
|
||||||
|
import { updateUser } from "../../db/queries/users";
|
||||||
|
import { stripe } from "../../stripe/client";
|
||||||
|
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
|
||||||
|
|
||||||
|
export default async function webhook(req: BunRequest) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await req.text();
|
||||||
|
const signature = req.headers.get("stripe-signature");
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return new Response("Missing signature", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// use async version for Bun compatibility
|
||||||
|
event = await stripe.webhooks.constructEventAsync(payload, signature, webhookSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("webhook signature verification failed:", err);
|
||||||
|
return new Response("Invalid signature", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
|
if (session.mode !== "subscription" || !session.subscription) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = parseInt(session.metadata?.userId || "0", 10);
|
||||||
|
if (!userId) {
|
||||||
|
console.error("missing userId in session metadata");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch full subscription to get item id
|
||||||
|
const stripeSubscription = await stripe.subscriptions.retrieve(
|
||||||
|
session.subscription as string,
|
||||||
|
);
|
||||||
|
if (!stripeSubscription) {
|
||||||
|
console.error("failed to retrieve subscription:", session.subscription);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!stripeSubscription.items.data[0]) {
|
||||||
|
console.error("subscription has no items:", stripeSubscription.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSubscription({
|
||||||
|
userId,
|
||||||
|
stripeCustomerId: session.customer as string,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
stripeSubscriptionItemId: stripeSubscription.items.data[0].id,
|
||||||
|
stripePriceId: session.metadata?.priceId || "",
|
||||||
|
status: stripeSubscription.status,
|
||||||
|
quantity: parseInt(session.metadata?.quantity || "1", 10),
|
||||||
|
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
|
||||||
|
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
|
||||||
|
trialEnd: stripeSubscription.trial_end
|
||||||
|
? new Date(stripeSubscription.trial_end * 1000)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateUser(userId, { plan: "pro" });
|
||||||
|
|
||||||
|
console.log(`subscription activated for user ${userId}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.updated": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
if (!subscription) {
|
||||||
|
console.error("failed to retrieve subscription (customer.subscription.updated)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!subscription.items.data[0]) {
|
||||||
|
console.error("subscription has no items:", subscription.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSub = await getSubscriptionByStripeId(subscription.id);
|
||||||
|
if (!localSub) {
|
||||||
|
console.error("subscription not found:", subscription.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// safely convert timestamps to dates
|
||||||
|
const currentPeriodStart = (subscription as any).current_period_start
|
||||||
|
? new Date((subscription as any).current_period_start * 1000)
|
||||||
|
: undefined;
|
||||||
|
const currentPeriodEnd = (subscription as any).current_period_end
|
||||||
|
? new Date((subscription as any).current_period_end * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await updateSubscription(localSub.id, {
|
||||||
|
status: subscription.status,
|
||||||
|
...(currentPeriodStart && { currentPeriodStart }),
|
||||||
|
...(currentPeriodEnd && { currentPeriodEnd }),
|
||||||
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||||
|
quantity: subscription.items.data[0].quantity || 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`subscription updated: ${subscription.id}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
const localSub = await getSubscriptionByStripeId(subscription.id);
|
||||||
|
if (!localSub) break;
|
||||||
|
|
||||||
|
// delete subscription from database
|
||||||
|
const { deleteSubscription } = await import("../../db/queries/subscriptions");
|
||||||
|
await deleteSubscription(localSub.id);
|
||||||
|
await updateUser(localSub.userId, { plan: "free" });
|
||||||
|
|
||||||
|
console.log(`subscription deleted: ${subscription.id}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_succeeded": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (!(invoice as any).subscription) break;
|
||||||
|
|
||||||
|
const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string);
|
||||||
|
if (!localSub) break;
|
||||||
|
|
||||||
|
await createPayment({
|
||||||
|
subscriptionId: localSub.id,
|
||||||
|
stripePaymentIntentId: (invoice as any).payment_intent as string,
|
||||||
|
amount: invoice.amount_paid,
|
||||||
|
currency: invoice.currency,
|
||||||
|
status: "succeeded",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`payment recorded for subscription ${(invoice as any).subscription}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_failed": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (!(invoice as any).subscription) break;
|
||||||
|
|
||||||
|
const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string);
|
||||||
|
if (!localSub) break;
|
||||||
|
|
||||||
|
await updateSubscription(localSub.id, { status: "past_due" });
|
||||||
|
|
||||||
|
console.log(`payment failed for subscription ${(invoice as any).subscription}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ received: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("webhook processing error:", error);
|
||||||
|
return new Response("Webhook handler failed", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user