feat: send notifications when subscription tier changes (refs #59)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 7m15s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 30s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-31 19:50:34 -06:00
parent a97c9e2579
commit cc2898f6ff
5 changed files with 356 additions and 10 deletions

View File

@@ -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<void> {
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,