- Integrated HLS.js version 1.6.15 into the project for improved video streaming capabilities. - Updated the ContentsController to check for HLS manifest availability and fall back to presigned URLs for original files if not found. - Enhanced the VideoPlayer component to handle loading and error states more effectively, improving user experience during streaming. - Refactored content service methods to return detailed streaming information, including HLS and DASH manifest URLs.
353 lines
9.9 KiB
TypeScript
353 lines
9.9 KiB
TypeScript
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<Payment>,
|
|
@InjectRepository(PaymentMethod)
|
|
private paymentMethodsRepository: Repository<PaymentMethod>,
|
|
@InjectRepository(Shareholder)
|
|
private shareholdersRepository: Repository<Shareholder>,
|
|
@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<Payment[]> {
|
|
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<Payment[]> {
|
|
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<Payment[]> {
|
|
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<number> {
|
|
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<Shareholder> = {
|
|
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<Payment[]> {
|
|
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);
|
|
}
|
|
}
|