From 5dd40d61c354519f15a3ae64b25ce66a4bac8cba Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 9 Aug 2026 19:30:57 +0200 Subject: [PATCH] feat: implement stripe checkout --- README.md | 4 +- server/api/account/checkout.post.ts | 60 +++++++++++++++++++++++++++++ server/api/account/tiers.get.ts | 42 ++++++++++++++++++++ server/utils/getStripeDonations.ts | 30 +++++---------- server/utils/papr.ts | 1 + shared/api-types.ts | 21 ++++++++++ 6 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 server/api/account/checkout.post.ts create mode 100644 server/api/account/tiers.get.ts diff --git a/README.md b/README.md index 6f12591..c315848 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/api/account/checkout.post.ts b/server/api/account/checkout.post.ts new file mode 100644 index 0000000..a22c56e --- /dev/null +++ b/server/api/account/checkout.post.ts @@ -0,0 +1,60 @@ +import { usePapr } from "~~/server/utils/papr"; +import { ApiAccountCheckoutLink, CheckoutSchema } from "~~/shared/api-types"; + +export default defineEventHandler(async (event): Promise => { + 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, + } +}); diff --git a/server/api/account/tiers.get.ts b/server/api/account/tiers.get.ts new file mode 100644 index 0000000..b93a8dc --- /dev/null +++ b/server/api/account/tiers.get.ts @@ -0,0 +1,42 @@ +import { ApiAccountTiers, TierItem } from "~~/shared/api-types"; + +export default defineEventHandler(async (event): Promise => { + 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, + } +}); diff --git a/server/utils/getStripeDonations.ts b/server/utils/getStripeDonations.ts index 10167d8..5537e10 100644 --- a/server/utils/getStripeDonations.ts +++ b/server/utils/getStripeDonations.ts @@ -14,27 +14,15 @@ async function getStripeDonationData(stripe: Stripe): Promise { + const plan = sub.items.data[0]?.plan; + if (!plan) return; + donationData.donatorCount += 1; + donationData.totalDonationsCents += plan?.amount ?? 0; + }); return donationData; } diff --git a/server/utils/papr.ts b/server/utils/papr.ts index 15aea13..9e7fcf0 100644 --- a/server/utils/papr.ts +++ b/server/utils/papr.ts @@ -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(), diff --git a/shared/api-types.ts b/shared/api-types.ts index 1a3544e..aacff2b 100644 --- a/shared/api-types.ts +++ b/shared/api-types.ts @@ -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()