import { Injectable, NotFoundException, Optional, UnprocessableEntityException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { DramasService } from '../dramas/dramas.service'; import { UserRecord } from '../users/interfaces/user-record.interface'; import { CreateOrderDto } from './dto/create-order.dto'; import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto'; import { Order } from './entities/order.entity'; import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity'; import { OrderRecord, OrderStatus, UserEpisodeUnlockRecord, } from './interfaces/order-records.interface'; @Injectable() export class OrdersService { private orderSequence = 1; private unlockSequence = 1; private readonly orders: OrderRecord[] = []; private readonly unlocks: UserEpisodeUnlockRecord[] = []; constructor( private readonly dramasService: DramasService, @Optional() @InjectDataSource() readonly dataSource?: DataSource, ) {} async createOrder(user: UserRecord, dto: CreateOrderDto): Promise { const episode = await this.dramasService.getPublishedEpisodeOrThrow( dto.episodeId, ); if (!episode.isPaid) { throw new UnprocessableEntityException('Episode is free'); } const amount = episode.price ?? episode.unlockPrice ?? 0; const orderNo = this.createOrderNo(); const tradeOrderId = this.createTradeOrderId(); this.orderSequence++; if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Order); const entity = repository.create({ orderNo, tradeOrderId, userId: user.id, albumId: episode.albumId, episodeId: episode.id, amount, status: 'pending', }); return this.toOrderRecord(await repository.save(entity)); } const now = new Date().toISOString(); const order: OrderRecord = { id: this.orders.length + 1, orderNo, tradeOrderId, userId: user.id, albumId: episode.albumId, episodeId: episode.id, amount, status: 'pending', createdAt: now, updatedAt: now, }; this.orders.push(order); return order; } async getPlayback(user: UserRecord, episodeId: number) { const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); const isUnlocked = !episode.isPaid || (await this.isUnlocked(user.id, episodeId)); return { episodeId: episode.id, dramaId: episode.dramaId, albumId: episode.albumId, isLocked: !isUnlocked, isPaid: episode.isPaid, trialDurationSeconds: episode.trialDurationSeconds, durationSeconds: episode.durationSeconds, videoUrl: isUnlocked ? episode.videoUrl : null, }; } async handlePaymentWebhook(dto: TikTokPaymentWebhookDto) { if (this.hasDatabase()) { const orderRepository = this.dataSource.getRepository(Order); const where: Record[] = []; if (dto.orderNo) { where.push({ orderNo: dto.orderNo }); } if (dto.tradeOrderId) { where.push({ tradeOrderId: dto.tradeOrderId }); } const order = where.length ? await orderRepository.findOne({ where }) : null; if (!order) { throw new NotFoundException('Order not found'); } if (order.status === 'paid') { return { orderNo: order.orderNo, status: order.status, unlocked: await this.isUnlocked( Number(order.userId), Number(order.episodeId), ), }; } order.providerTransactionId = dto.providerTransactionId; order.tiktokOrderId = dto.tiktokOrderId ?? order.tiktokOrderId; order.status = dto.status; await orderRepository.save(order); if ( dto.status === 'paid' && !(await this.isUnlocked( Number(order.userId), Number(order.episodeId), )) ) { const unlockRepository = this.dataSource.getRepository(UserEpisodeUnlock); await unlockRepository.save( unlockRepository.create({ userId: Number(order.userId), episodeId: Number(order.episodeId), source: 'payment', orderNo: order.orderNo, }), ); } return { orderNo: order.orderNo, tradeOrderId: order.tradeOrderId, status: order.status, unlocked: await this.isUnlocked( Number(order.userId), Number(order.episodeId), ), }; } const order = this.orders.find( (item) => item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId, ); if (!order) { throw new NotFoundException('Order not found'); } if (order.status === 'paid') { return { orderNo: order.orderNo, status: order.status, unlocked: await this.isUnlocked(order.userId, order.episodeId), }; } order.providerTransactionId = dto.providerTransactionId; order.tiktokOrderId = dto.tiktokOrderId ?? order.tiktokOrderId; order.status = dto.status; order.updatedAt = new Date().toISOString(); if ( dto.status === 'paid' && !(await this.isUnlocked(order.userId, order.episodeId)) ) { this.unlocks.push({ id: this.unlockSequence++, userId: order.userId, episodeId: order.episodeId, source: 'payment', orderNo: order.orderNo, createdAt: new Date().toISOString(), }); } return { orderNo: order.orderNo, tradeOrderId: order.tradeOrderId, status: order.status, unlocked: await this.isUnlocked(order.userId, order.episodeId), }; } async hasEpisodePermission(userId: number, episodeId: number) { const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); return !episode.isPaid || (await this.isUnlocked(userId, episodeId)); } async listUnlockRecords(input: { page: number; pageSize: number; }): Promise> { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); if (this.hasDatabase()) { const [records, total] = await this.dataSource .getRepository(UserEpisodeUnlock) .findAndCount({ order: { id: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); return { list: records.map((record) => this.toUnlockRecord(record)), pagination: { page, pageSize, total }, }; } const records = [...this.unlocks].reverse(); return { list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize), pagination: { page, pageSize, total: this.unlocks.length }, }; } private async isUnlocked(userId: number, episodeId: number): Promise { if (this.hasDatabase()) { const count = await this.dataSource .getRepository(UserEpisodeUnlock) .count({ where: { userId, episodeId } }); return count > 0; } return this.unlocks.some( (unlock) => unlock.userId === userId && unlock.episodeId === episodeId, ); } private hasDatabase(): this is this & { dataSource: DataSource } { return Boolean(this.dataSource?.isInitialized); } private toOrderRecord(order: Order): OrderRecord { return { id: Number(order.id), orderNo: order.orderNo, tradeOrderId: order.tradeOrderId, userId: Number(order.userId), albumId: Number(order.albumId), episodeId: Number(order.episodeId), tiktokOrderId: order.tiktokOrderId, amount: order.amount, status: order.status as OrderStatus, providerTransactionId: order.providerTransactionId, createdAt: this.toIso(order.createdAt), updatedAt: this.toIso(order.updatedAt), }; } private toUnlockRecord(unlock: UserEpisodeUnlock): UserEpisodeUnlockRecord { return { id: Number(unlock.id), userId: Number(unlock.userId), episodeId: Number(unlock.episodeId), source: unlock.source as 'payment' | 'admin' | 'free', orderNo: unlock.orderNo, createdAt: this.toIso(unlock.createdAt), }; } private toIso(value?: Date | string): string { if (!value) { return new Date().toISOString(); } return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } private createOrderNo() { return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`; } private createTradeOrderId() { return `TRADE${Date.now()}${String(this.orderSequence).padStart(6, '0')}`; } }