feat: implement stripe checkout

This commit is contained in:
mrjvs
2026-08-09 19:30:57 +02:00
parent 87d2b6357b
commit 5dd40d61c3
6 changed files with 135 additions and 23 deletions

View File

@@ -36,8 +36,8 @@ And the tasks left on the backend:
- [x] Password forgot flow
- [x] Stripe webhook emails
- [x] Stripe webhook database updates
- [ ] Discord integration (add/remove roles on link and subscription changes)
- [ ] Creation of stripe subscriptions
- [x] Discord integration (add/remove roles on link and subscription changes)
- [x] Creation of stripe subscriptions
- [x] Account editing (mii saving, server environment changes)
- [x] Delete account
- [x] Discourse SSO

View File

@@ -0,0 +1,60 @@
import { usePapr } from "~~/server/utils/papr";
import { ApiAccountCheckoutLink, CheckoutSchema } from "~~/shared/api-types";
export default defineEventHandler(async (event): Promise<ApiAccountCheckoutLink> => {
const auth = enforceLoggedIn(event);
const papr = await usePapr(event);
const stripe = useStripe(event);
const config = useRuntimeConfig(event);
if (!stripe || !papr) throw createError({
status: 400,
message: 'Stripe integration not configured',
})
const body = await readZodBody(event, CheckoutSchema);
const { data: searchResults } = await stripe.customers.search({
query: `metadata['pnid_pid']:'${auth.pid}'`
});
let customer = searchResults[0];
if (!customer) {
customer = await stripe.customers.create({
email: auth.email,
metadata: {
pnid_pid: auth.pid
}
});
}
// ensure PNID always has latest customer ID
if (auth.accessLevel >= 2) {
throw createError({
status: 400,
message: 'Staff members do not need to purchase tiers',
})
}
await papr.Pnid.updateOne({ pid: auth.pid }, {
$set: {
'connections.stripe.customer_id': customer.id,
'connections.stripe.latest_webhook_timestamp': 0
}
})
const priceId = body.priceId;
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1
}
],
customer: customer.id,
mode: 'subscription',
success_url: new URL('/account?upgrade_success=true', config.public.baseUrl).toString(),
cancel_url: new URL('/account?upgrade_success=false', config.public.baseUrl).toString(),
});
if (!session.url) throw new Error("Failed to create session");
return {
url: session.url,
}
});

View File

@@ -0,0 +1,42 @@
import { ApiAccountTiers, TierItem } from "~~/shared/api-types";
export default defineEventHandler(async (event): Promise<ApiAccountTiers> => {
enforceLoggedIn(event);
const stripe = useStripe(event);
if (!stripe) throw createError({
status: 400,
message: 'Stripe integration not configured',
})
const prices = await stripe.prices.list().autoPagingToArray({ limit: 10 });
const products = await stripe.products.list().autoPagingToArray({ limit: 10 });
const tiers: TierItem[] = [];
for (let product of products) {
if (!product.active) continue;
const price = prices.find(price => price.id === product.default_price);
if (!price) continue;
const tierLevel = Number(product.metadata.tier_level ?? "0");
const hasDiscordReadPerk = product.metadata.discord_read === 'true';
const hasBetaAccessPerk = product.metadata.beta === 'true';
tiers.push({
priceId: price.id,
tierLevel,
priceCents: price.unit_amount ?? 0,
thumbnailUrl: product.images[0] ?? null,
name: product.name,
description: product.description,
perks: {
discordRead: hasDiscordReadPerk,
beta: hasBetaAccessPerk,
},
})
}
return {
tiers,
}
});

View File

@@ -14,27 +14,15 @@ async function getStripeDonationData(stripe: Stripe): Promise<StripeDonationResp
totalDonationsCents: 0,
};
let hasMore: boolean;
let lastId: string | null = null;
do {
const { data: activeSubscriptions, has_more } = await stripe.subscriptions.list({
limit: 100,
status: 'active',
starting_after: lastId ?? undefined,
});
for (const subscription of activeSubscriptions) {
const plan = subscription.items.data[0]?.plan;
if (!plan) continue;
donationData.donatorCount += 1;
donationData.totalDonationsCents += plan?.amount ?? 0;
lastId = subscription.id;
}
hasMore = has_more;
} while (hasMore);
await stripe.subscriptions.list({
limit: 100,
status: 'active',
}).autoPagingEach((sub) => {
const plan = sub.items.data[0]?.plan;
if (!plan) return;
donationData.donatorCount += 1;
donationData.totalDonationsCents += plan?.amount ?? 0;
});
return donationData;
}

View File

@@ -12,6 +12,7 @@ const pnidSchema = schema({
server_access_level: types.enum(['prod', 'test', 'dev'] as const, { required: true }),
connections: types.object({
stripe: types.object({
customer_id: types.string(),
subscription_id: types.string(),
price_id: types.string(),
tier_level: types.number(),

View File

@@ -37,6 +37,27 @@ export type ApiAccountDiscordLink = {
url: string
};
export type ApiAccountCheckoutLink = {
url: string
};
export type TierItem = {
priceId: string;
tierLevel: number;
priceCents: number;
thumbnailUrl: string | null;
name: string;
description: string | null;
perks: {
discordRead: boolean;
beta: boolean;
}
}
export type ApiAccountTiers = {
tiers: TierItem[];
}
export const LoginSchema = z.object({
username: z.string(),
password: z.string()