Files
motovaultpro/backend/src/features/subscriptions/domain/subscriptions.service.ts
Eric Gullickson cc2898f6ff
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
feat: send notifications when subscription tier changes (refs #59)
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>
2026-01-31 19:50:34 -06:00

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(),
};
}
}