From 95820e441bca759e5fb3749caedef534d5f310c2 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 17 Apr 2025 14:41:48 +0200 Subject: [PATCH] feat: add stripe webhook handling --- nuxt.config.ts | 5 + package-lock.json | 56 +++-- package.json | 1 + src/server/routes/stripe/webhook.post.ts | 30 +++ src/server/tsconfig.json | 2 +- src/server/utils/stripe-event.ts | 285 +++++++++++++++++++++++ src/server/utils/stripe.ts | 18 ++ 7 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 src/server/routes/stripe/webhook.post.ts create mode 100644 src/server/utils/stripe-event.ts create mode 100644 src/server/utils/stripe.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 2d5787d..927fd10 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -3,6 +3,11 @@ export default defineNuxtConfig({ devtools: { enabled: true }, srcDir: './src', + runtimeConfig: { + stripeSecretKey: '', + stripeWebhookSecret: '' + }, + nitro: { prerender: { routes: ['/blog/feed.xml'] diff --git a/package-lock.json b/package-lock.json index 6ed2e47..40c74d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "eslint": "^9.24.0", "feed": "^4.2.2", "nuxt": "^3.16.2", + "stripe": "^18.0.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -3833,6 +3834,14 @@ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, + "node_modules/@types/node": { + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -5303,7 +5312,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -5316,7 +5324,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -6441,7 +6448,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -6672,7 +6678,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -6681,7 +6686,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -6695,7 +6699,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -7928,7 +7931,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -7957,7 +7959,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -8142,7 +8143,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -8246,7 +8246,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9850,7 +9849,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -11217,7 +11215,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -12342,6 +12339,20 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", @@ -13740,7 +13751,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -13759,7 +13769,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -13775,7 +13784,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -13793,7 +13801,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -14340,6 +14347,18 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==" }, + "node_modules/stripe": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.0.0.tgz", + "integrity": "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/structured-clone-es": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz", @@ -14932,6 +14951,11 @@ "unplugin": "^2.1.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "node_modules/unenv": { "version": "2.0.0-rc.15", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz", diff --git a/package.json b/package.json index 6f25454..08c661a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "eslint": "^9.24.0", "feed": "^4.2.2", "nuxt": "^3.16.2", + "stripe": "^18.0.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/src/server/routes/stripe/webhook.post.ts b/src/server/routes/stripe/webhook.post.ts new file mode 100644 index 0000000..be2b873 --- /dev/null +++ b/src/server/routes/stripe/webhook.post.ts @@ -0,0 +1,30 @@ +import { handleStripeEvent } from '~/server/utils/stripe-event'; +import { getStripeClient } from '../../utils/stripe'; +import type Stripe from 'stripe'; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event); + const stripe = getStripeClient(event); + if (!stripe || !config.stripeWebhookKey) { + throw createError({ + statusCode: 403 + }); + } + + const rawBody = await readRawBody(event) ?? ''; + const signature = getHeader(event, 'Stripe-Signature') ?? ''; + let stripeEvent: Stripe.Event | null = null; + try { + stripeEvent = stripe.webhooks.constructEvent(rawBody, signature, config.stripeWebhookKey); + } catch (err) { + console.error('Failed to validate webhook', err); + throw createError({ + statusCode: 400, + message: 'Failed to validate webhook' + }); + } + + await handleStripeEvent(stripeEvent, stripe); + + return 'success'; +}); diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index b9ed69c..54e8380 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "../.nuxt/tsconfig.server.json" + "extends": "../../.nuxt/tsconfig.server.json" } diff --git a/src/server/utils/stripe-event.ts b/src/server/utils/stripe-event.ts new file mode 100644 index 0000000..dd61dd3 --- /dev/null +++ b/src/server/utils/stripe-event.ts @@ -0,0 +1,285 @@ +import type Stripe from 'stripe'; + +export async function handleStripeEvent(event: Stripe.Event, stripe: Stripe): Promise { + if (event.type !== 'customer.subscription.updated' && event.type !== 'customer.subscription.deleted') { + return; + } + + const subscription = event.data.object; + const product = await stripe.products.retrieve(subscription.plan.product); + const customer = await stripe.customers.retrieve(subscription.customer); + + 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.del(subscription.id); + + const invoice = await stripe.invoices.retrieve(subscription.latest_invoice); + await stripe.refunds.create({ + payment_intent: invoice.payment_intent + }); + } catch (error) { + console.error(`Error refunding subscription | ${customer.id}, ${subscription.id} | - ${error.message}`); + } + + try { + await mailer.sendMail({ + to: customer.email, + subject: 'Pretendo Network Subscription Failed - No Linked PNID', + text: `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}` + }); + } catch (error) { + console.error(`Error sending email | ${customer.id}, ${customer.email} | - ${error.message}`); + } + } 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.del(subscription.id); + + const invoice = await stripe.invoices.retrieve(subscription.latest_invoice); + await stripe.refunds.create({ + payment_intent: invoice.payment_intent + }); + } catch (error) { + console.error(`Error refunding subscription | ${customer.id}, ${subscription.id} | - ${error.message}`); + } + + try { + await mailer.sendMail({ + to: customer.email, + subject: 'Pretendo Network Subscription Failed - PNID Not Found', + text: `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}` + }); + } catch (error) { + console.error(`Error sending email | ${customer.id}, ${customer.email} | - ${error.message}`); + } + } 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 >= event.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.message}`); + }); + } + + 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.message}`); + }); + + const updateData = { + 'connections.stripe.latest_webhook_timestamp': event.created + }; + + await database.PNID.updateOne({ + pid, + 'connections.stripe.latest_webhook_timestamp': { + $lte: event.created + } + }, { $set: updateData }).exec(); + + return; + } + + const updateData = { + 'connections.stripe.subscription_id': subscription.status === 'active' ? subscription.id : null, + 'connections.stripe.price_id': subscription.status === 'active' ? subscription.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': event.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.message}`); + }); + } 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.message}`); + }); + } + } + + await database.PNID.updateOne({ + pid, + 'connections.stripe.latest_webhook_timestamp': { + $lte: event.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.del(pastSubscription.id); + } catch (error) { + console.error(`Error canceling old user subscription | ${customer.id}, ${pid}, ${pastSubscription.id} | - ${error.message}`); + } + } + + try { + await mailer.sendMail({ + to: customer.email, + subject: `Pretendo Network ${product.name} Subscription - Active`, + text: `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!` + }); + } catch (error) { + console.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); + } + + 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.message}`); + }); + + for (const email of config.stripe.notification_emails) { + // * Send notification emails for new sub + try { + await mailer.sendMail({ + to: email, + subject: `[Pretendo] - New ${product.name} subscription`, + text: `${pnid.get('username')} just became a ${product.name} tier subscriber` + }); + } catch (error) { + console.error(`Error sending notification email | ${email} | - ${error.message}`); + } + } + } else if (subscription.status === 'canceled') { + try { + await mailer.sendMail({ + to: customer.email, + subject: `Pretendo Network ${product.name} Subscription - Canceled`, + text: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! ` + }); + } catch (error) { + console.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); + } + + 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.message}`); + }); + + for (const email of config.stripe.notification_emails) { + // * Send notification emails for new sub + try { + await mailer.sendMail({ + to: email, + subject: `[Pretendo] - Canceled ${product.name} subscription`, + text: `${pnid.get('username')} just canceled their ${product.name} tier subscription` + }); + } catch (error) { + console.error(`Error sending notification email | ${email} | - ${error.message}`); + } + } + } else if (subscription.status === 'unpaid') { + try { + await mailer.sendMail({ + to: customer.email, + subject: `Pretendo Network ${product.name} Subscription - Unpaid`, + text: `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! ` + }); + } catch (error) { + console.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); + } + + 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.message}`); + }); + + for (const email of config.stripe.notification_emails) { + // * Send notification emails for new sub + try { + await mailer.sendMail({ + to: email, + subject: `[Pretendo] - Removed ${product.name} subscription`, + text: `${pnid.get('username')}'s ${product.name} tier subscription has been canceled due to non payment` + }); + } catch (error) { + console.error(`Error sending notification email | ${email} | - ${error.message}`); + } + } + } else { + try { + await mailer.sendMail({ + to: customer.email, + subject: `Pretendo Network ${product.name} Subscription - ${subscription.status}`, + text: `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!` + }); + } catch (error) { + console.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`); + } + + 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.message}`); + }); + + for (const email of config.stripe.notification_emails) { + // * Send notification emails for new sub + try { + await mailer.sendMail({ + to: email, + subject: `[Pretendo] - Removed ${product.name} subscription`, + text: `${pnid.username}'s ${product.name} tier subscription status has been changed to ${subscription.status}` + }); + } catch (error) { + console.error(`Error sending notification email | ${email} | - ${error.message}`); + } + } + } +} diff --git a/src/server/utils/stripe.ts b/src/server/utils/stripe.ts new file mode 100644 index 0000000..65e329c --- /dev/null +++ b/src/server/utils/stripe.ts @@ -0,0 +1,18 @@ +import Stripe from 'stripe'; +import type { H3Event } from 'h3'; + +let stripeClient: Stripe | null = null; + +export function getStripeClient(event: H3Event): Stripe | null { + if (stripeClient) { + return stripeClient; + } + + const config = useRuntimeConfig(event); + if (!config.stripeSecretKey) { + return null; + } + stripeClient = new Stripe(config.stripeSecretKey); + + return stripeClient; +}