Skip to content

Commit

Permalink
feat: send customer confirmation of cancellation
Browse files Browse the repository at this point in the history
  • Loading branch information
aalemayhu committed Jan 6, 2025
1 parent ddd61e8 commit 29dc423
Show file tree
Hide file tree
Showing 5 changed files with 287 additions and 8 deletions.
51 changes: 43 additions & 8 deletions src/routes/WebhookRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -44,32 +45,66 @@ 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,
customerSubscriptionUpdated.id
);
}
}
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':
Expand Down
164 changes: 164 additions & 0 deletions src/services/EmailService/EmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +35,17 @@ export interface IEmailService {
currency: string,
name: string
): Promise<void>;
sendSubscriptionCancelledEmail(
email: string,
name: string,
subscriptionId: string
): Promise<void>;
sendSubscriptionScheduledCancellationEmail(
email: string,
name: string,
cancelDate: Date,
subscriptionId: string
): Promise<void>;
}

class EmailService implements IEmailService {
Expand Down Expand Up @@ -201,6 +215,126 @@ class EmailService implements IEmailService {
throw error;
}
}

private loadCancellationsSent(): Set<string> {
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<void> {
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,
subscriptionId: string

Check failure on line 300 in src/services/EmailService/EmailService.ts

View workflow job for this annotation

GitHub Actions / build (20.18.0)

'subscriptionId' is defined but never used
): Promise<void> {
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 {
Expand Down Expand Up @@ -240,6 +374,36 @@ export class UnimplementedEmailService implements IEmailService {
console.info('sendVatNotificationEmail not handled', email, currency, name);
return Promise.resolve();
}

sendSubscriptionCancelledEmail(
email: string,
name: string,
subscriptionId: string
): Promise<void> {
console.info(
'sendSubscriptionCancelledEmail not handled',
email,
name,
subscriptionId
);
return Promise.resolve();
}

sendSubscriptionScheduledCancellationEmail(
email: string,
name: string,
cancelDate: Date,
subscriptionId: string
): Promise<void> {
console.info(
'sendSubscriptionScheduledCancellationEmail not handled',
email,
name,
cancelDate,
subscriptionId
);
return Promise.resolve();
}
}

export const useDefaultEmailService = () => {
Expand Down
19 changes: 19 additions & 0 deletions src/services/EmailService/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
30 changes: 30 additions & 0 deletions src/services/EmailService/templates/subscription-cancelled.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>2anki.net - Subscription Cancelled</title>
</head>

<body>
<p>Hei {{name}},</p>

<p>This email confirms that your 2anki.net subscription has been cancelled successfully.</p>

<p>If you cancelled by mistake or would like to resubscribe in the future, you can do so at any time by visiting
<a href="https://2anki.net">2anki.net</a>.
</p>

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

<p style="margin-top: 2em; color: #666;">
--<br>
Happy learning,<br>
The 2anki Team<br>
<a href="https://2anki.net/" style="color: #0066cc; text-decoration: none;">2anki.net</a>
</p>
</body>

</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>2anki.net - Subscription Cancellation Scheduled</title>
</head>

<body>
<p>Hei {{name}},</p>

<p>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.</p>

<p>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 <a
href="https://billing.stripe.com/p/login/test">billing portal</a>.</p>

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

<p style="margin-top: 2em; color: #666;">
--<br>
Happy learning,<br>
The 2anki Team<br>
<a href="https://2anki.net/" style="color: #0066cc; text-decoration: none;">2anki.net</a>
</p>
</body>

</html>

0 comments on commit 29dc423

Please sign in to comment.