diff --git a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.mjml b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.mjml index 36f8a49d008..eda8385970a 100644 --- a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.mjml +++ b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.mjml @@ -18,7 +18,7 @@ - <% if (locals.hadDiscount) { %> + <% if (locals.discountEnding) { %> Because a previous discount has ended, your subscription will renew at the standard price. diff --git a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.stories.ts b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.stories.ts index f6ab9097e6a..2672c3357b8 100644 --- a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.stories.ts +++ b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.stories.ts @@ -18,7 +18,7 @@ const data = { reminderLength: '7', subscriptionSupportUrl: 'http://localhost:3030/support', updateBillingUrl: 'http://localhost:3030/subscriptions', - hadDiscount: false, + discountEnding: false, hasDifferentDiscount: false, }; @@ -36,7 +36,7 @@ export const MonthlyPlanNoDiscount = createStory( export const MonthlyPlanDiscountEnding = createStory( { - hadDiscount: true, + discountEnding: true, }, 'Monthly Plan - Discount Ending' ); @@ -47,7 +47,7 @@ export const YearlyPlanNoDiscount = createStory( planIntervalCount: '1', reminderLength: '15', invoiceTotal: '$199.99', - hadDiscount: false, + discountEnding: false, }, 'Yearly Plan - No Discount' ); @@ -58,7 +58,7 @@ export const YearlyPlanDiscountEnding = createStory( planIntervalCount: '1', reminderLength: '15', invoiceTotal: '$199.99', - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: false, }, 'Yearly Plan - Discount Ending' @@ -66,7 +66,7 @@ export const YearlyPlanDiscountEnding = createStory( export const MonthlyPlanDiscountChanging = createStory( { - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: true, invoiceTotal: '$14.00', }, @@ -79,7 +79,7 @@ export const YearlyPlanDiscountChanging = createStory( planIntervalCount: '1', reminderLength: '15', invoiceTotal: '$139.99', - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: true, }, 'Yearly Plan - Discount Changing' diff --git a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.ts b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.ts index 2cf0db296fa..b084d375aa3 100644 --- a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.ts +++ b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.ts @@ -14,7 +14,7 @@ export type TemplateData = SubscriptionSupportContactTemplateData & reminderLength: string; subscriptionSupportUrl: string; updateBillingUrl: string; - hadDiscount?: boolean; + discountEnding?: boolean; hasDifferentDiscount?: boolean; }; diff --git a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.txt b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.txt index 27a6f1de155..5e7ac88b1fc 100644 --- a/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.txt +++ b/libs/accounts/email-renderer/src/templates/subscriptionRenewalReminder/index.txt @@ -6,7 +6,7 @@ subscriptionRenewalReminder-content-greeting = "Dear <%- productName %> customer subscriptionRenewalReminder-content-intro = "Your current subscription is set to automatically renew in <%- reminderLength %> days." -<% if (locals.hadDiscount) { %> +<% if (locals.discountEnding) { %> subscriptionRenewalReminder-content-discount-ending = "Because a previous discount has ended, your subscription will renew at the standard price." <% } else if (locals.hasDifferentDiscount) { %> subscriptionRenewalReminder-content-discount-change = "Your next invoice reflects a change in pricing, as a previous discount has ended and a new discount has been applied." diff --git a/packages/fxa-auth-server/lib/payments/subscription-reminders.ts b/packages/fxa-auth-server/lib/payments/subscription-reminders.ts index 45c86d26c84..b736b96b0fa 100644 --- a/packages/fxa-auth-server/lib/payments/subscription-reminders.ts +++ b/packages/fxa-auth-server/lib/payments/subscription-reminders.ts @@ -331,14 +331,6 @@ export class SubscriptionReminders { } try { const account = await this.db.account(uid); - this.log.info('sendSubscriptionRenewalReminderEmail', { - message: 'Sending a renewal reminder email.', - subscriptionId: subscription.id, - currentPeriodStart: subscription.current_period_start, - currentPeriodEnd: subscription.current_period_end, - currentDateMs: Date.now(), - reminderLength: effectiveReminderDuration.as('days'), - }); const { email } = account; const formattedSubscription = await this.stripeHelper.formatSubscriptionForEmail(subscription); @@ -368,10 +360,30 @@ export class SubscriptionReminders { : null; // Detect if discount is ending - const hadDiscount = this.hasDiscountEnding(currentDiscountId, upcomingDiscountId); + const discountEnding = this.hasDiscountEnding(currentDiscountId, upcomingDiscountId); // Detect if renewal has a different discount const hasDifferentDiscount = this.hasDifferentDiscount(currentDiscountId, upcomingDiscountId); + // Business rule: Monthly subscriptions only receive renewal reminders when a discount is ending, + // to avoid notification fatigue for standard monthly renewals. + if (interval === 'month' && !discountEnding) { + this.log.info('sendSubscriptionRenewalReminderEmail.skippingMonthlyNoDiscount', { + subscriptionId: subscription.id, + planId, + }); + return false; + } + + // If we reach here, we're sending the email + this.log.info('sendSubscriptionRenewalReminderEmail', { + message: 'Sending a renewal reminder email.', + subscriptionId: subscription.id, + currentPeriodStart: subscription.current_period_start, + currentPeriodEnd: subscription.current_period_end, + currentDateMs: Date.now(), + reminderLength: effectiveReminderDuration.as('days'), + }); + await this.mailer.sendSubscriptionRenewalReminderEmail( account.emails, account, @@ -388,7 +400,7 @@ export class SubscriptionReminders { invoiceTotalCurrency: invoicePreview.currency, productMetadata: formattedSubscription.productMetadata, planConfig: formattedSubscription.planConfig, - hadDiscount, + discountEnding, hasDifferentDiscount, } ); @@ -488,8 +500,8 @@ export class SubscriptionReminders { * Sends a reminder email for all active subscriptions for all plans * as long or longer than `planLength`: * 1. Get a list of all plans of sufficient `planLength` - * 2. Send 30-day reminders for yearly plans (if enabled) - * 3. Send 14-day reminders for all plans + * 2. Send 15-day reminders for yearly plans (if enabled) + * 3. Send 7-day reminders for monthly plans * 4. If enabled, send subscription ending reminder emails if one * hasn't already been sent. */ diff --git a/packages/fxa-auth-server/lib/senders/email.js b/packages/fxa-auth-server/lib/senders/email.js index 8f7e23c3d25..d2ffc2c343e 100644 --- a/packages/fxa-auth-server/lib/senders/email.js +++ b/packages/fxa-auth-server/lib/senders/email.js @@ -3252,7 +3252,7 @@ module.exports = function (log, config, bounces, statsd) { message.invoiceTotalCurrency, message.acceptLanguage ), - hadDiscount: message.hadDiscount || false, + discountEnding: message.discountEnding || false, hasDifferentDiscount: message.hasDifferentDiscount || false, }, }); diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.mjml b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.mjml index d7ab2116079..14082f4fc26 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.mjml +++ b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.mjml @@ -18,7 +18,7 @@ - <% if (hadDiscount) { %> + <% if (discountEnding) { %> Because a previous discount has ended, your subscription will renew at the standard price. diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.stories.ts b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.stories.ts index 785dc3bd07e..df5886d633f 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.stories.ts +++ b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.stories.ts @@ -20,7 +20,7 @@ const createStory = subplatStoryWithProps( reminderLength: '7', subscriptionSupportUrl: 'http://localhost:3030/support', updateBillingUrl: 'http://localhost:3030/subscriptions', - hadDiscount: false, + discountEnding: false, hasDifferentDiscount: false, } ); @@ -32,7 +32,7 @@ export const MonthlyPlanNoDiscount = createStory( export const MonthlyPlanDiscountEnding = createStory( { - hadDiscount: true, + discountEnding: true, }, 'Monthly Plan - Discount Ending' ); @@ -43,7 +43,7 @@ export const YearlyPlanNoDiscount = createStory( planIntervalCount: '1', reminderLength: '15', invoiceTotal: '$199.99', - hadDiscount: false, + discountEnding: false, }, 'Yearly Plan - No Discount' ); @@ -54,7 +54,7 @@ export const YearlyPlanDiscountEnding = createStory( planIntervalCount: '1', reminderLength: '15', invoiceTotal: '$199.99', - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: false, }, 'Yearly Plan - Discount Ending' @@ -62,7 +62,7 @@ export const YearlyPlanDiscountEnding = createStory( export const MonthlyPlanDiscountChanging = createStory( { - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: true, invoiceTotal: '$14.00', }, @@ -75,7 +75,7 @@ export const YearlyPlanDiscountChanging = createStory( planIntervalCount: '1', reminderLength: '15', invoiceTotal: '$139.99', - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: true, }, 'Yearly Plan - Discount Changing' diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.txt b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.txt index 1103404d8af..01f0d43b342 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.txt +++ b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionRenewalReminder/index.txt @@ -6,7 +6,7 @@ subscriptionRenewalReminder-content-greeting = "Dear <%- productName %> customer subscriptionRenewalReminder-content-intro = "Your current subscription is set to automatically renew in <%- reminderLength %> days." -<% if (hadDiscount) { %> +<% if (discountEnding) { %> subscriptionRenewalReminder-content-discount-ending = "Because a previous discount has ended, your subscription will renew at the standard price." <% } else if (hasDifferentDiscount) { %> subscriptionRenewalReminder-content-discount-change = "Your next invoice reflects a change in pricing, as a previous discount has ended and a new discount has been applied." diff --git a/packages/fxa-auth-server/scripts/subscription-reminders.ts b/packages/fxa-auth-server/scripts/subscription-reminders.ts index 9d3c3b95e85..de9cdd10637 100644 --- a/packages/fxa-auth-server/scripts/subscription-reminders.ts +++ b/packages/fxa-auth-server/scripts/subscription-reminders.ts @@ -37,7 +37,7 @@ async function init() { DEFAULT_PLAN_LENGTH.toString() ) .option( - '-r, --reminder-length [days]', + '-r, --monthly-renewal-reminder-length [days]', 'Reminder length in days before the renewal date to send the reminder email for monthly plans. Defaults to 7.', DEFAULT_REMINDER_LENGTH.toString() ) diff --git a/packages/fxa-auth-server/test/local/payments/subscription-reminders.js b/packages/fxa-auth-server/test/local/payments/subscription-reminders.js index 891b649f318..4c37048522b 100644 --- a/packages/fxa-auth-server/test/local/payments/subscription-reminders.js +++ b/packages/fxa-auth-server/test/local/payments/subscription-reminders.js @@ -328,7 +328,7 @@ describe('SubscriptionReminders', () => { }); mockStripeHelper.getInvoice = sandbox.fake.resolves({ id: subscription.latest_invoice, - discount: null, + discount: { id: 'discount_ending' }, discounts: [], }); const planConfig = { @@ -406,7 +406,7 @@ describe('SubscriptionReminders', () => { invoiceTotalCurrency: invoicePreview.currency, productMetadata: formattedSubscription.productMetadata, planConfig, - hadDiscount: false, + discountEnding: true, hasDifferentDiscount: false, } ); @@ -418,6 +418,133 @@ describe('SubscriptionReminders', () => { ); }); + it('skips monthly reminder when no discount is ending', async () => { + const subscription = deepCopy(longSubscription1); + subscription.customer = { + email: 'abc@123.com', + metadata: { + userid: 'uid', + }, + }; + reminder.alreadySentEmail = sandbox.fake.resolves(false); + const account = { + emails: [], + email: 'testo@test.test', + locale: 'NZ', + }; + reminder.db.account = sandbox.fake.resolves(account); + mockLog.info = sandbox.fake.returns({}); + mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + amount: longPlan1.amount, + currency: longPlan1.currency, + interval_count: longPlan1.interval_count, + interval: longPlan1.interval, + }); + mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves({ + total: invoicePreview.total, + currency: invoicePreview.currency, + discount: null, + discounts: [], + }); + // Monthly plan with no discount - should skip + mockStripeHelper.getInvoice = sandbox.fake.resolves({ + id: subscription.latest_invoice, + discount: null, + discounts: [], + }); + mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + reminder.mailer.sendSubscriptionRenewalReminderEmail = + sandbox.fake.resolves(true); + reminder.updateSentEmail = sandbox.fake.resolves({}); + + const result = await reminder.sendSubscriptionRenewalReminderEmail( + subscription, + longPlan1.id + ); + + assert.isFalse(result); + sinon.assert.calledWithExactly( + mockLog.info, + 'sendSubscriptionRenewalReminderEmail.skippingMonthlyNoDiscount', + { + subscriptionId: subscription.id, + planId: longPlan1.id, + } + ); + sinon.assert.notCalled(reminder.mailer.sendSubscriptionRenewalReminderEmail); + sinon.assert.notCalled(reminder.updateSentEmail); + }); + + it('sends yearly reminder regardless of discount status', async () => { + const yearlyPlan = require('./fixtures/stripe/plan_yearly.json'); + const subscription = deepCopy(longSubscription1); + subscription.customer = { + email: 'abc@123.com', + metadata: { + userid: 'uid', + }, + }; + reminder.alreadySentEmail = sandbox.fake.resolves(false); + const account = { + emails: [], + email: 'testo@test.test', + locale: 'NZ', + }; + reminder.db.account = sandbox.fake.resolves(account); + mockLog.info = sandbox.fake.returns({}); + mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + amount: yearlyPlan.amount, + currency: yearlyPlan.currency, + interval_count: yearlyPlan.interval_count, + interval: yearlyPlan.interval, + }); + mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves({ + total: invoicePreview.total, + currency: invoicePreview.currency, + discount: null, + discounts: [], + }); + // Yearly plan with no discount - should still send + mockStripeHelper.getInvoice = sandbox.fake.resolves({ + id: subscription.latest_invoice, + discount: null, + discounts: [], + }); + const planConfig = { + wibble: 'quux', + }; + const formattedSubscription = { + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig, + }; + mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves( + formattedSubscription + ); + reminder.mailer.sendSubscriptionRenewalReminderEmail = + sandbox.fake.resolves(true); + reminder.updateSentEmail = sandbox.fake.resolves({}); + + const result = await reminder.sendSubscriptionRenewalReminderEmail( + subscription, + yearlyPlan.id + ); + + assert.isTrue(result); + sinon.assert.calledOnce(reminder.mailer.sendSubscriptionRenewalReminderEmail); + sinon.assert.calledOnce(reminder.updateSentEmail); + }); + it('returns false if an error is caught when trying to send a reminder email', async () => { const subscription = deepCopy(longSubscription1); subscription.customer = { @@ -444,7 +571,7 @@ describe('SubscriptionReminders', () => { }); mockStripeHelper.getInvoice = sandbox.fake.resolves({ id: subscription.latest_invoice, - discount: null, + discount: { id: 'discount_ending' }, discounts: [], }); mockLog.info = sandbox.fake.returns({}); @@ -549,7 +676,7 @@ describe('SubscriptionReminders', () => { sinon.assert.calledWithExactly(mockStripeHelper.getInvoice, 'in_test123'); const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isTrue(mailerCall.args[2].hadDiscount); + assert.isTrue(mailerCall.args[2].discountEnding); assert.isFalse(mailerCall.args[2].hasDifferentDiscount); }); @@ -612,11 +739,11 @@ describe('SubscriptionReminders', () => { assert.isTrue(result); const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isTrue(mailerCall.args[2].hadDiscount); + assert.isTrue(mailerCall.args[2].discountEnding); assert.isFalse(mailerCall.args[2].hasDifferentDiscount); }); - it('detects different discount from latest invoice', async () => { + it('skips monthly plan reminders when discount changes but does not end', async () => { const subscription = deepCopy(longSubscription1); subscription.customer = { email: 'abc@123.com', @@ -673,13 +800,11 @@ describe('SubscriptionReminders', () => { longPlan1.id ); - assert.isTrue(result); - const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isFalse(mailerCall.args[2].hadDiscount); - assert.isTrue(mailerCall.args[2].hasDifferentDiscount); + assert.isFalse(result); + sinon.assert.notCalled(reminder.mailer.sendSubscriptionRenewalReminderEmail); }); - it('does not flag discount changes when discount remains the same', async () => { + it('skips monthly plan reminders when discount remains the same', async () => { const subscription = deepCopy(longSubscription1); subscription.customer = { email: 'abc@123.com', @@ -736,10 +861,8 @@ describe('SubscriptionReminders', () => { longPlan1.id ); - assert.isTrue(result); - const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isFalse(mailerCall.args[2].hadDiscount); - assert.isFalse(mailerCall.args[2].hasDifferentDiscount); + assert.isFalse(result); + sinon.assert.notCalled(reminder.mailer.sendSubscriptionRenewalReminderEmail); }); it('handles when latest_invoice is an expanded object with discount ending', async () => { @@ -801,11 +924,11 @@ describe('SubscriptionReminders', () => { sinon.assert.notCalled(mockStripeHelper.getInvoice); const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isTrue(mailerCall.args[2].hadDiscount); + assert.isTrue(mailerCall.args[2].discountEnding); assert.isFalse(mailerCall.args[2].hasDifferentDiscount); }); - it('does not flag changes when no discount on either invoice', async () => { + it('skips monthly plan reminders when no discount on either invoice', async () => { const subscription = deepCopy(longSubscription1); subscription.customer = { email: 'abc@123.com', @@ -862,13 +985,11 @@ describe('SubscriptionReminders', () => { longPlan1.id ); - assert.isTrue(result); - const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isFalse(mailerCall.args[2].hadDiscount); - assert.isFalse(mailerCall.args[2].hasDifferentDiscount); + assert.isFalse(result); + sinon.assert.notCalled(reminder.mailer.sendSubscriptionRenewalReminderEmail); }); - it('does not flag changes when only upcoming invoice has discount', async () => { + it('skips monthly plan reminders when adding a discount to a full-price plan', async () => { const subscription = deepCopy(longSubscription1); subscription.customer = { email: 'abc@123.com', @@ -925,10 +1046,8 @@ describe('SubscriptionReminders', () => { longPlan1.id ); - assert.isTrue(result); - const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isFalse(mailerCall.args[2].hadDiscount); - assert.isFalse(mailerCall.args[2].hasDifferentDiscount); + assert.isFalse(result); + sinon.assert.notCalled(reminder.mailer.sendSubscriptionRenewalReminderEmail); }); it('handles discount as string in discounts array', async () => { @@ -990,11 +1109,11 @@ describe('SubscriptionReminders', () => { assert.isTrue(result); const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isTrue(mailerCall.args[2].hadDiscount); + assert.isTrue(mailerCall.args[2].discountEnding); assert.isFalse(mailerCall.args[2].hasDifferentDiscount); }); - it('detects different discount with discounts arrays', async () => { + it('skips monthly plan reminders with different discount in discounts arrays', async () => { const subscription = deepCopy(longSubscription1); subscription.customer = { email: 'abc@123.com', @@ -1051,10 +1170,8 @@ describe('SubscriptionReminders', () => { longPlan1.id ); - assert.isTrue(result); - const mailerCall = reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - assert.isFalse(mailerCall.args[2].hadDiscount); - assert.isTrue(mailerCall.args[2].hasDifferentDiscount); + assert.isFalse(result); + sinon.assert.notCalled(reminder.mailer.sendSubscriptionRenewalReminderEmail); }); }); diff --git a/packages/fxa-auth-server/test/local/senders/emails.ts b/packages/fxa-auth-server/test/local/senders/emails.ts index 9ce7ed21c92..21c0e8e30db 100644 --- a/packages/fxa-auth-server/test/local/senders/emails.ts +++ b/packages/fxa-auth-server/test/local/senders/emails.ts @@ -3921,7 +3921,7 @@ const TESTS: [string, any, Record?][] = [ updateTemplateValues: (x) => ({ ...x, productName: MESSAGE.subscription.productName, - hadDiscount: true, + discountEnding: true, hasDifferentDiscount: false, }), }, @@ -4067,7 +4067,7 @@ const TESTS: [string, any, Record?][] = [ updateTemplateValues: (x) => ({ ...x, productName: MESSAGE.subscription.productName, - hadDiscount: false, + discountEnding: false, hasDifferentDiscount: true, }), },