import { BadRequestException, Inject, Injectable, Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Payment } from '../entities/payment.entity'; import { Between, FindManyOptions, In, IsNull, MoreThanOrEqual, Not, Repository, } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Cron, CronExpression } from '@nestjs/schedule'; import { BTCPayService } from '../providers/services/btcpay.service'; import { PaymentMethod } from '../entities/payment-method.entity'; import { MailService } from 'src/mail/mail.service'; import * as moment from 'moment'; import { LightningService } from '../providers/services/lightning.service'; import { Type } from '../enums/type.enum'; import { Shareholder } from 'src/contents/entities/shareholder.entity'; import { Frequency } from '../enums/frequency.enum'; import { ProjectsService } from 'src/projects/projects.service'; import { Content } from 'src/contents/entities/content.entity'; import { Status } from '../enums/status.enum'; import { PayoutService } from 'src/payout/payout.service'; import { getAudiencePaymentAmount } from '../helpers/payment-per-period'; import { SubscriptionType } from 'src/subscriptions/enums/types.enum'; @Injectable() export class PaymentService { provider: LightningService; constructor( private readonly btcpayService: BTCPayService, @InjectRepository(Payment) private paymentsRepository: Repository, @InjectRepository(PaymentMethod) private paymentMethodsRepository: Repository, @InjectRepository(Shareholder) private shareholdersRepository: Repository, @Inject(MailService) private mailService: MailService, @Inject(ProjectsService) private projectsService: ProjectsService, @Inject(PayoutService) private payoutService: PayoutService, ) { this.provider = this.btcpayService; } async getPayments( filmmakerId: string, contentId: string, ): Promise { const shareholders = await this.shareholdersRepository.find({ where: { filmmakerId, contentId, }, select: ['id'], }); const shareholderIds = shareholders.map((shareholder) => shareholder.id); return await this.getPaymentsByShareholderIds(shareholderIds); } async getPaymentsByProjectIds( filmmakerId: string, projectIds: string[], ): Promise { let allProjects = []; if (projectIds.length === 0) { allProjects = await this.projectsService.findAll({ filmmakerId: filmmakerId, limit: undefined, offset: 0, show: 'shareholder', }); } const ids = projectIds.length === 0 ? allProjects.map((project) => project.id) : projectIds; const shareholders = await this.shareholdersRepository .createQueryBuilder('shareholder') .select('DISTINCT shareholder.id', 'id') .innerJoin(Content, 'content', 'shareholder.content_id = content.id') .where('shareholder.filmmaker_id = :filmmakerId', { filmmakerId }) .andWhere('content.project_id IN (:...projectIds)', { projectIds: ids }) .getRawMany(); const shareholderIds = shareholders.map((shareholder) => shareholder.id); return await this.getPaymentsByShareholderIds(shareholderIds, { statuses: ['completed'], order: 'DESC', }); } async getPaymentsByShareholderIds( shareholderIds: string[], options: { order?: 'ASC' | 'DESC'; statuses?: Status[]; } = {}, ): Promise { const payments = await this.paymentsRepository.find({ where: { shareholderId: In(shareholderIds), paymentMethodId: Not(IsNull()), status: options?.statuses ? In(options.statuses) : undefined, }, relations: ['paymentMethod'], order: { createdAt: options?.order }, }); return payments.filter((payment) => payment.paymentMethod); } async getSatPrice(): Promise { return await this.provider.getSatoshiRate(); } async sendPayment( userId: string, contentId: string, subscription: SubscriptionType, ): Promise<{ usdAmount: number; milisatAmount: number }> { const amount = getAudiencePaymentAmount(subscription); await this.payoutService.addAmountToCurrentMonthPayout( userId, amount.value, amount.maxPerMonth, ); await this.shareholdersRepository.update( { contentId, deletedAt: IsNull(), }, { pendingRevenue: () => `pending_revenue + (cast(share as decimal) / 100.00 * ${amount.value})`, }, ); const satPrice = await this.getSatPrice(); return { usdAmount: amount.value, milisatAmount: Number((amount.value / satPrice) * 1000), }; } @Cron(CronExpression.EVERY_MINUTE) async handlePaymentsCron() { await Promise.all([ this.handlePayment('watch'), this.handlePayment('rent'), ]); } @Cron(CronExpression.EVERY_DAY_AT_10AM) async handleDailyPayments() { await Promise.all([ this.handlePayment('watch', 'daily'), this.handlePayment('rent', 'daily'), ]); } @Cron(CronExpression.EVERY_WEEK) async handleWeeklyPayments() { await Promise.all([ this.handlePayment('watch', 'weekly'), this.handlePayment('rent', 'weekly'), ]); } @Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON) async handleMonthlyPayments() { await Promise.all([ this.handlePayment('watch', 'monthly'), this.handlePayment('rent', 'monthly'), ]); } async handlePayment( type: 'watch' | 'rent' = 'watch', frequency: Frequency = 'automatic', ) { const satoshiRate = await this.provider.getSatoshiRate(); const column = type === 'watch' ? 'pendingRevenue' : 'rentPendingRevenue'; // Rental prices (and therefore rentPendingRevenue) are denominated in // sats, not USD. Use a 1-sat minimum threshold for rentals instead of // the USD-based satoshiRate. const minThreshold = type === 'rent' ? 1 : satoshiRate; const options: FindManyOptions = { where: { [column]: MoreThanOrEqual(minThreshold), filmmaker: { paymentMethods: { type: 'LIGHTNING', selected: true, withdrawalFrequency: frequency, }, }, }, order: { [column]: 'DESC', }, relations: ['filmmaker', 'filmmaker.paymentMethods'], take: 5, }; const shareholders = await this.shareholdersRepository.find(options); for (const shareholder of shareholders) { const selectedPaymentMethod = shareholder.filmmaker.paymentMethods.find( (method) => method.selected, ); if (selectedPaymentMethod?.lightningAddress) await this.sendLightningPaymentToShareholder( shareholder, satoshiRate, type, ); } } async sendLightningPaymentToShareholder( shareholder: Shareholder, satoshiRate: number, type: Type, ) { const revenue = type === 'watch' ? shareholder.pendingRevenue : shareholder.rentPendingRevenue; // Rental revenue is already in sats (rentalPrice is denominated in sats). // Subscription/watch revenue is in USD and must be converted. let rounded: number; let revenueToBeSent: number; if (type === 'rent') { rounded = Math.floor(revenue); revenueToBeSent = rounded; // sats in, sats out } else { const sats = revenue / satoshiRate; rounded = Math.floor(sats); const missing = sats - rounded; const missingRevenue = missing * satoshiRate; revenueToBeSent = revenue - missingRevenue; } if (rounded <= 0) { return; } const selectedLightningAddress = shareholder.filmmaker.paymentMethods.find( (method) => method.selected && method.type === 'LIGHTNING', ); const payment = this.paymentsRepository.create({ id: randomUUID(), shareholderId: shareholder.id, status: 'pending', milisatsAmount: rounded * 1000, paymentMethodId: selectedLightningAddress.id, usdAmount: revenueToBeSent, providerId: 'TBD', type, }); const existingPayment = await this.paymentsRepository.exists({ where: { shareholderId: shareholder.id, status: 'pending', type, createdAt: MoreThanOrEqual(moment().subtract(1, 'minute').toDate()), }, }); if (existingPayment) { return; } else { await this.paymentsRepository.save(payment); } try { const providerPayment = await this.provider.sendPaymentWithAddress( selectedLightningAddress.lightningAddress, payment, ); payment.providerId = providerPayment.id; payment.status = 'completed'; await (type === 'watch' ? this.shareholdersRepository.update( { id: shareholder.id }, { pendingRevenue: () => `pending_revenue - ${revenueToBeSent}`, updatedAt: () => 'updated_at', }, ) : this.shareholdersRepository.update( { id: shareholder.id }, { rentPendingRevenue: () => `rent_pending_revenue - ${revenueToBeSent}`, updatedAt: () => 'updated_at', }, )); } catch { payment.status = 'failed'; } finally { await this.paymentsRepository.save(payment); } } async getPaymentsByDate( shareholderIds: string[], date?: Date, ): Promise { const createdAt = date ? Between(date, new Date()) : undefined; const payments = await this.paymentsRepository.find({ where: { shareholderId: In(shareholderIds), createdAt, status: 'completed', }, order: { createdAt: 'ASC', }, }); return payments; } async validateLightningAddress(address: string) { return this.provider.validateAddress(address); } }