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) {
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(Subscription)
|
||||
.where(eq(Subscription.userId, userId));
|
||||
const [subscription] = await db.select().from(Subscription).where(eq(Subscription.userId, userId));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -51,7 +48,7 @@ export async function updateSubscription(
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trialEnd: Date;
|
||||
quantity: number;
|
||||
}>
|
||||
}>,
|
||||
) {
|
||||
const [subscription] = await db
|
||||
.update(Subscription)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { stripe } from "../stripe/client";
|
||||
import { getOrganisationsByUserId, getOrganisationMembers } from "../db/queries/organisations";
|
||||
import { getOrganisationMembers, getOrganisationsByUserId } from "../db/queries/organisations";
|
||||
import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions";
|
||||
import { getUserById } from "../db/queries/users";
|
||||
import { stripe } from "../stripe/client";
|
||||
|
||||
export async function updateSeatCount(userId: number) {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getOrganisationMemberRole,
|
||||
getUserById,
|
||||
} from "../../db/queries";
|
||||
import { updateSeatCount } from "../../lib/seats";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
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);
|
||||
|
||||
// update seat count if the requester is the owner
|
||||
if (requesterMember.role === "owner") {
|
||||
await updateSeatCount(req.userId);
|
||||
}
|
||||
|
||||
return Response.json(member);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OrgRemoveMemberRequestSchema } from "@sprint/shared";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
|
||||
import { updateSeatCount } from "../../lib/seats";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function organisationRemoveMember(req: AuthedRequest) {
|
||||
@@ -34,5 +35,10 @@ export default async function organisationRemoveMember(req: AuthedRequest) {
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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