mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-09-10 09:56:48 -05:00
feat: implement stripe checkout
This commit is contained in:
@@ -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
|
||||
|
||||
60
server/api/account/checkout.post.ts
Normal file
60
server/api/account/checkout.post.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
42
server/api/account/tiers.get.ts
Normal file
42
server/api/account/tiers.get.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user