Implement backend API and database services in Docker setup
- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO. - Introduced PostgreSQL and Redis services with health checks and configurations for data persistence. - Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets. - Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage. - Enhanced the Dockerfile to support the new API environment variables and configurations. - Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
358
backend/src/payment/services/payment.service.ts
Normal file
358
backend/src/payment/services/payment.service.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
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() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch'),
|
||||
this.handlePayment('rent'),
|
||||
]);
|
||||
Logger.log('Payments handled');
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_10AM)
|
||||
async handleDailyPayments() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch', 'daily'),
|
||||
this.handlePayment('rent', 'daily'),
|
||||
]);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEK)
|
||||
async handleWeeklyPayments() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
await Promise.all([
|
||||
this.handlePayment('watch', 'weekly'),
|
||||
this.handlePayment('rent', 'weekly'),
|
||||
]);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON)
|
||||
async handleMonthlyPayments() {
|
||||
if (
|
||||
process.env.ENVIRONMENT === 'development' ||
|
||||
process.env.ENVIRONMENT === 'local'
|
||||
)
|
||||
return;
|
||||
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';
|
||||
const options: FindManyOptions<Shareholder> = {
|
||||
where: {
|
||||
[column]: MoreThanOrEqual(satoshiRate),
|
||||
filmmaker: {
|
||||
paymentMethods: {
|
||||
type: 'LIGHTNING',
|
||||
selected: true,
|
||||
withdrawalFrequency: frequency,
|
||||
},
|
||||
},
|
||||
},
|
||||
order: {
|
||||
pendingRevenue: '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;
|
||||
const sats = revenue / satoshiRate;
|
||||
|
||||
const rounded = Math.floor(sats);
|
||||
|
||||
if (rounded <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = sats - rounded;
|
||||
const missingRevenue = missing * satoshiRate;
|
||||
const revenueToBeSent = revenue - missingRevenue;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user