From 77ce35e2c1778f04c6efb5277dfb7de954219784 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Mon, 6 Jan 2025 07:21:24 +0100 Subject: [PATCH] feat: send customer confirmation of cancellation --- .../StripeController/StripeController.ts | 2 - src/routes/WebhookRouter.ts | 50 +++++- src/services/EmailService/EmailService.ts | 160 ++++++++++++++++++ src/services/EmailService/constants.ts | 19 +++ .../templates/subscription-cancelled.html | 30 ++++ .../subscription-scheduled-cancellation.html | 31 ++++ 6 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 src/services/EmailService/templates/subscription-cancelled.html create mode 100644 src/services/EmailService/templates/subscription-scheduled-cancellation.html diff --git a/src/controllers/StripeController/StripeController.ts b/src/controllers/StripeController/StripeController.ts index 74a1a93b..9596cc43 100644 --- a/src/controllers/StripeController/StripeController.ts +++ b/src/controllers/StripeController/StripeController.ts @@ -31,14 +31,12 @@ export class StripeController { const loggedInUser = await authService.getUserFrom(token); const sessionId = req.query.session_id as string; - console.log('loggedInUser', loggedInUser); console.log('sessionId', sessionId); if (loggedInUser && sessionId) { const stripe = getStripe(); const session = await stripe.checkout.sessions.retrieve(sessionId); const email = session.customer_details?.email; - console.log('cmp', loggedInUser.email, ' ', email); if (loggedInUser.email !== email && email) { console.log('updated email for customer'); await usersService.updateSubScriptionEmailUsingPrimaryEmail( diff --git a/src/routes/WebhookRouter.ts b/src/routes/WebhookRouter.ts index 42d00ccc..724f6f08 100644 --- a/src/routes/WebhookRouter.ts +++ b/src/routes/WebhookRouter.ts @@ -9,6 +9,7 @@ import { import { getDatabase } from '../data_layer'; import { StripeController } from '../controllers/StripeController/StripeController'; import UsersRepository from '../data_layer/UsersRepository'; +import { useDefaultEmailService } from '../services/EmailService/EmailService'; const WebhooksRouter = () => { const router = express.Router(); @@ -44,32 +45,65 @@ const WebhooksRouter = () => { switch (event.type) { case 'customer.subscription.updated': const customerSubscriptionUpdated = event.data.object; - // Then define and call a function to handle the event customer.subscription.updated - const customer = await stripe.customers.retrieve( - // @ts-ignore - getCustomerId(customerSubscriptionUpdated.customer) + const customerId = getCustomerId( + customerSubscriptionUpdated.customer as string ); + if (!customerId) { + console.error('No customer ID found'); + return; + } + const customer = await stripe.customers.retrieve(customerId); await updateStoreSubscription( getDatabase(), customer as Stripe.Customer, customerSubscriptionUpdated ); + + if ( + customerSubscriptionUpdated.cancel_at_period_end === true && + event.data.previous_attributes?.cancel_at_period_end === false + ) { + const cancelDate = new Date( + customerSubscriptionUpdated.current_period_end * 1000 + ); + const emailService = useDefaultEmailService(); + if ('email' in customer) { + await emailService.sendSubscriptionScheduledCancellationEmail( + customer.email!, + customer.name || 'there', + cancelDate + ); + } + } break; case 'customer.subscription.deleted': const customerSubscriptionDeleted = event.data.object; if (typeof customerSubscriptionDeleted.customer === 'string') { - // Then define and call a function to handle the event customer.subscription.deleted - const customerDeleted = await stripe.customers.retrieve( - // @ts-ignore - getCustomerId(customerSubscriptionDeleted.customer) + const deletedCustomerId = getCustomerId( + customerSubscriptionDeleted.customer ); + if (!deletedCustomerId) { + console.error('No customer ID found'); + return; + } + const customerDeleted = + await stripe.customers.retrieve(deletedCustomerId); await updateStoreSubscription( getDatabase(), customerDeleted as Stripe.Customer, customerSubscriptionDeleted ); + + if ('email' in customerDeleted) { + const emailService = useDefaultEmailService(); + await emailService.sendSubscriptionCancelledEmail( + customerDeleted.email!, + customerDeleted.name || 'there', + customerSubscriptionDeleted.id + ); + } } break; case 'checkout.session.completed': diff --git a/src/services/EmailService/EmailService.ts b/src/services/EmailService/EmailService.ts index 8bca0b09..9252aca9 100644 --- a/src/services/EmailService/EmailService.ts +++ b/src/services/EmailService/EmailService.ts @@ -9,7 +9,10 @@ import { DEFAULT_SENDER, PASSWORD_RESET_TEMPLATE, VAT_NOTIFICATION_TEMPLATE, + SUBSCRIPTION_CANCELLED_TEMPLATE, VAT_NOTIFICATIONS_LOG_PATH, + SUBSCRIPTION_CANCELLATIONS_LOG_PATH, + SUBSCRIPTION_SCHEDULED_CANCELLATION_TEMPLATE, } from './constants'; import { isValidDeckName, addDeckNameSuffix } from '../../lib/anki/format'; import { ClientResponse } from '@sendgrid/mail'; @@ -32,6 +35,16 @@ export interface IEmailService { currency: string, name: string ): Promise; + sendSubscriptionCancelledEmail( + email: string, + name: string, + subscriptionId: string + ): Promise; + sendSubscriptionScheduledCancellationEmail( + email: string, + name: string, + cancelDate: Date + ): Promise; } class EmailService implements IEmailService { @@ -201,6 +214,125 @@ class EmailService implements IEmailService { throw error; } } + + private loadCancellationsSent(): Set { + try { + // Ensure .2anki directory exists + const dir = path.dirname(SUBSCRIPTION_CANCELLATIONS_LOG_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(SUBSCRIPTION_CANCELLATIONS_LOG_PATH)) { + const data = fs.readFileSync( + SUBSCRIPTION_CANCELLATIONS_LOG_PATH, + 'utf8' + ); + return new Set(JSON.parse(data)); + } + } catch (error) { + console.warn('Error loading cancellations log:', error); + } + return new Set(); + } + + private saveCancellationSent(subscriptionId: string): void { + try { + const cancellationsSent = this.loadCancellationsSent(); + cancellationsSent.add(subscriptionId); + fs.writeFileSync( + SUBSCRIPTION_CANCELLATIONS_LOG_PATH, + JSON.stringify([...cancellationsSent]) + ); + } catch (error) { + console.error('Error saving cancellation log:', error); + } + } + + async sendSubscriptionCancelledEmail( + email: string, + name: string, + subscriptionId: string + ): Promise { + const cancellationsSent = this.loadCancellationsSent(); + if (cancellationsSent.has(subscriptionId)) { + console.log( + `Skipping ${email} - Cancellation notification already sent for subscription ${subscriptionId}` + ); + return; + } + + const markup = SUBSCRIPTION_CANCELLED_TEMPLATE.replace( + '{{name}}', + name || 'there' + ); + + const $ = cheerio.load(markup); + const text = $('body').text().replace(/\s+/g, ' ').trim(); + + const msg = { + to: email, + from: this.defaultSender, + subject: '2anki.net - Subscription Cancelled', + text, + html: markup, + replyTo: 'support@2anki.net', + }; + + try { + await sgMail.send(msg); + this.saveCancellationSent(subscriptionId); + console.log(`Successfully sent cancellation confirmation to ${email}`); + } catch (error) { + console.error( + `Failed to send cancellation confirmation to ${email}:`, + error + ); + throw error; + } + } + + async sendSubscriptionScheduledCancellationEmail( + email: string, + name: string, + cancelDate: Date + ): Promise { + const formattedDate = cancelDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const markup = SUBSCRIPTION_SCHEDULED_CANCELLATION_TEMPLATE.replace( + '{{name}}', + name || 'there' + ).replace(/{{cancelDate}}/g, formattedDate); + + const $ = cheerio.load(markup); + const text = $('body').text().replace(/\s+/g, ' ').trim(); + + const msg = { + to: email, + from: this.defaultSender, + subject: '2anki.net - Subscription Cancellation Scheduled', + text, + html: markup, + replyTo: 'support@2anki.net', + }; + + try { + await sgMail.send(msg); + console.log( + `Successfully sent scheduled cancellation notification to ${email}` + ); + } catch (error) { + console.error( + `Failed to send scheduled cancellation notification to ${email}:`, + error + ); + throw error; + } + } } export class UnimplementedEmailService implements IEmailService { @@ -240,6 +372,34 @@ export class UnimplementedEmailService implements IEmailService { console.info('sendVatNotificationEmail not handled', email, currency, name); return Promise.resolve(); } + + sendSubscriptionCancelledEmail( + email: string, + name: string, + subscriptionId: string + ): Promise { + console.info( + 'sendSubscriptionCancelledEmail not handled', + email, + name, + subscriptionId + ); + return Promise.resolve(); + } + + sendSubscriptionScheduledCancellationEmail( + email: string, + name: string, + cancelDate: Date + ): Promise { + console.info( + 'sendSubscriptionScheduledCancellationEmail not handled', + email, + name, + cancelDate + ); + return Promise.resolve(); + } } export const useDefaultEmailService = () => { diff --git a/src/services/EmailService/constants.ts b/src/services/EmailService/constants.ts index fb52e17e..089b9656 100644 --- a/src/services/EmailService/constants.ts +++ b/src/services/EmailService/constants.ts @@ -31,3 +31,22 @@ export const VAT_NOTIFICATIONS_LOG_PATH = path.join( '.2anki', 'vat-notifications-sent.json' ); + +export const SUBSCRIPTION_CANCELLED_TEMPLATE = fs.readFileSync( + path.join(EMAIL_TEMPLATES_DIRECTORY, 'subscription-cancelled.html'), + 'utf8' +); + +export const SUBSCRIPTION_CANCELLATIONS_LOG_PATH = path.join( + os.homedir(), + '.2anki', + 'subscriptions-cancelled-sent.json' +); + +export const SUBSCRIPTION_SCHEDULED_CANCELLATION_TEMPLATE = fs.readFileSync( + path.join( + EMAIL_TEMPLATES_DIRECTORY, + 'subscription-scheduled-cancellation.html' + ), + 'utf8' +); diff --git a/src/services/EmailService/templates/subscription-cancelled.html b/src/services/EmailService/templates/subscription-cancelled.html new file mode 100644 index 00000000..bdb2f8c2 --- /dev/null +++ b/src/services/EmailService/templates/subscription-cancelled.html @@ -0,0 +1,30 @@ + + + + + + + 2anki.net - Subscription Cancelled + + + +

Hei {{name}},

+ +

This email confirms that your 2anki.net subscription has been cancelled successfully.

+ +

If you cancelled by mistake or would like to resubscribe in the future, you can do so at any time by visiting + 2anki.net. +

+ +

Thank you for being a subscriber. If you have any feedback about your experience, please let us know by + replying to this email.

+ +

+ --
+ Happy learning,
+ The 2anki Team
+ 2anki.net +

+ + + \ No newline at end of file diff --git a/src/services/EmailService/templates/subscription-scheduled-cancellation.html b/src/services/EmailService/templates/subscription-scheduled-cancellation.html new file mode 100644 index 00000000..992a94a6 --- /dev/null +++ b/src/services/EmailService/templates/subscription-scheduled-cancellation.html @@ -0,0 +1,31 @@ + + + + + + + 2anki.net - Subscription Cancellation Scheduled + + + +

Hei {{name}},

+ +

We've received your request to cancel your 2anki.net subscription. Your subscription will remain active until + {{cancelDate}}, after which it will be cancelled automatically.

+ +

You'll continue to have full access to all premium features until then. If you change your mind, you can + reactivate your subscription at any time before {{cancelDate}} by visiting your billing portal.

+ +

Thank you for being a subscriber. If you have any feedback about your experience, please let us know by + replying to this email.

+ +

+ --
+ Happy learning,
+ The 2anki Team
+ 2anki.net +

+ + + \ No newline at end of file