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
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:
@@ -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<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',
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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
|
* Send a test email for a template to verify email configuration
|
||||||
* @param key Template key to test
|
* @param key Template key to test
|
||||||
@@ -301,6 +429,17 @@ export class NotificationsService {
|
|||||||
documentTitle: 'State Farm Auto Policy',
|
documentTitle: 'State Farm Auto Policy',
|
||||||
expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString(),
|
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:
|
default:
|
||||||
return baseVariables;
|
return baseVariables;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export type TemplateKey =
|
|||||||
| 'maintenance_due_soon'
|
| 'maintenance_due_soon'
|
||||||
| 'maintenance_overdue'
|
| 'maintenance_overdue'
|
||||||
| 'document_expiring'
|
| 'document_expiring'
|
||||||
| 'document_expired';
|
| 'document_expired'
|
||||||
|
| 'subscription_tier_change';
|
||||||
|
|
||||||
// Email template API response type (camelCase for frontend)
|
// Email template API response type (camelCase for frontend)
|
||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
@@ -84,7 +85,8 @@ export const TemplateKeySchema = z.enum([
|
|||||||
'maintenance_due_soon',
|
'maintenance_due_soon',
|
||||||
'maintenance_overdue',
|
'maintenance_overdue',
|
||||||
'document_expiring',
|
'document_expiring',
|
||||||
'document_expired'
|
'document_expired',
|
||||||
|
'subscription_tier_change'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const UpdateEmailTemplateSchema = z.object({
|
export const UpdateEmailTemplateSchema = z.object({
|
||||||
|
|||||||
@@ -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"]',
|
||||||
|
'<!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>'
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@ import { logger } from '../../../core/logging/logger';
|
|||||||
import { SubscriptionsRepository } from '../data/subscriptions.repository';
|
import { SubscriptionsRepository } from '../data/subscriptions.repository';
|
||||||
import { StripeClient } from '../external/stripe/stripe.client';
|
import { StripeClient } from '../external/stripe/stripe.client';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
|
import { NotificationsService } from '../../notifications/domain/notifications.service';
|
||||||
import {
|
import {
|
||||||
Subscription,
|
Subscription,
|
||||||
SubscriptionResponse,
|
SubscriptionResponse,
|
||||||
@@ -30,6 +31,7 @@ interface StripeWebhookEvent {
|
|||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
private userProfileRepository: UserProfileRepository;
|
private userProfileRepository: UserProfileRepository;
|
||||||
private vehiclesRepository: VehiclesRepository;
|
private vehiclesRepository: VehiclesRepository;
|
||||||
|
private notificationsService: NotificationsService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private repository: SubscriptionsRepository,
|
private repository: SubscriptionsRepository,
|
||||||
@@ -38,6 +40,7 @@ export class SubscriptionsService {
|
|||||||
) {
|
) {
|
||||||
this.userProfileRepository = new UserProfileRepository(pool);
|
this.userProfileRepository = new UserProfileRepository(pool);
|
||||||
this.vehiclesRepository = new VehiclesRepository(pool);
|
this.vehiclesRepository = new VehiclesRepository(pool);
|
||||||
|
this.notificationsService = new NotificationsService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,6 +216,14 @@ export class SubscriptionsService {
|
|||||||
// Sync tier to user profile
|
// Sync tier to user profile
|
||||||
await this.syncTierToUserProfile(userId, newTier);
|
await this.syncTierToUserProfile(userId, newTier);
|
||||||
|
|
||||||
|
// Send tier change notification
|
||||||
|
await this.sendTierChangeNotificationSafe(
|
||||||
|
userId,
|
||||||
|
currentSubscription.tier,
|
||||||
|
newTier,
|
||||||
|
'user_upgrade'
|
||||||
|
);
|
||||||
|
|
||||||
logger.info('Subscription upgraded', {
|
logger.info('Subscription upgraded', {
|
||||||
subscriptionId: updatedSubscription.id,
|
subscriptionId: updatedSubscription.id,
|
||||||
userId,
|
userId,
|
||||||
@@ -405,6 +416,14 @@ export class SubscriptionsService {
|
|||||||
// Sync tier to user profile
|
// Sync tier to user profile
|
||||||
await this.syncTierToUserProfile(userId, targetTier);
|
await this.syncTierToUserProfile(userId, targetTier);
|
||||||
|
|
||||||
|
// Send tier change notification
|
||||||
|
await this.sendTierChangeNotificationSafe(
|
||||||
|
userId,
|
||||||
|
currentSubscription.tier,
|
||||||
|
targetTier,
|
||||||
|
'user_downgrade'
|
||||||
|
);
|
||||||
|
|
||||||
logger.info('Subscription downgraded', {
|
logger.info('Subscription downgraded', {
|
||||||
subscriptionId: updatedSubscription.id,
|
subscriptionId: updatedSubscription.id,
|
||||||
userId,
|
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
|
* Sync subscription tier to user_profiles table
|
||||||
*/
|
*/
|
||||||
@@ -809,9 +870,11 @@ export class SubscriptionsService {
|
|||||||
logger.info('Admin overriding subscription tier', { userId, newTier });
|
logger.info('Admin overriding subscription tier', { userId, newTier });
|
||||||
|
|
||||||
// Check if user has a subscription record
|
// 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)
|
// Create subscription record for user (admin override bypasses Stripe)
|
||||||
logger.info('Creating subscription record for admin override', { userId, newTier });
|
logger.info('Creating subscription record for admin override', { userId, newTier });
|
||||||
subscription = await this.repository.createForAdminOverride(userId, newTier, client);
|
subscription = await this.repository.createForAdminOverride(userId, newTier, client);
|
||||||
@@ -832,6 +895,14 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
await client.query('COMMIT');
|
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', {
|
logger.info('Admin subscription tier override complete', {
|
||||||
userId,
|
userId,
|
||||||
newTier,
|
newTier,
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
import { NotificationsService } from '../../notifications/domain/notifications.service';
|
||||||
|
|
||||||
let jobPool: Pool | null = null;
|
let jobPool: Pool | null = null;
|
||||||
|
let notificationsService: NotificationsService | null = null;
|
||||||
|
|
||||||
export function setGracePeriodJobPool(pool: Pool): void {
|
export function setGracePeriodJobPool(pool: Pool): void {
|
||||||
jobPool = pool;
|
jobPool = pool;
|
||||||
|
notificationsService = new NotificationsService();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GracePeriodResult {
|
interface GracePeriodResult {
|
||||||
@@ -36,13 +39,21 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
|||||||
const client = await jobPool.connect();
|
const client = await jobPool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find subscriptions with expired grace periods
|
// Find subscriptions with expired grace periods (join with user_profiles for notification)
|
||||||
const query = `
|
const query = `
|
||||||
SELECT id, user_id, tier, stripe_subscription_id
|
SELECT
|
||||||
FROM subscriptions
|
s.id,
|
||||||
WHERE status = 'past_due'
|
s.user_id,
|
||||||
AND grace_period_end < NOW()
|
s.tier,
|
||||||
ORDER BY grace_period_end ASC
|
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);
|
const queryResult = await client.query(query);
|
||||||
@@ -99,6 +110,34 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
|||||||
userId: subscription.user_id,
|
userId: subscription.user_id,
|
||||||
previousTier: subscription.tier,
|
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) {
|
} catch (error: any) {
|
||||||
// Rollback transaction on error
|
// Rollback transaction on error
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
|
|||||||
Reference in New Issue
Block a user