feat: phase1 platform upgrade with moderation, dev payments, admin panel and landing updates

This commit is contained in:
root
2026-02-06 17:26:53 +00:00
parent 4ca66ea896
commit 979adb9d3d
54 changed files with 2687 additions and 318 deletions

View File

@ -0,0 +1,22 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
@Controller('payments')
@ApiBearerAuth()
export class DevPaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Post('dev/yoomoney/complete')
@ApiOperation({ summary: 'Complete DEV YooMoney payment (mock flow)' })
async completeDevYoomoneyPayment(
@CurrentUser() user: User,
@Body('courseId') courseId: string,
) {
return this.paymentsService.completeDevYoomoneyPayment(user.id, courseId);
}
}

View File

@ -3,9 +3,10 @@ import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
import { WebhooksController } from './webhooks.controller';
import { DevPaymentsController } from './dev-payments.controller';
@Module({
controllers: [PaymentsController, WebhooksController],
controllers: [PaymentsController, WebhooksController, DevPaymentsController],
providers: [PaymentsService, StripeService],
exports: [PaymentsService, StripeService],
})

View File

@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service';
import { StripeService } from './stripe.service';
import { SubscriptionTier } from '@coursecraft/database';
import { PaymentMode, PaymentProvider, SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
@Injectable()
@ -78,6 +78,9 @@ export class PaymentsService {
if (!course) {
throw new NotFoundException('Course not found');
}
if (!course.isPublished) {
throw new ForbiddenException('Course is not available for purchase');
}
if (!course.price) {
throw new Error('Course is free, checkout is not required');
}
@ -85,8 +88,26 @@ export class PaymentsService {
throw new Error('Course is already purchased');
}
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const paymentMode = this.getPaymentMode();
if (paymentMode === PaymentMode.DEV) {
await this.handleCoursePurchaseCompleted({
userId,
courseId,
provider: PaymentProvider.YOOMONEY,
mode: PaymentMode.DEV,
eventCode: 'DEV_PAYMENT_SUCCESS',
});
console.log('DEV_PAYMENT_SUCCESS', { userId, courseId, provider: 'YOOMONEY' });
return {
url: `${appUrl}/courses/${courseId}?purchase=success&devPayment=1`,
mode: 'DEV',
provider: 'YOOMONEY',
};
}
const { stripeCustomerId } = await this.getOrCreateStripeCustomer(userId);
const unitAmount = Math.round(Number(course.price) * 100);
const session = await this.stripeService.createOneTimeCheckoutSession({
@ -104,7 +125,23 @@ export class PaymentsService {
},
});
return { url: session.url };
return { url: session.url, mode: 'PROD', provider: 'STRIPE' };
}
async completeDevYoomoneyPayment(userId: string, courseId: string) {
await this.handleCoursePurchaseCompleted({
userId,
courseId,
provider: PaymentProvider.YOOMONEY,
mode: PaymentMode.DEV,
eventCode: 'DEV_PAYMENT_SUCCESS',
});
return {
success: true,
eventCode: 'DEV_PAYMENT_SUCCESS',
provider: 'YOOMONEY',
mode: 'DEV',
};
}
async createPortalSession(userId: string) {
@ -169,6 +206,9 @@ export class PaymentsService {
await this.handleCoursePurchaseCompleted({
userId: metadata.userId,
courseId: metadata.courseId || '',
provider: PaymentProvider.STRIPE,
mode: PaymentMode.PROD,
eventCode: 'STRIPE_PAYMENT_SUCCESS',
});
return;
}
@ -201,7 +241,13 @@ export class PaymentsService {
});
}
private async handleCoursePurchaseCompleted(params: { userId: string; courseId: string }) {
private async handleCoursePurchaseCompleted(params: {
userId: string;
courseId: string;
provider?: PaymentProvider;
mode?: PaymentMode;
eventCode?: string;
}) {
const { userId, courseId } = params;
if (!courseId) return;
@ -219,11 +265,27 @@ export class PaymentsService {
amount: course.price,
currency: course.currency,
status: 'completed',
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
eventCode: params.eventCode || null,
metadata: {
eventCode: params.eventCode || null,
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
},
},
update: {
status: 'completed',
amount: course.price,
currency: course.currency,
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
eventCode: params.eventCode || null,
metadata: {
eventCode: params.eventCode || null,
provider: params.provider || PaymentProvider.STRIPE,
mode: params.mode || PaymentMode.PROD,
},
},
});
@ -241,6 +303,11 @@ export class PaymentsService {
});
}
private getPaymentMode(): PaymentMode {
const raw = (this.configService.get<string>('PAYMENT_MODE') || 'PROD').toUpperCase();
return raw === PaymentMode.DEV ? PaymentMode.DEV : PaymentMode.PROD;
}
private async getOrCreateStripeCustomer(userId: string): Promise<{ stripeCustomerId: string; email: string; name: string | null }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },