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
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user