Merge pull request 'feat: send notifications when subscription tier changes (#59)' (#63) from issue-59-tier-change-notifications into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 25s
Deploy to Staging / Deploy to Staging (push) Successful in 30s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 1m9s

Reviewed-on: #63
This commit was merged in pull request #63.
This commit is contained in:
2026-02-01 02:38:20 +00:00
5 changed files with 363 additions and 10 deletions

View File

@@ -227,6 +227,141 @@ 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<void> {
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<string, string> = {
admin_override: 'Administrator adjustment',
user_upgrade: 'Subscription upgrade',
user_downgrade: 'Subscription downgrade',
grace_period_expiration: 'Payment grace period expired',
};
const vehicleLimitMap: Record<string, string> = {
free: '2',
pro: '5',
enterprise: 'unlimited',
};
// Build additional info based on change type
let additionalInfo = '';
if (isDowngrade) {
const vehicleLimit = vehicleLimitMap[newTier.toLowerCase()] || '2';
additionalInfo = `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.`;
} else if (isUpgrade) {
additionalInfo = `You now have access to all the features included in the ${this.formatTierName(newTier)} tier. Enjoy your enhanced MotoVaultPro experience!`;
}
const variables = {
userName,
changeType,
previousTier: this.formatTierName(previousTier),
newTier: this.formatTierName(newTier),
reason: reasonDisplayMap[reason] || reason,
additionalInfo,
};
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<string, number> = {
free: 1,
pro: 2,
enterprise: 3,
};
return ranks[tier.toLowerCase()] || 0;
}
/**
* Format tier name for display
*/
private formatTierName(tier: string): string {
const names: Record<string, string> = {
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 +436,15 @@ 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',
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!',
};
default:
return baseVariables;
}

View File

@@ -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({

View File

@@ -0,0 +1,97 @@
/**
* 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 or update 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}}
{{additionalInfo}}
If you have any questions, please contact support.
Best regards,
MotoVaultPro Team',
'["userName", "changeType", "previousTier", "newTier", "reason", "additionalInfo"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subscription Tier Change</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #1976d2; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Subscription {{changeType}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your MotoVaultPro subscription has been <strong>{{changeType}}</strong>.</p>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #1976d2;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Previous Tier:</strong> {{previousTier}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>New Tier:</strong> {{newTier}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Reason:</strong> {{reason}}</p>
</td>
</tr>
</table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">If you have any questions about this change, please contact our support team.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="https://motovaultpro.com/settings/billing" style="display: inline-block; padding: 14px 28px; background-color: #1976d2; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">View Subscription</a>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
)
ON CONFLICT (template_key) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
subject = EXCLUDED.subject,
body = EXCLUDED.body,
variables = EXCLUDED.variables,
html_body = EXCLUDED.html_body,
updated_at = NOW();

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,

View File

@@ -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<GracePeriodResult
const client = await jobPool.connect();
try {
// Find subscriptions with expired grace periods
// Find subscriptions with expired grace periods (join with user_profiles for notification)
const query = `
SELECT id, user_id, tier, stripe_subscription_id
FROM subscriptions
WHERE status = 'past_due'
AND grace_period_end < NOW()
ORDER BY grace_period_end ASC
SELECT
s.id,
s.user_id,
s.tier,
s.stripe_subscription_id,
up.email as user_email,
up.notification_email,
up.display_name
FROM subscriptions s
LEFT JOIN user_profiles up ON s.user_id = up.auth0_sub
WHERE s.status = 'past_due'
AND s.grace_period_end < NOW()
ORDER BY s.grace_period_end ASC
`;
const queryResult = await client.query(query);
@@ -99,6 +110,34 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
userId: subscription.user_id,
previousTier: subscription.tier,
});
// Send tier change notification (after transaction committed)
if (notificationsService && subscription.user_email) {
try {
const userEmail = subscription.notification_email || subscription.user_email;
const userName = subscription.display_name || subscription.user_email.split('@')[0];
await notificationsService.sendTierChangeNotification(
subscription.user_id,
userEmail,
userName,
subscription.tier,
'free',
'grace_period_expiration'
);
logger.info('Grace period expiration notification sent', {
userId: subscription.user_id,
previousTier: subscription.tier,
});
} catch (notifyError: any) {
// Log but don't fail the job - notification is non-critical
logger.error('Failed to send grace period expiration notification', {
userId: subscription.user_id,
error: notifyError.message,
});
}
}
} catch (error: any) {
// Rollback transaction on error
await client.query('ROLLBACK');