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>
966 lines
28 KiB
TypeScript
966 lines
28 KiB
TypeScript
/**
|
|
* @ai-summary Subscription business logic and webhook handling
|
|
* @ai-context Manages subscription lifecycle, Stripe integration, and tier syncing
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
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,
|
|
SubscriptionTier,
|
|
BillingCycle,
|
|
SubscriptionStatus,
|
|
UpdateSubscriptionData,
|
|
NeedsVehicleSelectionResponse,
|
|
} from './subscriptions.types';
|
|
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
|
|
|
|
interface StripeWebhookEvent {
|
|
id: string;
|
|
type: string;
|
|
data: {
|
|
object: any;
|
|
};
|
|
}
|
|
|
|
export class SubscriptionsService {
|
|
private userProfileRepository: UserProfileRepository;
|
|
private vehiclesRepository: VehiclesRepository;
|
|
private notificationsService: NotificationsService;
|
|
|
|
constructor(
|
|
private repository: SubscriptionsRepository,
|
|
private stripeClient: StripeClient,
|
|
pool: Pool
|
|
) {
|
|
this.userProfileRepository = new UserProfileRepository(pool);
|
|
this.vehiclesRepository = new VehiclesRepository(pool);
|
|
this.notificationsService = new NotificationsService();
|
|
}
|
|
|
|
/**
|
|
* Get current subscription for user
|
|
*/
|
|
async getSubscription(userId: string): Promise<SubscriptionResponse | null> {
|
|
try {
|
|
const subscription = await this.repository.findByUserId(userId);
|
|
|
|
if (!subscription) {
|
|
return null;
|
|
}
|
|
|
|
return this.mapToResponse(subscription);
|
|
} catch (error: any) {
|
|
logger.error('Failed to get subscription', {
|
|
userId,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user needs to make a vehicle selection after auto-downgrade
|
|
* Returns true if: user is on free tier, has >2 vehicles, and hasn't made selections
|
|
*/
|
|
async checkNeedsVehicleSelection(userId: string): Promise<NeedsVehicleSelectionResponse> {
|
|
const FREE_TIER_VEHICLE_LIMIT = 2;
|
|
|
|
try {
|
|
// Get current subscription
|
|
const subscription = await this.repository.findByUserId(userId);
|
|
|
|
// No subscription or not on free tier = no selection needed
|
|
if (!subscription || subscription.tier !== 'free') {
|
|
return {
|
|
needsSelection: false,
|
|
vehicleCount: 0,
|
|
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
|
|
};
|
|
}
|
|
|
|
// Count user's active vehicles
|
|
const vehicleCount = await this.vehiclesRepository.countByUserId(userId);
|
|
|
|
// If within limit, no selection needed
|
|
if (vehicleCount <= FREE_TIER_VEHICLE_LIMIT) {
|
|
return {
|
|
needsSelection: false,
|
|
vehicleCount,
|
|
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
|
|
};
|
|
}
|
|
|
|
// Check if user already has vehicle selections
|
|
const existingSelections = await this.repository.findVehicleSelectionsByUserId(userId);
|
|
|
|
// If user already made selections, no selection needed
|
|
if (existingSelections.length > 0) {
|
|
return {
|
|
needsSelection: false,
|
|
vehicleCount,
|
|
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
|
|
};
|
|
}
|
|
|
|
// User is on free tier, has >2 vehicles, and hasn't made selections
|
|
return {
|
|
needsSelection: true,
|
|
vehicleCount,
|
|
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
|
|
};
|
|
} catch (error: any) {
|
|
logger.error('Failed to check needs vehicle selection', {
|
|
userId,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create new subscription (Stripe customer + initial free tier record)
|
|
*/
|
|
async createSubscription(userId: string, email: string): Promise<Subscription> {
|
|
try {
|
|
logger.info('Creating subscription', { userId, email });
|
|
|
|
// Check if user already has a subscription
|
|
const existing = await this.repository.findByUserId(userId);
|
|
if (existing) {
|
|
logger.warn('User already has a subscription', { userId, subscriptionId: existing.id });
|
|
return existing;
|
|
}
|
|
|
|
// Create Stripe customer
|
|
const stripeCustomer = await this.stripeClient.createCustomer(email);
|
|
|
|
// Create subscription record with free tier
|
|
const subscription = await this.repository.create({
|
|
userId,
|
|
stripeCustomerId: stripeCustomer.id,
|
|
tier: 'free',
|
|
billingCycle: 'monthly',
|
|
});
|
|
|
|
logger.info('Subscription created', {
|
|
subscriptionId: subscription.id,
|
|
userId,
|
|
stripeCustomerId: stripeCustomer.id,
|
|
});
|
|
|
|
return subscription;
|
|
} catch (error: any) {
|
|
logger.error('Failed to create subscription', {
|
|
userId,
|
|
email,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upgrade from current tier to new tier
|
|
*/
|
|
async upgradeSubscription(
|
|
userId: string,
|
|
newTier: 'pro' | 'enterprise',
|
|
billingCycle: 'monthly' | 'yearly',
|
|
paymentMethodId: string
|
|
): Promise<Subscription> {
|
|
try {
|
|
logger.info('Upgrading subscription', { userId, newTier, billingCycle });
|
|
|
|
// Get current subscription
|
|
const currentSubscription = await this.repository.findByUserId(userId);
|
|
if (!currentSubscription) {
|
|
throw new Error('No subscription found for user');
|
|
}
|
|
|
|
// Determine price ID from environment variables
|
|
const priceId = this.getPriceId(newTier, billingCycle);
|
|
|
|
// Create or update Stripe subscription
|
|
const stripeSubscription = await this.stripeClient.createSubscription(
|
|
currentSubscription.stripeCustomerId,
|
|
priceId,
|
|
paymentMethodId
|
|
);
|
|
|
|
// Update subscription record
|
|
const updateData: UpdateSubscriptionData = {
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
tier: newTier,
|
|
billingCycle,
|
|
status: this.mapStripeStatus(stripeSubscription.status),
|
|
currentPeriodStart: new Date(stripeSubscription.currentPeriodStart * 1000),
|
|
currentPeriodEnd: new Date(stripeSubscription.currentPeriodEnd * 1000),
|
|
cancelAtPeriodEnd: false,
|
|
};
|
|
|
|
const updatedSubscription = await this.repository.update(
|
|
currentSubscription.id,
|
|
updateData
|
|
);
|
|
|
|
if (!updatedSubscription) {
|
|
throw new Error('Failed to update subscription');
|
|
}
|
|
|
|
// 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,
|
|
newTier,
|
|
billingCycle,
|
|
});
|
|
|
|
return updatedSubscription;
|
|
} catch (error: any) {
|
|
logger.error('Failed to upgrade subscription', {
|
|
userId,
|
|
newTier,
|
|
billingCycle,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel subscription (schedules for end of period)
|
|
*/
|
|
async cancelSubscription(userId: string): Promise<Subscription> {
|
|
try {
|
|
logger.info('Canceling subscription', { userId });
|
|
|
|
// Get current subscription
|
|
const currentSubscription = await this.repository.findByUserId(userId);
|
|
if (!currentSubscription) {
|
|
throw new Error('No subscription found for user');
|
|
}
|
|
|
|
if (!currentSubscription.stripeSubscriptionId) {
|
|
throw new Error('No active Stripe subscription to cancel');
|
|
}
|
|
|
|
// Cancel at period end in Stripe
|
|
await this.stripeClient.cancelSubscription(
|
|
currentSubscription.stripeSubscriptionId,
|
|
true
|
|
);
|
|
|
|
// Update subscription record
|
|
const updatedSubscription = await this.repository.update(currentSubscription.id, {
|
|
cancelAtPeriodEnd: true,
|
|
});
|
|
|
|
if (!updatedSubscription) {
|
|
throw new Error('Failed to update subscription');
|
|
}
|
|
|
|
logger.info('Subscription canceled', {
|
|
subscriptionId: updatedSubscription.id,
|
|
userId,
|
|
});
|
|
|
|
return updatedSubscription;
|
|
} catch (error: any) {
|
|
logger.error('Failed to cancel subscription', {
|
|
userId,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reactivate a pending cancellation
|
|
*/
|
|
async reactivateSubscription(userId: string): Promise<Subscription> {
|
|
try {
|
|
logger.info('Reactivating subscription', { userId });
|
|
|
|
// Get current subscription
|
|
const currentSubscription = await this.repository.findByUserId(userId);
|
|
if (!currentSubscription) {
|
|
throw new Error('No subscription found for user');
|
|
}
|
|
|
|
if (!currentSubscription.stripeSubscriptionId) {
|
|
throw new Error('No active Stripe subscription to reactivate');
|
|
}
|
|
|
|
if (!currentSubscription.cancelAtPeriodEnd) {
|
|
logger.warn('Subscription is not pending cancellation', {
|
|
subscriptionId: currentSubscription.id,
|
|
userId,
|
|
});
|
|
return currentSubscription;
|
|
}
|
|
|
|
// Reactivate in Stripe (remove cancel_at_period_end flag)
|
|
await this.stripeClient.cancelSubscription(
|
|
currentSubscription.stripeSubscriptionId,
|
|
false
|
|
);
|
|
|
|
// Update subscription record
|
|
const updatedSubscription = await this.repository.update(currentSubscription.id, {
|
|
cancelAtPeriodEnd: false,
|
|
});
|
|
|
|
if (!updatedSubscription) {
|
|
throw new Error('Failed to update subscription');
|
|
}
|
|
|
|
logger.info('Subscription reactivated', {
|
|
subscriptionId: updatedSubscription.id,
|
|
userId,
|
|
});
|
|
|
|
return updatedSubscription;
|
|
} catch (error: any) {
|
|
logger.error('Failed to reactivate subscription', {
|
|
userId,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downgrade subscription to a lower tier with vehicle selection
|
|
*/
|
|
async downgradeSubscription(
|
|
userId: string,
|
|
targetTier: SubscriptionTier,
|
|
vehicleIdsToKeep: string[]
|
|
): Promise<Subscription> {
|
|
try {
|
|
logger.info('Downgrading subscription', { userId, targetTier, vehicleCount: vehicleIdsToKeep.length });
|
|
|
|
// Get current subscription
|
|
const currentSubscription = await this.repository.findByUserId(userId);
|
|
if (!currentSubscription) {
|
|
throw new Error('No subscription found for user');
|
|
}
|
|
|
|
// Define tier limits
|
|
const tierLimits: Record<SubscriptionTier, number | null> = {
|
|
free: 2,
|
|
pro: 5,
|
|
enterprise: null, // unlimited
|
|
};
|
|
|
|
const targetLimit = tierLimits[targetTier];
|
|
|
|
// Validate vehicle selection count
|
|
if (targetLimit !== null && vehicleIdsToKeep.length > targetLimit) {
|
|
throw new Error(`Vehicle selection exceeds tier limit. ${targetTier} tier allows ${targetLimit} vehicles, but ${vehicleIdsToKeep.length} were selected.`);
|
|
}
|
|
|
|
// Cancel current Stripe subscription if exists (downgrading from paid tier)
|
|
if (currentSubscription.stripeSubscriptionId) {
|
|
await this.stripeClient.cancelSubscription(
|
|
currentSubscription.stripeSubscriptionId,
|
|
false // Cancel immediately, not at period end
|
|
);
|
|
}
|
|
|
|
// Clear previous vehicle selections
|
|
await this.repository.deleteVehicleSelectionsByUserId(userId);
|
|
|
|
// Save new vehicle selections
|
|
for (const vehicleId of vehicleIdsToKeep) {
|
|
await this.repository.createVehicleSelection({
|
|
userId,
|
|
vehicleId,
|
|
});
|
|
}
|
|
|
|
// Update subscription tier
|
|
const updateData: UpdateSubscriptionData = {
|
|
tier: targetTier,
|
|
status: 'active',
|
|
stripeSubscriptionId: undefined,
|
|
billingCycle: undefined,
|
|
cancelAtPeriodEnd: false,
|
|
};
|
|
|
|
const updatedSubscription = await this.repository.update(
|
|
currentSubscription.id,
|
|
updateData
|
|
);
|
|
|
|
if (!updatedSubscription) {
|
|
throw new Error('Failed to update subscription');
|
|
}
|
|
|
|
// 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,
|
|
targetTier,
|
|
vehicleCount: vehicleIdsToKeep.length,
|
|
});
|
|
|
|
return updatedSubscription;
|
|
} catch (error: any) {
|
|
logger.error('Failed to downgrade subscription', {
|
|
userId,
|
|
targetTier,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming Stripe webhook event
|
|
*/
|
|
async handleWebhookEvent(payload: Buffer, signature: string): Promise<void> {
|
|
try {
|
|
// Construct and verify webhook event
|
|
const event = this.stripeClient.constructWebhookEvent(
|
|
payload,
|
|
signature
|
|
) as StripeWebhookEvent;
|
|
|
|
logger.info('Processing webhook event', {
|
|
eventId: event.id,
|
|
eventType: event.type,
|
|
});
|
|
|
|
// Check idempotency - skip if we've already processed this event
|
|
const existingEvent = await this.repository.findEventByStripeId(event.id);
|
|
if (existingEvent) {
|
|
logger.info('Event already processed, skipping', { eventId: event.id });
|
|
return;
|
|
}
|
|
|
|
// Process based on event type
|
|
switch (event.type) {
|
|
case 'customer.subscription.created':
|
|
await this.handleSubscriptionCreated(event);
|
|
break;
|
|
case 'customer.subscription.updated':
|
|
await this.handleSubscriptionUpdated(event);
|
|
break;
|
|
case 'customer.subscription.deleted':
|
|
await this.handleSubscriptionDeleted(event);
|
|
break;
|
|
case 'invoice.payment_succeeded':
|
|
await this.handlePaymentSucceeded(event);
|
|
break;
|
|
case 'invoice.payment_failed':
|
|
await this.handlePaymentFailed(event);
|
|
break;
|
|
case 'payment_intent.succeeded':
|
|
await this.handleDonationPaymentSucceeded(event);
|
|
break;
|
|
default:
|
|
logger.info('Unhandled webhook event type', { eventType: event.type });
|
|
}
|
|
|
|
logger.info('Webhook event processed', { eventId: event.id, eventType: event.type });
|
|
} catch (error: any) {
|
|
logger.error('Failed to handle webhook event', {
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ========== Private Helper Methods ==========
|
|
|
|
/**
|
|
* Handle customer.subscription.created webhook
|
|
*/
|
|
private async handleSubscriptionCreated(event: StripeWebhookEvent): Promise<void> {
|
|
const stripeSubscription = event.data.object;
|
|
|
|
// Find subscription by Stripe customer ID
|
|
const subscription = await this.repository.findByStripeCustomerId(
|
|
stripeSubscription.customer
|
|
);
|
|
|
|
if (!subscription) {
|
|
logger.warn('Subscription not found for Stripe customer', {
|
|
stripeCustomerId: stripeSubscription.customer,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Update subscription with Stripe subscription ID
|
|
await this.repository.update(subscription.id, {
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
status: this.mapStripeStatus(stripeSubscription.status),
|
|
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
|
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
|
});
|
|
|
|
// Log event
|
|
await this.repository.createEvent({
|
|
subscriptionId: subscription.id,
|
|
stripeEventId: event.id,
|
|
eventType: event.type,
|
|
payload: event.data.object,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle customer.subscription.updated webhook
|
|
*/
|
|
private async handleSubscriptionUpdated(event: StripeWebhookEvent): Promise<void> {
|
|
const stripeSubscription = event.data.object;
|
|
|
|
// Find subscription by Stripe subscription ID
|
|
const subscription = await this.repository.findByStripeSubscriptionId(
|
|
stripeSubscription.id
|
|
);
|
|
|
|
if (!subscription) {
|
|
logger.warn('Subscription not found for Stripe subscription', {
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Determine tier from price metadata or plan
|
|
const tier = this.determineTierFromStripeSubscription(stripeSubscription);
|
|
|
|
// Update subscription
|
|
const updateData: UpdateSubscriptionData = {
|
|
status: this.mapStripeStatus(stripeSubscription.status),
|
|
tier,
|
|
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
|
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
|
|
};
|
|
|
|
await this.repository.update(subscription.id, updateData);
|
|
|
|
// Sync tier to user profile
|
|
await this.syncTierToUserProfile(subscription.userId, tier);
|
|
|
|
// Log event
|
|
await this.repository.createEvent({
|
|
subscriptionId: subscription.id,
|
|
stripeEventId: event.id,
|
|
eventType: event.type,
|
|
payload: event.data.object,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle customer.subscription.deleted webhook
|
|
*/
|
|
private async handleSubscriptionDeleted(event: StripeWebhookEvent): Promise<void> {
|
|
const stripeSubscription = event.data.object;
|
|
|
|
// Find subscription by Stripe subscription ID
|
|
const subscription = await this.repository.findByStripeSubscriptionId(
|
|
stripeSubscription.id
|
|
);
|
|
|
|
if (!subscription) {
|
|
logger.warn('Subscription not found for Stripe subscription', {
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Update subscription to canceled
|
|
await this.repository.update(subscription.id, {
|
|
status: 'canceled',
|
|
});
|
|
|
|
// Downgrade tier to free
|
|
await this.syncTierToUserProfile(subscription.userId, 'free');
|
|
|
|
// Log event
|
|
await this.repository.createEvent({
|
|
subscriptionId: subscription.id,
|
|
stripeEventId: event.id,
|
|
eventType: event.type,
|
|
payload: event.data.object,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle invoice.payment_succeeded webhook
|
|
*/
|
|
private async handlePaymentSucceeded(event: StripeWebhookEvent): Promise<void> {
|
|
const invoice = event.data.object;
|
|
|
|
// Find subscription by Stripe subscription ID
|
|
const subscription = await this.repository.findByStripeSubscriptionId(
|
|
invoice.subscription
|
|
);
|
|
|
|
if (!subscription) {
|
|
logger.warn('Subscription not found for Stripe subscription', {
|
|
stripeSubscriptionId: invoice.subscription,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Clear grace period and mark as active
|
|
await this.repository.update(subscription.id, {
|
|
status: 'active',
|
|
gracePeriodEnd: undefined,
|
|
});
|
|
|
|
// Log event
|
|
await this.repository.createEvent({
|
|
subscriptionId: subscription.id,
|
|
stripeEventId: event.id,
|
|
eventType: event.type,
|
|
payload: event.data.object,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle invoice.payment_failed webhook
|
|
*/
|
|
private async handlePaymentFailed(event: StripeWebhookEvent): Promise<void> {
|
|
const invoice = event.data.object;
|
|
|
|
// Find subscription by Stripe subscription ID
|
|
const subscription = await this.repository.findByStripeSubscriptionId(
|
|
invoice.subscription
|
|
);
|
|
|
|
if (!subscription) {
|
|
logger.warn('Subscription not found for Stripe subscription', {
|
|
stripeSubscriptionId: invoice.subscription,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Set grace period (30 days from now)
|
|
const gracePeriodEnd = new Date();
|
|
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 30);
|
|
|
|
await this.repository.update(subscription.id, {
|
|
status: 'past_due',
|
|
gracePeriodEnd,
|
|
});
|
|
|
|
// Log event
|
|
await this.repository.createEvent({
|
|
subscriptionId: subscription.id,
|
|
stripeEventId: event.id,
|
|
eventType: event.type,
|
|
payload: event.data.object,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle payment_intent.succeeded webhook for donations
|
|
*/
|
|
private async handleDonationPaymentSucceeded(event: StripeWebhookEvent): Promise<void> {
|
|
const paymentIntent = event.data.object;
|
|
|
|
// Check if this is a donation (based on metadata)
|
|
if (paymentIntent.metadata?.type !== 'donation') {
|
|
logger.info('PaymentIntent is not a donation, skipping', {
|
|
paymentIntentId: paymentIntent.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Find donation by payment intent ID
|
|
const donation = await this.repository.findDonationByPaymentIntentId(
|
|
paymentIntent.id
|
|
);
|
|
|
|
if (!donation) {
|
|
logger.warn('Donation not found for payment intent', {
|
|
paymentIntentId: paymentIntent.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Update donation status to succeeded
|
|
await this.repository.updateDonation(donation.id, {
|
|
status: 'succeeded',
|
|
});
|
|
|
|
logger.info('Donation marked as succeeded via webhook', {
|
|
donationId: donation.id,
|
|
paymentIntentId: paymentIntent.id,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
|
|
try {
|
|
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
|
|
logger.info('Subscription tier synced to user profile', { userId, tier });
|
|
} catch (error: any) {
|
|
logger.error('Failed to sync tier to user profile', {
|
|
userId,
|
|
tier,
|
|
error: error.message,
|
|
});
|
|
// Don't throw - we don't want to fail the subscription operation if sync fails
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Stripe price ID from environment variables
|
|
*/
|
|
private getPriceId(tier: 'pro' | 'enterprise', billingCycle: BillingCycle): string {
|
|
const envVarMap: Record<string, string> = {
|
|
'pro-monthly': 'STRIPE_PRO_MONTHLY_PRICE_ID',
|
|
'pro-yearly': 'STRIPE_PRO_YEARLY_PRICE_ID',
|
|
'enterprise-monthly': 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID',
|
|
'enterprise-yearly': 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID',
|
|
};
|
|
|
|
const envVar = envVarMap[`${tier}-${billingCycle}`];
|
|
const priceId = process.env[envVar];
|
|
|
|
if (!priceId) {
|
|
throw new Error(`Missing environment variable: ${envVar}`);
|
|
}
|
|
|
|
return priceId;
|
|
}
|
|
|
|
/**
|
|
* Map Stripe subscription status to our status type
|
|
*/
|
|
private mapStripeStatus(stripeStatus: string): SubscriptionStatus {
|
|
switch (stripeStatus) {
|
|
case 'active':
|
|
case 'trialing':
|
|
return 'active';
|
|
case 'past_due':
|
|
return 'past_due';
|
|
case 'canceled':
|
|
case 'incomplete_expired':
|
|
return 'canceled';
|
|
case 'unpaid':
|
|
return 'unpaid';
|
|
default:
|
|
logger.warn('Unknown Stripe status, defaulting to canceled', { stripeStatus });
|
|
return 'canceled';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine tier from Stripe subscription object
|
|
*/
|
|
private determineTierFromStripeSubscription(stripeSubscription: any): SubscriptionTier {
|
|
// Try to extract tier from price metadata or plan
|
|
const priceId = stripeSubscription.items?.data?.[0]?.price?.id;
|
|
|
|
if (!priceId) {
|
|
logger.warn('Could not determine tier from Stripe subscription, defaulting to free');
|
|
return 'free';
|
|
}
|
|
|
|
// Check environment variables to match price ID to tier
|
|
if (
|
|
priceId === process.env.STRIPE_PRO_MONTHLY_PRICE_ID ||
|
|
priceId === process.env.STRIPE_PRO_YEARLY_PRICE_ID
|
|
) {
|
|
return 'pro';
|
|
}
|
|
|
|
if (
|
|
priceId === process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID ||
|
|
priceId === process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID
|
|
) {
|
|
return 'enterprise';
|
|
}
|
|
|
|
logger.warn('Unknown price ID, defaulting to free', { priceId });
|
|
return 'free';
|
|
}
|
|
|
|
/**
|
|
* Admin override of subscription tier (bypasses Stripe)
|
|
* Updates both subscriptions.tier and user_profiles.subscription_tier atomically
|
|
* Creates subscription record if user doesn't have one
|
|
*
|
|
* Note: Admin feature depends on Subscriptions for tier management
|
|
* This is intentional - admin has oversight capabilities
|
|
*/
|
|
async adminOverrideTier(userId: string, newTier: SubscriptionTier): Promise<Subscription> {
|
|
const pool = this.repository.getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
logger.info('Admin overriding subscription tier', { userId, newTier });
|
|
|
|
// Check if user has a subscription record
|
|
const existingSubscription = await this.repository.findByUserId(userId);
|
|
const previousTier: SubscriptionTier = existingSubscription?.tier || 'free';
|
|
|
|
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);
|
|
} else {
|
|
// Update existing subscription tier
|
|
const updated = await this.repository.updateTierByUserId(userId, newTier, client);
|
|
if (!updated) {
|
|
throw new Error('Failed to update subscription tier');
|
|
}
|
|
subscription = updated;
|
|
}
|
|
|
|
// Sync tier to user_profiles table (within same transaction)
|
|
await client.query(
|
|
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2',
|
|
[newTier, userId]
|
|
);
|
|
|
|
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,
|
|
subscriptionId: subscription.id,
|
|
});
|
|
|
|
return subscription;
|
|
} catch (error: any) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Failed to admin override subscription tier', {
|
|
userId,
|
|
newTier,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get invoices for a user's subscription
|
|
*/
|
|
async getInvoices(userId: string): Promise<any[]> {
|
|
try {
|
|
const subscription = await this.repository.findByUserId(userId);
|
|
if (!subscription?.stripeCustomerId) {
|
|
return [];
|
|
}
|
|
return this.stripeClient.listInvoices(subscription.stripeCustomerId);
|
|
} catch (error: any) {
|
|
logger.error('Failed to get invoices', {
|
|
userId,
|
|
error: error.message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map subscription entity to response DTO
|
|
*/
|
|
private mapToResponse(subscription: Subscription): SubscriptionResponse {
|
|
return {
|
|
id: subscription.id,
|
|
userId: subscription.userId,
|
|
stripeCustomerId: subscription.stripeCustomerId,
|
|
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
|
tier: subscription.tier,
|
|
billingCycle: subscription.billingCycle,
|
|
status: subscription.status,
|
|
currentPeriodStart: subscription.currentPeriodStart?.toISOString(),
|
|
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString(),
|
|
gracePeriodEnd: subscription.gracePeriodEnd?.toISOString(),
|
|
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
createdAt: subscription.createdAt.toISOString(),
|
|
updatedAt: subscription.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|