## Database Migrations (18 new) - Migrate all primary keys from SERIAL to UUID - Add soft delete (deleted_at) to all 19 entities - Add missing indexes for performance optimization - Add CHECK constraints for data validation - Add user audit fields (last_login_at, timezone, locale) - Add weather station location fields (latitude, longitude, elevation) - Add foreign key relationships (CameraDevice→Device, ValidatedEvent→WeatherStation) - Prepare private key encryption fields ## Backend Entity Updates - All entities updated with UUID primary keys - Added @DeleteDateColumn for soft delete support - Updated relations and foreign key types ## Backend Service/Controller Updates - Changed ID parameters from number to string (UUID) - Removed ParseIntPipe from controllers - Updated TypeORM queries for string IDs ## Frontend Updates - Updated all service interfaces to use string IDs - Fixed CameraDevice.location as JSONB object - Updated weather.ts with new fields (elevation, timezone) - Added Supabase integration hooks and lib - Fixed chart components for new data structure ## Cleanup - Removed deprecated .claude/agents configuration files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
294 lines
9.3 KiB
TypeScript
294 lines
9.3 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, FindManyOptions } from 'typeorm';
|
|
import { SubscriptionPlan } from '../entities/subscription-plan.entity';
|
|
import { UserSubscription } from '../entities/user-subscription.entity';
|
|
import { SubscriptionHistory } from '../entities/subscription-history.entity';
|
|
import { PaymentRecord } from '../entities/payment-record.entity';
|
|
|
|
export interface SubscriptionQuery {
|
|
page?: number;
|
|
limit?: number;
|
|
status?: string;
|
|
planId?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
export interface PlanStats {
|
|
planId: string;
|
|
planName: string;
|
|
totalSubscriptions: number;
|
|
activeSubscriptions: number;
|
|
revenue: number;
|
|
popularityRank: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SubscriptionService {
|
|
constructor(
|
|
@InjectRepository(SubscriptionPlan)
|
|
private subscriptionPlanRepository: Repository<SubscriptionPlan>,
|
|
@InjectRepository(UserSubscription)
|
|
private userSubscriptionRepository: Repository<UserSubscription>,
|
|
@InjectRepository(SubscriptionHistory)
|
|
private subscriptionHistoryRepository: Repository<SubscriptionHistory>,
|
|
@InjectRepository(PaymentRecord)
|
|
private paymentRecordRepository: Repository<PaymentRecord>,
|
|
) {}
|
|
|
|
// Subscription Plans
|
|
async getAllPlans() {
|
|
const plans = await this.subscriptionPlanRepository.find({
|
|
where: { isActive: true },
|
|
order: {
|
|
price: 'ASC',
|
|
isPopular: 'DESC',
|
|
},
|
|
});
|
|
|
|
return plans;
|
|
}
|
|
|
|
async getPlan(id: string) {
|
|
const plan = await this.subscriptionPlanRepository.findOne({
|
|
where: { id },
|
|
});
|
|
|
|
if (!plan) {
|
|
throw new NotFoundException(`Subscription plan with ID ${id} not found`);
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
async getPlanByPlanId(planId: string) {
|
|
const plan = await this.subscriptionPlanRepository.findOne({
|
|
where: { planId },
|
|
});
|
|
|
|
if (!plan) {
|
|
throw new NotFoundException(`Subscription plan with plan ID ${planId} not found`);
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
// User Subscriptions
|
|
async getUserSubscriptions(query: SubscriptionQuery = {}) {
|
|
const { page = 1, limit = 10, status, planId, userId } = query;
|
|
const skip = (page - 1) * limit;
|
|
|
|
const queryBuilder = this.userSubscriptionRepository
|
|
.createQueryBuilder('subscription')
|
|
.leftJoinAndSelect('subscription.subscriptionPlan', 'plan')
|
|
.leftJoinAndSelect('subscription.userProfile', 'user')
|
|
.orderBy('subscription.createdAt', 'DESC')
|
|
.skip(skip)
|
|
.take(limit);
|
|
|
|
if (status) {
|
|
queryBuilder.andWhere('subscription.status = :status', { status });
|
|
}
|
|
|
|
if (planId) {
|
|
queryBuilder.andWhere('plan.planId = :planId', { planId });
|
|
}
|
|
|
|
if (userId) {
|
|
queryBuilder.andWhere('subscription.userProfileId = :userId', { userId });
|
|
}
|
|
|
|
const [subscriptions, total] = await queryBuilder.getManyAndCount();
|
|
|
|
return {
|
|
subscriptions,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
async getUserSubscription(userId: string) {
|
|
const subscription = await this.userSubscriptionRepository
|
|
.createQueryBuilder('subscription')
|
|
.leftJoinAndSelect('subscription.subscriptionPlan', 'plan')
|
|
.leftJoinAndSelect('subscription.userProfile', 'user')
|
|
.where('subscription.userProfileId = :userId', { userId })
|
|
.andWhere('subscription.status IN (:...statuses)', { statuses: ['active', 'trialing'] })
|
|
.getOne();
|
|
|
|
return subscription;
|
|
}
|
|
|
|
async createSubscription(userId: string, planId: string, subscriptionData: Partial<UserSubscription>) {
|
|
const plan = await this.getPlanByPlanId(planId);
|
|
|
|
// Check if user already has an active subscription
|
|
const existingSubscription = await this.getUserSubscription(userId);
|
|
if (existingSubscription) {
|
|
throw new BadRequestException('User already has an active subscription');
|
|
}
|
|
|
|
const subscription = this.userSubscriptionRepository.create({
|
|
userProfileId: userId,
|
|
subscriptionPlanId: plan.id,
|
|
status: 'active',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
|
...subscriptionData,
|
|
});
|
|
|
|
const savedSubscription = await this.userSubscriptionRepository.save(subscription);
|
|
|
|
// Create subscription history record
|
|
await this.createSubscriptionHistory(savedSubscription.id, 'created', null, 'active', {
|
|
planId: plan.planId,
|
|
createdBy: 'system',
|
|
});
|
|
|
|
return savedSubscription;
|
|
}
|
|
|
|
async updateSubscriptionStatus(subscriptionId: string, status: string, metadata?: any) {
|
|
const subscription = await this.userSubscriptionRepository.findOne({
|
|
where: { id: subscriptionId },
|
|
});
|
|
|
|
if (!subscription) {
|
|
throw new NotFoundException(`Subscription with ID ${subscriptionId} not found`);
|
|
}
|
|
|
|
const oldStatus = subscription.status;
|
|
subscription.status = status as any;
|
|
subscription.updatedAt = new Date();
|
|
|
|
if (status === 'canceled') {
|
|
subscription.canceledAt = new Date();
|
|
}
|
|
|
|
const updatedSubscription = await this.userSubscriptionRepository.save(subscription);
|
|
|
|
// Create history record
|
|
await this.createSubscriptionHistory(subscriptionId, 'updated', oldStatus, status, metadata);
|
|
|
|
return updatedSubscription;
|
|
}
|
|
|
|
// Subscription History
|
|
async createSubscriptionHistory(
|
|
subscriptionId: string,
|
|
action: string,
|
|
oldStatus: string | null,
|
|
newStatus: string | null,
|
|
metadata?: any,
|
|
) {
|
|
const history = new SubscriptionHistory();
|
|
history.userSubscriptionId = subscriptionId;
|
|
history.action = action as any;
|
|
history.oldStatus = oldStatus || undefined;
|
|
history.newStatus = newStatus || undefined;
|
|
history.metadata = metadata;
|
|
|
|
return await this.subscriptionHistoryRepository.save(history);
|
|
}
|
|
|
|
async getSubscriptionHistory(subscriptionId: string) {
|
|
return await this.subscriptionHistoryRepository.find({
|
|
where: { userSubscriptionId: subscriptionId },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
// Payment Records
|
|
async createPaymentRecord(subscriptionId: string, paymentData: Partial<PaymentRecord>) {
|
|
const payment = this.paymentRecordRepository.create({
|
|
userSubscriptionId: subscriptionId,
|
|
...paymentData,
|
|
});
|
|
|
|
return await this.paymentRecordRepository.save(payment);
|
|
}
|
|
|
|
async getPaymentRecords(subscriptionId: string) {
|
|
return await this.paymentRecordRepository.find({
|
|
where: { userSubscriptionId: subscriptionId },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
// Statistics and Analytics
|
|
async getSubscriptionStats() {
|
|
const totalPlans = await this.subscriptionPlanRepository.count({ where: { isActive: true } });
|
|
|
|
const totalSubscriptions = await this.userSubscriptionRepository.count();
|
|
const activeSubscriptions = await this.userSubscriptionRepository.count({
|
|
where: { status: 'active' },
|
|
});
|
|
const trialSubscriptions = await this.userSubscriptionRepository.count({
|
|
where: { status: 'trialing' },
|
|
});
|
|
const canceledSubscriptions = await this.userSubscriptionRepository.count({
|
|
where: { status: 'canceled' },
|
|
});
|
|
|
|
// Calculate total revenue from successful payments
|
|
const revenueResult = await this.paymentRecordRepository
|
|
.createQueryBuilder('payment')
|
|
.select('SUM(payment.amount)', 'total')
|
|
.where('payment.status = :status', { status: 'succeeded' })
|
|
.getRawOne();
|
|
|
|
const totalRevenue = parseFloat(revenueResult?.total || '0');
|
|
|
|
// Calculate monthly recurring revenue (MRR)
|
|
const mrrResult = await this.userSubscriptionRepository
|
|
.createQueryBuilder('subscription')
|
|
.leftJoin('subscription.subscriptionPlan', 'plan')
|
|
.select('SUM(plan.price)', 'mrr')
|
|
.where('subscription.status IN (:...statuses)', { statuses: ['active', 'trialing'] })
|
|
.andWhere('plan.interval = :interval', { interval: 'month' })
|
|
.getRawOne();
|
|
|
|
const monthlyRecurringRevenue = parseFloat(mrrResult?.mrr || '0');
|
|
|
|
return {
|
|
totalPlans,
|
|
totalSubscriptions,
|
|
activeSubscriptions,
|
|
trialSubscriptions,
|
|
canceledSubscriptions,
|
|
totalRevenue,
|
|
monthlyRecurringRevenue,
|
|
};
|
|
}
|
|
|
|
async getPlanStats(): Promise<PlanStats[]> {
|
|
const plans = await this.subscriptionPlanRepository
|
|
.createQueryBuilder('plan')
|
|
.leftJoin('plan.userSubscriptions', 'subscription')
|
|
.leftJoin('subscription.paymentRecords', 'payment', 'payment.status = :paymentStatus', { paymentStatus: 'succeeded' })
|
|
.select([
|
|
'plan.planId as planId',
|
|
'plan.name as planName',
|
|
'COUNT(subscription.id) as totalSubscriptions',
|
|
'COUNT(CASE WHEN subscription.status = \'active\' THEN 1 END) as activeSubscriptions',
|
|
'COALESCE(SUM(payment.amount), 0) as revenue',
|
|
])
|
|
.where('plan.isActive = :active', { active: true })
|
|
.groupBy('plan.id, plan.planId, plan.name')
|
|
.orderBy('totalSubscriptions', 'DESC')
|
|
.getRawMany();
|
|
|
|
return plans.map((plan, index) => ({
|
|
planId: plan.planid,
|
|
planName: plan.planname,
|
|
totalSubscriptions: parseInt(plan.totalsubscriptions),
|
|
activeSubscriptions: parseInt(plan.activesubscriptions),
|
|
revenue: parseFloat(plan.revenue),
|
|
popularityRank: index + 1,
|
|
}));
|
|
}
|
|
} |