Files
indee-demo/backend/src/payment/services/payment.service.ts
Dorian 7e9a35a963 Add HLS.js support and enhance content streaming logic
- 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.
2026-02-13 00:04:53 +00:00

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);
}
}