From cc2898f6ff2272e0c8e9dd024ffe2bbf1be97d18 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:50:34 -0600 Subject: [PATCH] feat: send notifications when subscription tier changes (refs #59) Adds email and in-app notifications when user subscription tier changes: - Extended TemplateKey type with 'subscription_tier_change' - Added migration for tier change email template with HTML - Added sendTierChangeNotification() to NotificationsService - Integrated notifications into upgradeSubscription, downgradeSubscription, adminOverrideTier - Integrated notifications into grace-period.job.ts for auto-downgrades Notifications include previous tier, new tier, and reason for change. Co-Authored-By: Claude Opus 4.5 --- .../domain/notifications.service.ts | 139 ++++++++++++++++++ .../domain/notifications.types.ts | 6 +- .../007_subscription_tier_change_template.sql | 95 ++++++++++++ .../domain/subscriptions.service.ts | 75 +++++++++- .../subscriptions/jobs/grace-period.job.ts | 51 ++++++- 5 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 backend/src/features/notifications/migrations/007_subscription_tier_change_template.sql diff --git a/backend/src/features/notifications/domain/notifications.service.ts b/backend/src/features/notifications/domain/notifications.service.ts index 550a162..de21b6a 100644 --- a/backend/src/features/notifications/domain/notifications.service.ts +++ b/backend/src/features/notifications/domain/notifications.service.ts @@ -227,6 +227,134 @@ export class NotificationsService { } } + /** + * Send tier change notification (email + in-app) when subscription tier changes + * @param userId User ID + * @param userEmail User email address + * @param userName User display name + * @param previousTier Previous subscription tier + * @param newTier New subscription tier + * @param reason Reason for change (admin_override, user_upgrade, user_downgrade, grace_period_expiration) + */ + async sendTierChangeNotification( + userId: string, + userEmail: string, + userName: string, + previousTier: string, + newTier: string, + reason: 'admin_override' | 'user_upgrade' | 'user_downgrade' | 'grace_period_expiration' + ): Promise { + const templateKey: TemplateKey = 'subscription_tier_change'; + + const template = await this.repository.getEmailTemplateByKey(templateKey); + if (!template || !template.isActive) { + // Log but don't throw - notifications are non-critical + console.warn(`Template ${templateKey} not found or inactive, skipping tier change notification`); + return; + } + + const isDowngrade = this.getTierRank(newTier) < this.getTierRank(previousTier); + const isUpgrade = this.getTierRank(newTier) > this.getTierRank(previousTier); + const changeType = isUpgrade ? 'Upgraded' : isDowngrade ? 'Downgraded' : 'Changed'; + + const reasonDisplayMap: Record = { + admin_override: 'Administrator adjustment', + user_upgrade: 'Subscription upgrade', + user_downgrade: 'Subscription downgrade', + grace_period_expiration: 'Payment grace period expired', + }; + + const vehicleLimitMap: Record = { + free: '2', + pro: '5', + enterprise: 'unlimited', + }; + + const variables = { + userName, + changeType, + previousTier: this.formatTierName(previousTier), + newTier: this.formatTierName(newTier), + reason: reasonDisplayMap[reason] || reason, + isDowngrade: isDowngrade ? 'true' : '', + isUpgrade: isUpgrade ? 'true' : '', + vehicleLimit: vehicleLimitMap[newTier.toLowerCase()] || '2', + }; + + const subject = this.templateService.render(template.subject, variables); + const htmlBody = this.templateService.renderEmailHtml(template.body, variables); + + // Send email notification + try { + await this.emailService.send(userEmail, subject, htmlBody); + + await this.repository.insertNotificationLog({ + user_id: userId, + notification_type: 'email', + template_key: templateKey, + recipient_email: userEmail, + subject, + reference_type: 'subscription', + status: 'sent', + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Failed to send tier change email to ${userEmail}: ${errorMessage}`); + + await this.repository.insertNotificationLog({ + user_id: userId, + notification_type: 'email', + template_key: templateKey, + recipient_email: userEmail, + subject, + reference_type: 'subscription', + status: 'failed', + error_message: errorMessage, + }); + } + + // Create in-app notification + try { + const title = `Subscription ${changeType}`; + const message = `Your subscription has been ${changeType.toLowerCase()} from ${this.formatTierName(previousTier)} to ${this.formatTierName(newTier)}. ${reasonDisplayMap[reason]}.`; + + await this.repository.insertUserNotification({ + userId, + notificationType: 'subscription', + title, + message, + referenceType: 'subscription', + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Failed to create in-app tier change notification for ${userId}: ${errorMessage}`); + } + } + + /** + * Get numeric rank for tier comparison + */ + private getTierRank(tier: string): number { + const ranks: Record = { + free: 1, + pro: 2, + enterprise: 3, + }; + return ranks[tier.toLowerCase()] || 0; + } + + /** + * Format tier name for display + */ + private formatTierName(tier: string): string { + const names: Record = { + free: 'Free', + pro: 'Pro', + enterprise: 'Enterprise', + }; + return names[tier.toLowerCase()] || tier; + } + /** * Send a test email for a template to verify email configuration * @param key Template key to test @@ -301,6 +429,17 @@ export class NotificationsService { documentTitle: 'State Farm Auto Policy', expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString(), }; + case 'subscription_tier_change': + return { + ...baseVariables, + changeType: 'Upgraded', + previousTier: 'Free', + newTier: 'Pro', + reason: 'Subscription upgrade', + isDowngrade: '', + isUpgrade: 'true', + vehicleLimit: '5', + }; default: return baseVariables; } diff --git a/backend/src/features/notifications/domain/notifications.types.ts b/backend/src/features/notifications/domain/notifications.types.ts index 47c4ac4..3f419c8 100644 --- a/backend/src/features/notifications/domain/notifications.types.ts +++ b/backend/src/features/notifications/domain/notifications.types.ts @@ -10,7 +10,8 @@ export type TemplateKey = | 'maintenance_due_soon' | 'maintenance_overdue' | 'document_expiring' - | 'document_expired'; + | 'document_expired' + | 'subscription_tier_change'; // Email template API response type (camelCase for frontend) export interface EmailTemplate { @@ -84,7 +85,8 @@ export const TemplateKeySchema = z.enum([ 'maintenance_due_soon', 'maintenance_overdue', 'document_expiring', - 'document_expired' + 'document_expired', + 'subscription_tier_change' ]); export const UpdateEmailTemplateSchema = z.object({ diff --git a/backend/src/features/notifications/migrations/007_subscription_tier_change_template.sql b/backend/src/features/notifications/migrations/007_subscription_tier_change_template.sql new file mode 100644 index 0000000..536e55f --- /dev/null +++ b/backend/src/features/notifications/migrations/007_subscription_tier_change_template.sql @@ -0,0 +1,95 @@ +/** + * Migration: Add subscription tier change email template + * @ai-summary Adds email template for subscription tier change notifications + * @ai-context Template sent when tier changes via admin, user action, or grace period expiration + */ + +-- Extend template_key CHECK constraint to include subscription_tier_change +ALTER TABLE email_templates +DROP CONSTRAINT IF EXISTS email_templates_template_key_check; + +ALTER TABLE email_templates +ADD CONSTRAINT email_templates_template_key_check +CHECK (template_key IN ( + 'maintenance_due_soon', 'maintenance_overdue', + 'document_expiring', 'document_expired', + 'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day', + 'subscription_tier_change' +)); + +-- Insert subscription tier change email template +INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES + ( + 'subscription_tier_change', + 'Subscription Tier Change', + 'Sent when user subscription tier changes (upgrade, downgrade, or admin override)', + 'MotoVaultPro: Your Subscription Has Been {{changeType}}', + 'Hi {{userName}}, + +Your MotoVaultPro subscription has been {{changeType}}. + +Previous Tier: {{previousTier}} +New Tier: {{newTier}} +Reason: {{reason}} + +{{#if isDowngrade}} +As a result of this change, you now have access to {{vehicleLimit}} vehicles. Any vehicles beyond this limit will be hidden but your data remains safe. +{{/if}} + +{{#if isUpgrade}} +You now have access to all the features included in the {{newTier}} tier. Enjoy your enhanced MotoVaultPro experience! +{{/if}} + +If you have any questions, please contact support. + +Best regards, +MotoVaultPro Team', + '["userName", "changeType", "previousTier", "newTier", "reason", "isDowngrade", "isUpgrade", "vehicleLimit"]', + ' + + + + + Subscription Tier Change + + + + + + +
+ + + + + + + + + + +
+

Subscription {{changeType}}

+
+

Hi {{userName}},

+

Your MotoVaultPro subscription has been {{changeType}}.

+ + + + +
+

Previous Tier: {{previousTier}}

+

New Tier: {{newTier}}

+

Reason: {{reason}}

+
+

If you have any questions about this change, please contact our support team.

+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ); diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 4a2665e..76f6030 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -8,6 +8,7 @@ import { logger } from '../../../core/logging/logger'; import { SubscriptionsRepository } from '../data/subscriptions.repository'; import { StripeClient } from '../external/stripe/stripe.client'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { NotificationsService } from '../../notifications/domain/notifications.service'; import { Subscription, SubscriptionResponse, @@ -30,6 +31,7 @@ interface StripeWebhookEvent { export class SubscriptionsService { private userProfileRepository: UserProfileRepository; private vehiclesRepository: VehiclesRepository; + private notificationsService: NotificationsService; constructor( private repository: SubscriptionsRepository, @@ -38,6 +40,7 @@ export class SubscriptionsService { ) { this.userProfileRepository = new UserProfileRepository(pool); this.vehiclesRepository = new VehiclesRepository(pool); + this.notificationsService = new NotificationsService(); } /** @@ -213,6 +216,14 @@ export class SubscriptionsService { // Sync tier to user profile await this.syncTierToUserProfile(userId, newTier); + // Send tier change notification + await this.sendTierChangeNotificationSafe( + userId, + currentSubscription.tier, + newTier, + 'user_upgrade' + ); + logger.info('Subscription upgraded', { subscriptionId: updatedSubscription.id, userId, @@ -405,6 +416,14 @@ export class SubscriptionsService { // Sync tier to user profile await this.syncTierToUserProfile(userId, targetTier); + // Send tier change notification + await this.sendTierChangeNotificationSafe( + userId, + currentSubscription.tier, + targetTier, + 'user_downgrade' + ); + logger.info('Subscription downgraded', { subscriptionId: updatedSubscription.id, userId, @@ -701,6 +720,48 @@ export class SubscriptionsService { }); } + /** + * Send tier change notification safely (non-blocking, logs errors) + */ + private async sendTierChangeNotificationSafe( + userId: string, + previousTier: SubscriptionTier, + newTier: SubscriptionTier, + reason: 'admin_override' | 'user_upgrade' | 'user_downgrade' | 'grace_period_expiration' + ): Promise { + try { + // Get user profile for email and name + const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); + if (!userProfile) { + logger.warn('User profile not found for tier change notification', { userId }); + return; + } + + const userEmail = userProfile.notificationEmail || userProfile.email; + const userName = userProfile.displayName || userProfile.email.split('@')[0]; + + await this.notificationsService.sendTierChangeNotification( + userId, + userEmail, + userName, + previousTier, + newTier, + reason + ); + + logger.info('Tier change notification sent', { userId, previousTier, newTier, reason }); + } catch (error: any) { + // Log but don't throw - notifications are non-critical + logger.error('Failed to send tier change notification', { + userId, + previousTier, + newTier, + reason, + error: error.message, + }); + } + } + /** * Sync subscription tier to user_profiles table */ @@ -809,9 +870,11 @@ export class SubscriptionsService { logger.info('Admin overriding subscription tier', { userId, newTier }); // Check if user has a subscription record - let subscription = await this.repository.findByUserId(userId); + const existingSubscription = await this.repository.findByUserId(userId); + const previousTier: SubscriptionTier = existingSubscription?.tier || 'free'; - if (!subscription) { + let subscription: Subscription; + if (!existingSubscription) { // Create subscription record for user (admin override bypasses Stripe) logger.info('Creating subscription record for admin override', { userId, newTier }); subscription = await this.repository.createForAdminOverride(userId, newTier, client); @@ -832,6 +895,14 @@ export class SubscriptionsService { await client.query('COMMIT'); + // Send tier change notification (after transaction committed) + await this.sendTierChangeNotificationSafe( + userId, + previousTier, + newTier, + 'admin_override' + ); + logger.info('Admin subscription tier override complete', { userId, newTier, diff --git a/backend/src/features/subscriptions/jobs/grace-period.job.ts b/backend/src/features/subscriptions/jobs/grace-period.job.ts index cd8117c..60ce022 100644 --- a/backend/src/features/subscriptions/jobs/grace-period.job.ts +++ b/backend/src/features/subscriptions/jobs/grace-period.job.ts @@ -5,11 +5,14 @@ import { Pool } from 'pg'; import { logger } from '../../../core/logging/logger'; +import { NotificationsService } from '../../notifications/domain/notifications.service'; let jobPool: Pool | null = null; +let notificationsService: NotificationsService | null = null; export function setGracePeriodJobPool(pool: Pool): void { jobPool = pool; + notificationsService = new NotificationsService(); } interface GracePeriodResult { @@ -36,13 +39,21 @@ export async function processGracePeriodExpirations(): Promise