diff --git a/src/lib/storage/jobs/helpers/sendVatNotificationEmail.ts b/src/lib/storage/jobs/helpers/sendVatNotificationEmail.ts new file mode 100644 index 00000000..d761390c --- /dev/null +++ b/src/lib/storage/jobs/helpers/sendVatNotificationEmail.ts @@ -0,0 +1,49 @@ +import { useDefaultEmailService } from '../../../../services/EmailService/EmailService'; +import { getStripe } from '../../../integrations/stripe'; +import Stripe from 'stripe'; + +const stripe = getStripe(); + +const sendVatNotificationEmail = async () => { + const emailService = useDefaultEmailService(); + let hasMore = true; + let startingAfter: string | undefined = undefined; + + while (hasMore) { + const subscriptions: Stripe.ApiList = + await stripe.subscriptions.list({ + limit: 100, + status: 'active', + starting_after: startingAfter, + }); + + for (const subscription of subscriptions.data) { + if (typeof subscription.customer === 'string') { + const customer = await stripe.customers.retrieve(subscription.customer); + + if ('email' in customer) { + console.log({ + email: customer.email, + currency: subscription.currency, + }); + await emailService.sendVatNotificationEmail( + customer.email ?? `alexander+${subscription.id}@alemayhu.com`, // fallback to inform dev if email is not set + subscription.currency, + customer.name ?? 'there' + ); + } else { + console.warn('Customer does not have an email'); + } + } + } + + hasMore = subscriptions.has_more; + if (hasMore) { + startingAfter = subscriptions.data[subscriptions.data.length - 1].id; + } + } +}; + +if (require.main === module) { + sendVatNotificationEmail().catch(console.error); +} diff --git a/src/services/EmailService/EmailService.ts b/src/services/EmailService/EmailService.ts index 2955f799..8bca0b09 100644 --- a/src/services/EmailService/EmailService.ts +++ b/src/services/EmailService/EmailService.ts @@ -1,10 +1,15 @@ import sgMail = require('@sendgrid/mail'); +import * as cheerio from 'cheerio'; +import * as fs from 'fs'; +import * as path from 'path'; import { CONVERT_LINK_TEMPLATE, CONVERT_TEMPLATE, DEFAULT_SENDER, PASSWORD_RESET_TEMPLATE, + VAT_NOTIFICATION_TEMPLATE, + VAT_NOTIFICATIONS_LOG_PATH, } from './constants'; import { isValidDeckName, addDeckNameSuffix } from '../../lib/anki/format'; import { ClientResponse } from '@sendgrid/mail'; @@ -22,6 +27,11 @@ export interface IEmailService { message: string, attachments: Express.Multer.File[] ): Promise; + sendVatNotificationEmail( + email: string, + currency: string, + name: string + ): Promise; } class EmailService implements IEmailService { @@ -120,6 +130,77 @@ class EmailService implements IEmailService { return { didSend: false, error: e as Error }; } } + + private loadVatNotificationsSent(): Set { + try { + // Ensure .2anki directory exists + const dir = path.dirname(VAT_NOTIFICATIONS_LOG_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(VAT_NOTIFICATIONS_LOG_PATH)) { + const data = fs.readFileSync(VAT_NOTIFICATIONS_LOG_PATH, 'utf8'); + return new Set(JSON.parse(data)); + } + } catch (error) { + console.warn('Error loading VAT notifications log:', error); + } + return new Set(); + } + + private saveVatNotificationSent(email: string): void { + try { + const vatNotificationsSent = this.loadVatNotificationsSent(); + vatNotificationsSent.add(email); + fs.writeFileSync( + VAT_NOTIFICATIONS_LOG_PATH, + JSON.stringify([...vatNotificationsSent]) + ); + } catch (error) { + console.error('Error saving VAT notification log:', error); + } + } + + async sendVatNotificationEmail( + email: string, + currency: string, + name: string + ): Promise { + const vatNotificationsSent = this.loadVatNotificationsSent(); + if (vatNotificationsSent.has(email)) { + console.log(`Skipping ${email} - VAT notification already sent`); + return; + } + + const amount = currency === 'eur' ? '€2' : '$2'; + const markup = VAT_NOTIFICATION_TEMPLATE.replace( + '{{amount}}', + amount + ).replace('{{name}}', name || 'there'); + + // Convert HTML to text using cheerio + const $ = cheerio.load(markup); + const text = $('body').text().replace(/\s+/g, ' ').trim(); + + const msg = { + to: email, + from: this.defaultSender, + subject: '2anki.net - Upcoming Changes to VAT', + text, + html: markup, + replyTo: 'support@2anki.net', + }; + + try { + await sgMail.send(msg); + this.saveVatNotificationSent(email); + console.log(`Successfully sent VAT notification to ${email}`); + } catch (error) { + console.error(`Failed to send VAT notification to ${email}:`, error); + throw error; + } + } } export class UnimplementedEmailService implements IEmailService { @@ -150,6 +231,15 @@ export class UnimplementedEmailService implements IEmailService { ); return Promise.resolve({ didSend: false }); } + + sendVatNotificationEmail( + email: string, + currency: string, + name: string + ): Promise { + console.info('sendVatNotificationEmail not handled', email, currency, name); + return Promise.resolve(); + } } export const useDefaultEmailService = () => { diff --git a/src/services/EmailService/constants.ts b/src/services/EmailService/constants.ts index b095fb14..fb52e17e 100644 --- a/src/services/EmailService/constants.ts +++ b/src/services/EmailService/constants.ts @@ -1,5 +1,6 @@ import path from 'path'; import fs from 'fs'; +import * as os from 'os'; export const EMAIL_TEMPLATES_DIRECTORY = path.join(__dirname, 'templates'); @@ -19,3 +20,14 @@ export const CONVERT_LINK_TEMPLATE = fs.readFileSync( ); export const DEFAULT_SENDER = '2anki.net '; + +export const VAT_NOTIFICATION_TEMPLATE = fs.readFileSync( + path.join(EMAIL_TEMPLATES_DIRECTORY, 'vat-notification.html'), + 'utf8' +); + +export const VAT_NOTIFICATIONS_LOG_PATH = path.join( + os.homedir(), + '.2anki', + 'vat-notifications-sent.json' +); diff --git a/src/services/EmailService/templates/vat-notification.html b/src/services/EmailService/templates/vat-notification.html new file mode 100644 index 00000000..44c4e051 --- /dev/null +++ b/src/services/EmailService/templates/vat-notification.html @@ -0,0 +1,28 @@ +

Hei {{name}},

+ +

+ We want to inform you about an upcoming change to your 2anki subscription. As a Norwegian company (Lær Smart + AS), + we are required by law to collect Value Added Tax (VAT) on our services. +

+ +

+ What this means for your subscription
+ Your current monthly subscription costs {{amount}}. In the near future, + we will need to add 25% VAT to this amount, as required by Norwegian law. +

+ +

+ We understand this change might affect your decision to continue using our service. If you wish to cancel your + subscription, + you can do so at any time using this link. + If you have any questions or concerns, please email us at + support@2anki.net. +

+ +

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

\ No newline at end of file