feat: add first version of stripe webhooks

This commit is contained in:
mrjvs
2026-08-08 23:11:46 +02:00
parent 728c057efd
commit a75bbf0b4b
2 changed files with 296 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import { Stripe } from "stripe";
export default defineEventHandler(async (event): Promise<{ success: boolean, message?: string }> => {
const config = useRuntimeConfig(event);
const stripe = useStripe(event);
if (!stripe || !config.stripeWebhookSecret) throw new Error('Stripe not configured on this instance');
let webhookEvent: Stripe.Event;
try {
const signatureHeader = getHeader(event, 'stripe-signature');
if (!signatureHeader) throw new Error('No signature header on webhook event');
const rawBody = await readRawBody(event) ?? '';
if (!rawBody) throw new Error('No body on webhook event');
webhookEvent = stripe.webhooks.constructEvent(rawBody, signatureHeader, config.stripeWebhookSecret);
} catch (error) {
console.error(error);
setResponseStatus(event, 400);
return {
success: false,
message: 'Invalid webhook',
}
}
await handleStripeEvent(stripe, webhookEvent);
return {
success: true,
}
});

View File

@@ -0,0 +1,267 @@
import { Stripe } from "stripe";
async function sendEmailToCustomer(customer: Stripe.Customer, ops: { pid: number, title: String, body: string }): Promise<void> {
try {
await mailer.sendMail({
to: customer.email,
subject: ops.title,
text: ops.body,
});
} catch (error) {
console.error(`Error sending email | ${customer.id}, ${ops.pid}, ${customer.email} |`, error);
}
}
async function sendToNotificationEmails(notificationEmails: string[], ops: { title: String, body: string }): Promise<void> {
for (const email of notificationEmails) {
// * Send notification emails for new sub
try {
await mailer.sendMail({
to: email,
subject: `[Pretendo] - ${ops.title}`,
text: ops.body,
});
} catch (error) {
console.error(`Error sending notification email | ${email} |`, error);
}
}
}
export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: Stripe.Event, notificationEmails: string[]) {
if (webhook.type === 'customer.subscription.updated' || webhook.type === 'customer.subscription.deleted') {
const subscription = webhook.data.object;
const subscriptionItem = subscription.items.data[0];
if (!subscriptionItem) throw new Error("No subscription item subscription");
const product = await stripe.products.retrieve(subscriptionItem.plan.product as string);
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) {
// Customer doesn't exist, no need to do anything
return;
}
if (!customer?.metadata?.pnid_pid) {
// No PNID PID linked to customer
if (subscription.status !== 'canceled' && subscription.status !== 'unpaid') {
// Abort and refund!
console.error(`Stripe user ${customer.id} has no PNID linked! Refunding order`);
try {
await stripe.subscriptions.cancel(subscription.id);
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice as string);
const intent = invoice.payments?.data[0]?.payment.payment_intent;
if (!intent) throw new Error("No intent found")
await stripe.refunds.create({
payment_intent: intent as string
});
} catch (error) {
console.error(`Error refunding subscription | ${customer.id}, ${subscription.id}`, error);
}
await sendEmailToCustomer(customer, {
pid: 0,
title: 'Pretendo Network Subscription Failed - No Linked PNID',
body: `Your recent subscription to Pretendo Network has failed.\nThis is due to no PNID PID being linked to the Stripe customer account used. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}`
})
} else {
console.error(`Stripe user ${customer.id} has no PNID linked!`);
}
return;
}
const pid = Number(customer.metadata.pnid_pid);
const pnid = await database.PNID.findOne({ pid });
if (!pnid) {
// PNID does not exist
if (subscription.status !== 'canceled' && subscription.status !== 'unpaid') {
// Abort and refund!
console.error(`PNID PID ${pid} does not exist! Found on Stripe user ${customer.id}! Refunding order`);
try {
await stripe.subscriptions.cancel(subscription.id);
if (subscription.latest_invoice) {
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice as string);
const intent = invoice.payments?.data[0]?.payment.payment_intent;
if (!intent) throw new Error("No intent found")
await stripe.refunds.create({
payment_intent: intent as string
});
}
} catch (error) {
console.error(`Error refunding subscription | ${customer.id}, ${subscription.id} |`, error);
}
await sendEmailToCustomer(customer, {
pid: 0,
title: 'Pretendo Network Subscription Failed - PNID Not Found',
body: `Your recent subscription to Pretendo Network has failed.\nThis is due to the provided PNID not being found. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}\nPNID PID: ${pid}`
})
} else {
console.error(`PNID PID ${pid} does not exist! Found on Stripe user ${customer.id}!`);
}
return;
}
const latestWebhookTimestamp = pnid.get('connections.stripe.latest_webhook_timestamp');
if (latestWebhookTimestamp && latestWebhookTimestamp >= webhook.created) {
// Do nothing, this webhook is older than the latest seen
return;
}
const currentSubscriptionId = pnid.get('connections.stripe.subscription_id');
const discordId = pnid.get('connections.discord.id');
if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) {
// Canceling old subscription, do nothing but update webhook date and remove Discord roles
if (product.metadata.beta === 'true') {
util.removeDiscordMemberTesterRole(discordId).catch((error) => {
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
}
util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
const updateData = {
'connections.stripe.latest_webhook_timestamp': webhook.created
};
await database.PNID.updateOne({
pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: webhook.created
}
}, { $set: updateData }).exec();
return;
}
const updateData = {
'connections.stripe.subscription_id': subscription.status === 'active' ? subscription.id : null,
'connections.stripe.price_id': subscription.status === 'active' ? subscriptionItem.plan.id : null,
'connections.stripe.tier_level': subscription.status === 'active' ? Number(product.metadata.tier_level || 0) : 0,
'connections.stripe.tier_name': subscription.status === 'active' ? product.name : null,
'connections.stripe.latest_webhook_timestamp': webhook.created
};
if (product.metadata.beta === 'true') {
if (subscription.status === 'active') {
if (pnid.access_level < 2) { // * Only change access level if not staff member
updateData.access_level = 1;
updateData.server_access_level = 'test';
}
util.assignDiscordMemberTesterRole(discordId).catch((error) => {
console.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
} else {
// * Assume any status other than active means payment has not been fulfilled
// * Once the payment goes through, status should update to active
if (pnid.access_level < 2) { // * Only change access level if not staff member
updateData.access_level = 0;
updateData.server_access_level = 'prod';
}
util.removeDiscordMemberTesterRole(discordId).catch((error) => {
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
}
}
await database.PNID.updateOne({
pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: webhook.created
}
}, { $set: updateData }).exec();
if (subscription.status === 'active') {
// Get all the customers active subscriptions
const { data: activeSubscriptions } = await stripe.subscriptions.list({
limit: 100,
status: 'active',
customer: customer.id
});
// Order subscriptions by creation time and remove the latest one
const orderedActiveSubscriptions = activeSubscriptions.sort((a, b) => b.created - a.created);
const pastSubscriptions = orderedActiveSubscriptions.slice(1);
// Remove any old past subscriptions that might still be hanging around
for (const pastSubscription of pastSubscriptions) {
try {
await stripe.subscriptions.cancel(pastSubscription.id);
} catch (error) {
console.error(`Error canceling old user subscription | ${customer.id}, ${pid}, ${pastSubscription.id} |`, error);
}
}
await sendEmailToCustomer(customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - Active`,
body: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!`
})
util.assignDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
await sendToNotificationEmails(notificationEmails, {
title: `New ${product.name} subscription`,
body: `${pnid.get('username')} just became a ${product.name} tier subscriber`,
})
} else if (subscription.status === 'canceled') {
await sendEmailToCustomer(customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - Canceled`,
body: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! `
})
util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
await sendToNotificationEmails(notificationEmails, {
title: `Canceled ${product.name} subscription`,
body: `${pnid.get('username')} just canceled their ${product.name} tier subscription`,
})
} else if (subscription.status === 'unpaid') {
await sendEmailToCustomer(customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - Unpaid`,
body: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! `
})
util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
await sendToNotificationEmails(notificationEmails, {
title: `Removed ${product.name} subscription`,
body: `${pnid.get('username')}'s ${product.name} tier subscription has been canceled due to non payment`,
})
} else {
await sendEmailToCustomer(customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - ${subscription.status}`,
body: `Your subscription for the ${product.name} tier has changed status to ${subscription.status}. This is usually caused by payment failure. Your account has been reverted back to default until payment resumes. If you believe this to be an error, please reach out for support on our Discord server, and we thank you for your previous support!`
})
util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
await sendToNotificationEmails(notificationEmails, {
title: `Removed ${product.name} subscription`,
body: `${pnid.username}'s ${product.name} tier subscription status has been changed to ${subscription.status}`,
})
}
}
}