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:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -0,0 +1,5 @@
import { Reflector } from '@nestjs/core';
import { AllowedSubscriptionType } from '../enums/types.enum';
export const Subscriptions =
Reflector.createDecorator<AllowedSubscriptionType[]>();

View File

@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { CreateSubscriptionDTO } from './create-subscription.dto';
export class AdminCreateSubscriptionDTO extends CreateSubscriptionDTO {
@IsEnum(['once', 'forever'])
duration: 'once' | 'forever';
@IsOptional()
@IsString()
userId: string;
}

View File

@@ -0,0 +1,11 @@
import { IsEnum } from 'class-validator';
import { SubscriptionType, subscriptionTypes } from '../enums/types.enum';
import { SubscriptionPeriod, subscriptionPeriods } from '../enums/periods.enum';
export class CreateSubscriptionDTO {
@IsEnum(subscriptionPeriods)
period: SubscriptionPeriod;
@IsEnum(subscriptionTypes)
type: SubscriptionType;
}

View File

@@ -0,0 +1,37 @@
import { flashEventType } from '../enums/flash-event.enum';
import { SubscriptionType } from '../enums/types.enum';
export interface FlashWebhookData {
public_key: string;
name: string;
email: string;
about?: string;
picture_url: string;
user_plan: SubscriptionType;
user_plan_id: number;
signup_date: string;
next_payment_date: string;
failed_payment_date: string;
transaction_id: string;
transaction_amount: number;
transaction_currency: string;
transaction_date: string;
external_uuid: string;
}
export interface FlashWebhookEvent {
version: string;
eventType: {
id: number;
name: (typeof flashEventType)[number];
};
user_public_key: string;
exp: number; // Expiration time as a Unix timestamp
// iat: number; // Issued at time as a Unix timestamp
}
export interface Flash {
event: FlashWebhookEvent;
data: FlashWebhookData;
type: SubscriptionType;
}

View File

@@ -0,0 +1,10 @@
import { IsIn } from 'class-validator';
import {
SubscriptionType,
subscriptionTypes,
} from 'src/subscriptions/enums/types.enum';
export class CreateBillingDTO {
@IsIn(subscriptionTypes)
type: SubscriptionType;
}

View File

@@ -0,0 +1,29 @@
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
import { SubscriptionPeriod } from 'src/subscriptions/enums/periods.enum';
import { PaymentStatus } from 'src/subscriptions/enums/status.enum';
import { SubscriptionType } from 'src/subscriptions/enums/types.enum';
export class SubscriptionDTO {
id: string;
stripeId: string;
status: PaymentStatus;
type: SubscriptionType;
period: SubscriptionPeriod;
periodEnd?: Date;
createdAt: Date;
flashId?: string;
flashEmail?: string;
constructor(subscription: Subscription) {
this.id = subscription.id;
this.stripeId = subscription.stripeId;
this.status = subscription.status;
this.type = subscription.type;
this.periodEnd = subscription.periodEnd;
this.createdAt = subscription.createdAt;
this.flashId = subscription?.flashId;
this.flashEmail = subscription?.flashEvents?.length
? subscription?.flashEvents[0]?.data?.email
: undefined;
}
}

View File

@@ -0,0 +1,54 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { PaymentStatus } from '../enums/status.enum';
import { SubscriptionPeriod } from '../enums/periods.enum';
import { User } from 'src/users/entities/user.entity';
import { SubscriptionType } from '../enums/types.enum';
import { Flash } from '../dto/flash-webhook-event.dto';
@Entity('subscriptions')
export class Subscription {
@PrimaryColumn()
id: string;
@Column({ nullable: true })
stripeId: string;
// Called external_uuid by pay with flash
@Column({ nullable: true })
flashId: string;
@Column()
status: PaymentStatus;
@Column()
userId: string;
@Column()
type: SubscriptionType;
@Column()
period: SubscriptionPeriod;
@Column({ type: 'timestamptz', nullable: true })
periodEnd?: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'timestamptz', nullable: true })
expirationReminderSentAt?: Date;
@Column({ type: 'jsonb', nullable: true })
flashEvents: Flash[];
@ManyToOne(() => User, (user) => user.subscriptions)
@JoinColumn({ name: 'user_id' })
user?: User;
}

View File

@@ -0,0 +1,7 @@
export const flashEventType = [
'user_signed_up' as const,
'renewal_successful' as const,
'renewal_failed' as const,
'user_cancelled_subscription' as const,
] as const;
export type FlashEventType = (typeof flashEventType)[number];

View File

@@ -0,0 +1,2 @@
export const subscriptionPeriods = ['yearly', 'monthly'] as const;
export type SubscriptionPeriod = (typeof subscriptionPeriods)[number];

View File

@@ -0,0 +1,10 @@
export const paymentStatus = [
'created',
'processing',
'succeeded',
'rejected',
'cancelled',
'paused',
'expired',
] as const;
export type PaymentStatus = (typeof paymentStatus)[number];

View File

@@ -0,0 +1,20 @@
export const subscriptionTypes = [
'enthusiast',
'film-buff',
'cinephile',
'rss-addon',
'verification-addon',
// Discontinued subscriptions but still in the database:
'audience',
'pro-plus',
'ultimate',
] as const;
export type SubscriptionType = (typeof subscriptionTypes)[number];
export const allowedSubscriptionTypes = [
...subscriptionTypes,
'free',
'pro',
'filmmaker',
] as const;
export type AllowedSubscriptionType = (typeof allowedSubscriptionTypes)[number];

View File

@@ -0,0 +1,252 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Subscription } from './entities/subscription.entity';
import { Repository } from 'typeorm';
import { UsersService } from 'src/users/users.service';
import { User } from 'src/users/entities/user.entity';
import { randomUUID } from 'node:crypto';
import { Flash } from './dto/flash-webhook-event.dto';
import { flashEventType } from './enums/flash-event.enum';
import { SubscriptionType } from './enums/types.enum';
import { SubscriptionsGateway } from 'src/events/subscriptions.gateway';
import { CreateSubscriptionDTO } from './dto/create-subscription.dto';
import * as dayjs from 'dayjs';
import { SubscriptionPeriod } from './enums/periods.enum';
const validateTXID = (id: string) =>
id === '' || id === null || id === undefined || id === '-1';
@Injectable()
export class FlashSubscriptionsService {
constructor(
@Inject(UsersService)
private readonly usersService: UsersService,
@InjectRepository(Subscription)
private readonly subscriptionsRepository: Repository<Subscription>,
@Inject(SubscriptionsGateway)
private readonly subscriptionsGateway: SubscriptionsGateway,
) {}
async handleUserSignedUp({ data, event, type }: Flash, user: User) {
if (event.eventType.id !== 1) {
Logger.error(
`Event type invalid, expected "1" got "${event.eventType.id}"`,
);
return;
}
if (data.failed_payment_date !== '') {
Logger.error(
`Signup payment failed for user: ${data.email}, expected success`,
);
return;
}
if (validateTXID(data.transaction_id)) {
Logger.error(
`no signup payment for user: ${data.email}, expected payment`,
);
return;
}
if (
user.subscriptions.some((sub) =>
sub?.flashEvents?.some(
(event) => event.data.transaction_id === data.transaction_id,
),
)
) {
Logger.warn(
`already received this account creation event for user ${data.email}`,
);
return;
}
const flashSubscriptionId = data.external_uuid;
const subscription = await this.subscriptionsRepository.findOneBy({
flashId: flashSubscriptionId,
userId: user.id,
});
if (subscription) {
subscription.periodEnd = new Date(data.next_payment_date);
subscription.type = type;
subscription.flashEvents = [{ data, event, type }];
subscription.period = data.user_plan.toLowerCase() as SubscriptionPeriod;
subscription.status = 'succeeded';
await this.subscriptionsRepository.save(subscription);
this.subscriptionsGateway.server.emit(user.id, {
subscriptionId: subscription.id,
});
Logger.log(`User "${data.email}" signed up successfully!`);
}
}
async handleRenewalSuccessful({ data, event, type }: Flash, user: User) {
if (event.eventType.id !== 2) {
Logger.error(
`Event type invalid, expected "2" got "${event.eventType.id}"`,
);
return;
}
if (data.failed_payment_date !== '') {
Logger.error(
`renewal payment failed for user: ${data.email}, expected success`,
);
return;
}
if (validateTXID(data.transaction_id)) {
Logger.error(
`no renewal payment for user: ${data.email}, expected payment`,
);
return;
}
const subscription = await this.subscriptionsRepository.findOne({
where: { userId: user.id },
});
if (!subscription) {
Logger.error('Subscription not found for user:', data.email);
return;
}
if (
subscription.flashEvents.some(
(event) => event.data.transaction_id === data.transaction_id,
)
) {
Logger.warn('Duplicate event detected:', data.transaction_id);
return;
}
subscription.flashEvents = subscription.flashEvents || [];
subscription.flashEvents.push({ event, data, type });
subscription.status = 'succeeded';
subscription.periodEnd = new Date(data.next_payment_date);
subscription.type = data.user_plan.toLowerCase() as SubscriptionType;
await this.subscriptionsRepository.save(subscription);
Logger.log(`User "${data.email}" renewed successfully!`);
}
async handleRenewalFailed({ event, data, type }: Flash, user: User) {
if (event.eventType.id !== 3) {
Logger.error(
`Event type invalid, expected "3" got "${event.eventType.id}"`,
);
return;
}
const subscription = await this.subscriptionsRepository.findOne({
where: { userId: user.id },
});
if (!subscription) {
Logger.error('Subscription not found for user:', data.email);
return;
}
if (
subscription.flashEvents.some(
(event) => event.data.failed_payment_date === data.failed_payment_date,
)
) {
Logger.warn('Duplicate failed renewal event detected:', data);
return;
}
if (data.transaction_id !== '-1') {
Logger.error(
'Received a failed event that was not a failed event?:',
data,
);
return;
}
subscription.flashEvents = subscription.flashEvents || [];
subscription.flashEvents.push({ event, data, type });
subscription.status = 'expired';
subscription.periodEnd = new Date(data.failed_payment_date);
await this.subscriptionsRepository.save(subscription);
Logger.log(`User "${data.email}" either failed payment, or unsubscribed.`);
}
async updateSubscriptionStatus(payload: Flash) {
if (!payload.event.version.startsWith('1.')) {
Logger.error(
`Can not process Flash Event version "${payload.event.version}"`,
);
return;
}
const flashSubscriptionId = payload.data.external_uuid;
// Existing bug on paywithflash service makes to the request to not include the correct email
const subscription = await this.subscriptionsRepository.findOne({
where: {
flashId: flashSubscriptionId,
},
relations: ['user'],
});
const userEmail = subscription?.user.email;
const user = await this.usersService.findUserByEmail(userEmail);
if (!user) {
Logger.error(`User not found for Flash event: "${userEmail}"`);
return;
}
switch (payload.event.eventType.name) {
case flashEventType[0]: {
Logger.log('Handling user sign-up');
await this.handleUserSignedUp(payload, user);
break;
}
case flashEventType[1]: {
Logger.log('Handling successful renewal');
await this.handleRenewalSuccessful(payload, user);
break;
}
case flashEventType[2]: {
Logger.log('Handling failed renewal');
await this.handleRenewalFailed(payload, user);
break;
}
case flashEventType[3]: {
Logger.log('Handling cancellation');
// Flash does not support proration so periodEnd and status will remain the same
break;
}
default: {
Logger.error('Unknown event type:', JSON.stringify(payload));
}
}
}
async initFlashPayment({ period, type }: CreateSubscriptionDTO, user: User) {
const subscriptionFlashId = randomUUID();
await this.subscriptionsRepository.save({
id: randomUUID(),
flashId: subscriptionFlashId,
status: 'created',
type,
period,
userId: user.id,
periodEnd:
period === 'monthly'
? dayjs().add(1, 'month').toDate()
: dayjs().add(1, 'year').toDate(),
});
return subscriptionFlashId;
}
}

View File

@@ -0,0 +1,60 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { SubscriptionType } from '../enums/types.enum';
@Injectable()
export class PayWithFlashAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader: string | undefined = request.headers.authorization;
const token = (authHeader ?? '').split(' ')[1];
const { url } = request;
// get the type from the URL
const type: SubscriptionType = url.split('/').pop() as SubscriptionType;
if (!token) {
throw new UnauthorizedException('No token provided');
}
let secret: string;
switch (type) {
case 'rss-addon': {
secret = process.env.FLASH_JWT_SECRET_RSS_ADDON;
break;
}
case 'verification-addon': {
secret = process.env.FLASH_JWT_SECRET_VERIFICATION_ADDON;
break;
}
case 'enthusiast': {
secret = process.env.FLASH_JWT_SECRET_ENTHUSIAST;
break;
}
case 'film-buff': {
secret = process.env.FLASH_JWT_SECRET_FILM_BUFF;
break;
}
case 'cinephile': {
secret = process.env.FLASH_JWT_SECRET_CINEPHILE;
break;
}
default: {
throw new UnauthorizedException('Invalid subscription type');
}
}
try {
const decoded = jwt.verify(token, secret);
request.user = decoded; // Attach user information to the request
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View File

@@ -0,0 +1,41 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Subscriptions } from '../decorators/subscriptions.decorator';
import { User } from 'src/users/entities/user.entity';
import { Subscription } from '../entities/subscription.entity';
@Injectable()
export class SubscriptionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const subscriptions = this.reflector.get(
Subscriptions,
context.getHandler(),
);
if (!subscriptions) {
return true;
}
const enabledSubscriptions = new Set(subscriptions);
const request = context.switchToHttp().getRequest();
const user: User = request.user;
if (subscriptions.includes('filmmaker')) return !!user.filmmaker;
if (this.hasActiveSubscription(user.subscriptions)) {
return user.subscriptions.some((sub) =>
enabledSubscriptions.has(sub.type),
);
}
return true;
}
hasActiveSubscription(subscriptions: Subscription[]) {
if (subscriptions.length === 0) return false;
const latestSubscription = subscriptions[0];
const currentDate = new Date();
// get subscription expiration date
const subscriptionExpirationDate = latestSubscription.periodEnd;
// check if subscription is active
return currentDate < subscriptionExpirationDate;
}
}

View File

@@ -0,0 +1,35 @@
import { SubscriptionType } from '../enums/types.enum';
export const getBenefits = (type: SubscriptionType) => {
if (type === 'rss-addon') {
return 'the ability to publish your content through RSS and automatically reach your audience';
}
return 'access to the Screening Room with exclusive films, series, and music videos';
};
export const getPurchaseUrl = (type: SubscriptionType) => {
const frontendUrl = process.env.FRONTEND_URL || 'https://indeehub.studio';
if (type === 'rss-addon') {
return `${frontendUrl}/add-ons`;
}
return `${frontendUrl}/subscription`;
};
export const getSubsctriptionName = (type: SubscriptionType) => {
const formattedType = formatSubscriptionType(type);
if (type === 'rss-addon') {
return 'RSS Add-on';
}
return formattedType + ' Subscription';
};
/**
* Capitalizes the first letter of each word and removes hyphens.
* Example: 'film-buff' => 'Film Buff'
*/
export const formatSubscriptionType = (type: SubscriptionType): string => {
return type
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};

View File

@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Subscription } from './entities/subscription.entity';
import { Brackets, Repository } from 'typeorm';
import { MailService } from 'src/mail/mail.service';
import { Cron, CronExpression } from '@nestjs/schedule';
import * as dayjs from 'dayjs';
import {
getBenefits,
getPurchaseUrl,
getSubsctriptionName,
} from './helpers/subscription.helper';
@Injectable()
export class SubscriptionsEmailService {
constructor(
@InjectRepository(Subscription)
private readonly _subscriptionsRepository: Repository<Subscription>,
private readonly _mailService: MailService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_11AM)
async handleExpiringReminderCron() {
Logger.log('Looking for subscriptions expiring soon...');
const now = dayjs();
const in24Hours = now.add(24, 'hour').toDate();
const last24Hours = now.subtract(24, 'hour').toDate();
// Query for subscriptions expiring in the next 24 hours
// and that haven't had a reminder sent in the last 24 hours
const expiring = await this._subscriptionsRepository
.createQueryBuilder('subscription')
// using withDeleted to include soft-deleted users data then exclude them later
// otherwise, the query would not join the user table correctly
.withDeleted()
.leftJoinAndSelect('subscription.user', 'user')
.where('subscription.periodEnd BETWEEN :now AND :in24Hours', {
now,
in24Hours,
})
.andWhere('subscription.status = :status', { status: 'cancelled' })
.andWhere(
new Brackets((qb) => {
qb.where('subscription.expirationReminderSentAt IS NULL').orWhere(
'subscription.expirationReminderSentAt < :last24Hours',
{ last24Hours },
);
}),
)
.andWhere('user.deletedAt IS NULL')
.getMany();
if (expiring.length === 0) {
Logger.log('No subscriptions expiring soon.');
return;
}
// Sending reminder emails
const renewSubscriptionTemplateId = 'd-681385c5f24c412e95ad70bc6410511d';
const recipients = expiring.map((subscription) => ({
email: subscription.user.email,
data: {
subscriptionAddonUrl: getPurchaseUrl(subscription.type),
subscriptionName: getSubsctriptionName(subscription.type),
benefits: getBenefits(subscription.type),
},
}));
await this._mailService.sendBatch(renewSubscriptionTemplateId, recipients);
// Setting the reminder sent date
const ids = expiring.map((s) => s.id);
await this._subscriptionsRepository
.createQueryBuilder()
.update()
.set({ expirationReminderSentAt: new Date() })
.whereInIds(ids)
.execute();
Logger.log(
`Sent reminder emails for ${expiring.length} expiring subscriptions.`,
);
}
}

View File

@@ -0,0 +1,86 @@
import {
Controller,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
Param,
Get,
} from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { CreateSubscriptionDTO } from './dto/create-subscription.dto';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { User } from 'src/auth/user.decorator';
import { RequestUser } from 'src/auth/dto/request/request-user.interface';
import {
FlashWebhookData,
FlashWebhookEvent,
} from './dto/flash-webhook-event.dto';
import { FlashSubscriptionsService } from './flash-subscription.service';
import { SubscriptionsGuard } from './guards/subscription.guard';
import { PayWithFlashAuthGuard } from './guards/pay-with-flash.guard';
import { SubscriptionType } from './enums/types.enum';
@Controller('subscriptions')
export class SubscriptionsController {
constructor(
private readonly subscriptionsService: SubscriptionsService,
private readonly flashSubscriptionsService: FlashSubscriptionsService,
) {}
@Post('/flash/init')
@UseGuards(HybridAuthGuard)
async initFlashPayment(
@Body() createSubscriptionDTO: CreateSubscriptionDTO,
@User() user: RequestUser['user'],
) {
const subscriptionFlashId =
await this.flashSubscriptionsService.initFlashPayment(
createSubscriptionDTO,
user,
);
return { subscriptionFlashId };
}
/**
* Create a Lightning invoice for a subscription payment.
* Returns BOLT11 invoice, expiration, and amount for QR code display.
*/
@Post('/lightning')
@UseGuards(HybridAuthGuard)
async createLightningSubscription(
@Body() createSubscriptionDTO: CreateSubscriptionDTO,
@User() user: RequestUser['user'],
) {
return await this.subscriptionsService.createLightningSubscription(
createSubscriptionDTO,
user,
);
}
/**
* Get the current user's active subscriptions.
*/
@Get('/active')
@UseGuards(HybridAuthGuard)
async getActiveSubscriptions(@User() user: RequestUser['user']) {
return await this.subscriptionsService.getActiveSubscriptions(user.id);
}
@Post('flash-payment-webhook/:type')
@UseGuards(PayWithFlashAuthGuard)
@HttpCode(HttpStatus.OK)
async flashPayment(
@Req() { user: event }: { user: FlashWebhookEvent },
@Body() { data }: { data: FlashWebhookData },
@Param('type') type: SubscriptionType,
) {
await this.flashSubscriptionsService.updateSubscriptionStatus({
event,
data,
type,
});
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { FlashSubscriptionsService } from './flash-subscription.service';
import { SubscriptionsController } from './subscriptions.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Subscription } from './entities/subscription.entity';
import { EventsModule } from 'src/events/events.module';
import { SubscriptionsEmailService } from './subscription-email.service';
@Module({
imports: [TypeOrmModule.forFeature([Subscription]), EventsModule],
controllers: [SubscriptionsController],
providers: [
SubscriptionsService,
FlashSubscriptionsService,
SubscriptionsEmailService,
],
exports: [SubscriptionsService, FlashSubscriptionsService],
})
export class SubscriptionsModule {}

View File

@@ -0,0 +1,172 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CreateSubscriptionDTO } from './dto/create-subscription.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Subscription } from './entities/subscription.entity';
import { In, IsNull, Not, Repository } from 'typeorm';
import { UsersService } from 'src/users/users.service';
import { User } from 'src/users/entities/user.entity';
import { randomUUID } from 'node:crypto';
import { SubscriptionType } from './enums/types.enum';
import { SubscriptionPeriod } from './enums/periods.enum';
import { MailService } from 'src/mail/mail.service';
import { AdminCreateSubscriptionDTO } from './dto/admin-create-subscription.dto';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
/**
* Subscription pricing in USD.
* Since Lightning doesn't support recurring billing,
* each period is a one-time payment that activates the subscription.
*/
const SUBSCRIPTION_PRICES: Record<
string,
{ monthly: number; yearly: number }
> = {
enthusiast: { monthly: 9.99, yearly: 99.99 },
'film-buff': { monthly: 19.99, yearly: 199.99 },
cinephile: { monthly: 29.99, yearly: 299.99 },
'rss-addon': { monthly: 4.99, yearly: 49.99 },
'verification-addon': { monthly: 2.99, yearly: 29.99 },
};
@Injectable()
export class SubscriptionsService {
constructor(
@Inject(UsersService)
private readonly usersService: UsersService,
@InjectRepository(Subscription)
private readonly subscriptionsRepository: Repository<Subscription>,
private readonly _mailService: MailService,
private readonly btcpayService: BTCPayService,
) {}
/**
* Get subscription price for a given type and period.
*/
private getPrice(type: SubscriptionType, period: SubscriptionPeriod): number {
const pricing = SUBSCRIPTION_PRICES[type];
if (!pricing) {
throw new Error(`Unknown subscription type: ${type}`);
}
return period === 'monthly' ? pricing.monthly : pricing.yearly;
}
/**
* Create a Lightning invoice for a subscription payment.
* Returns the BOLT11 invoice and metadata for the frontend QR display.
*/
async createLightningSubscription(
createSubscriptionDTO: CreateSubscriptionDTO,
user: User,
) {
const price = this.getPrice(
createSubscriptionDTO.type,
createSubscriptionDTO.period,
);
const correlationId = randomUUID();
const description = `${createSubscriptionDTO.type} subscription (${createSubscriptionDTO.period})`;
// Create BTCPay invoice
const invoice = await this.btcpayService.issueInvoice(
price,
description,
correlationId,
);
// Create a pending subscription record
// We store the BTCPay invoice ID in the stripeId field (legacy column name)
const periodEnd = this.calculatePeriodEnd(createSubscriptionDTO.period);
await this.subscriptionsRepository.save({
id: correlationId,
stripeId: invoice.invoiceId,
status: 'created',
type: createSubscriptionDTO.type,
period: createSubscriptionDTO.period,
userId: user.id,
periodEnd,
});
// Get the BOLT11 invoice
const quote = await this.btcpayService.issueQuote(invoice.invoiceId);
return {
id: correlationId,
lnInvoice: quote.lnInvoice,
expiration: quote.expiration,
sourceAmount: quote.sourceAmount,
conversionRate: quote.conversionRate,
};
}
/**
* Called by webhook when a subscription invoice is paid.
* Activates the subscription.
*/
async activateSubscription(invoiceId: string) {
// stripeId column is repurposed for BTCPay invoice ID
const subscription = await this.subscriptionsRepository.findOne({
where: { stripeId: invoiceId },
relations: ['user'],
});
if (!subscription) {
Logger.warn(
`No subscription found for BTCPay invoice ${invoiceId}`,
'SubscriptionsService',
);
return;
}
subscription.status = 'succeeded';
subscription.periodEnd = this.calculatePeriodEnd(subscription.period);
await this.subscriptionsRepository.save(subscription);
Logger.log(
`Subscription ${subscription.id} activated for user ${subscription.userId}`,
'SubscriptionsService',
);
}
/**
* Calculate the period end date from now.
*/
private calculatePeriodEnd(period: SubscriptionPeriod): Date {
const now = new Date();
if (period === 'monthly') {
now.setMonth(now.getMonth() + 1);
} else {
now.setFullYear(now.getFullYear() + 1);
}
// Add an extra day of grace
now.setDate(now.getDate() + 1);
return now;
}
async getActiveSubscriptions(userId: string) {
return this.subscriptionsRepository.find({
where: {
userId,
status: 'succeeded',
},
});
}
/**
* Admin: manually create a subscription for a user.
*/
async adminCreateSubscription({
userId,
type,
period,
}: AdminCreateSubscriptionDTO) {
const periodEnd = this.calculatePeriodEnd(period);
return await this.subscriptionsRepository.save({
id: randomUUID(),
status: 'succeeded',
type,
period,
userId,
periodEnd,
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,514 @@
/* eslint-disable unicorn/no-null */
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from 'src/users/users.service';
import { MailService } from 'src/mail/mail.service';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Subscription } from '../entities/subscription.entity';
import { SubscriptionsService } from '../subscriptions.service';
import { CreateSubscriptionDTO } from '../dto/create-subscription.dto';
import {
audienceMonthlyRejectedStripe,
audienceMonthlyStripe,
rssAddonMonthlyStripe,
verificationAddonMonthlyStripe,
} from '../../../test/mocks/subscription';
import {
audienceUserNoSubscription,
audienceUserStripe,
proPlusUserStripe,
ultimateUserStripe,
} from '../../../test/mocks/user';
import { Logger } from '@nestjs/common';
jest.mock('stripe'); // Mock Stripe module
jest.mock('@nestjs/common/services/logger.service');
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
let usersService: UsersService;
let mailService: MailService;
let subscriptionsRepository: Repository<Subscription>;
const stripe: jest.Mocked<Stripe> = {
checkout: { sessions: { create: jest.fn() } },
billingPortal: {
sessions: { create: jest.fn() },
configurations: { create: jest.fn() },
},
subscriptions: {
retrieve: jest.fn(),
},
} as unknown as jest.Mocked<Stripe>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SubscriptionsService,
{
provide: UsersService,
useValue: {
findUserByEmail: jest.fn(),
},
},
{
provide: MailService,
useValue: { sendMail: jest.fn() },
},
{
provide: getRepositoryToken(Subscription),
useValue: {
update: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
findOneOrFail: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get<SubscriptionsService>(SubscriptionsService);
service['stripe'] = stripe;
usersService = module.get<UsersService>(UsersService);
mailService = module.get<MailService>(MailService);
subscriptionsRepository = module.get<Repository<Subscription>>(
getRepositoryToken(Subscription),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createCheckout', () => {
// Mock Stripe's session creation method
it('should create a checkout session', async () => {
const mockDTO: CreateSubscriptionDTO = {
type: 'enthusiast',
period: 'monthly',
};
const mockSession: any = { url: 'https://stripe.checkout.url' };
jest
.spyOn(stripe.checkout.sessions, 'create')
.mockImplementation()
.mockResolvedValue(mockSession);
jest.spyOn(stripe.subscriptions, 'retrieve').mockResolvedValue({
items: {
data: [
{
price: { id: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID },
},
],
},
} as any);
const result = await service.createCheckout(
mockDTO,
audienceUserNoSubscription,
);
expect(result).toBe(mockSession.url);
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({
mode: 'subscription',
allow_promotion_codes: true,
line_items: [
{ price: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID, quantity: 1 },
],
subscription_data: { trial_period_days: 14 },
customer_email: audienceUserNoSubscription.email,
payment_method_types: ['card'],
success_url: expect.stringContaining('?refresh=true'),
cancel_url: expect.stringContaining('/subscription'),
});
});
});
describe('updateSubscription', () => {
it('should update a subscription on webhook event', async () => {
const mockWebhookEvent = {
data: {
object: { id: 'sub_123', object: 'subscription', cancel_at: null },
},
};
jest
.spyOn(service, 'getByStripeId')
.mockResolvedValue(audienceMonthlyStripe);
jest.spyOn(subscriptionsRepository, 'update').mockResolvedValue({
affected: 1,
raw: [],
generatedMaps: [],
});
await service.updateSubscription(mockWebhookEvent as any);
expect(subscriptionsRepository.update).toHaveBeenCalledWith(
{ id: audienceMonthlyStripe.id },
{
type: expect.any(String),
period: expect.any(String),
status: 'succeeded',
},
);
});
});
describe('notifySubscriptor', () => {
it('should send an email notification to the subscriber', async () => {
jest
.spyOn(service, 'getByStripeId')
.mockResolvedValue(audienceMonthlyStripe);
jest.spyOn(mailService, 'sendMail').mockResolvedValue();
await service.notifySubscriptor(
audienceMonthlyStripe.stripeId,
'd-62fb0e9d71304edfaed4f4f1781dc6f9',
{
trial_end: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60,
},
);
expect(mailService.sendMail).toHaveBeenCalledWith({
to: audienceMonthlyStripe.user.email,
templateId: 'd-62fb0e9d71304edfaed4f4f1781dc6f9',
data: {
trial_end: expect.any(Number),
},
});
});
});
describe('getByStripeId', () => {
it('should return a subscription by Stripe ID', async () => {
jest
.spyOn(subscriptionsRepository, 'findOneOrFail')
.mockResolvedValue(audienceMonthlyStripe);
const result = await service.getByStripeId(audienceMonthlyStripe.id);
expect(result).toBe(audienceMonthlyStripe);
expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({
where: { stripeId: audienceMonthlyStripe.id },
relations: ['user'],
});
});
});
describe('getPriceId', () => {
it('should return the correct price ID', () => {
const type = 'enthusiast';
const period = 'monthly';
const result = service['getPriceId'](type, period);
expect(result).toBe(process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID);
});
});
describe('getTypePeriodFromPriceId', () => {
it('should return the type and period from the price ID', () => {
const mockPriceId = process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID;
const result = service['getTypePeriodFromPriceId'](mockPriceId);
expect(result).toStrictEqual({
type: 'enthusiast',
period: 'monthly',
});
});
});
describe('updateOnCheckoutSessionCompleted', () => {
it('should update subscription on checkout session completed', async () => {
const mockWebhookEvent: any = {
data: {
object: {
subscription: rssAddonMonthlyStripe.stripeId,
payment_status: 'paid',
},
},
};
const mockStripeSubscription = {
subscription: {
id: rssAddonMonthlyStripe.stripeId,
items: {
data: [
{
price: { id: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID },
},
],
},
customer: { email: proPlusUserStripe.email },
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
};
jest
.spyOn(service, 'getStripeSubscription')
.mockResolvedValue(mockStripeSubscription as any);
jest
.spyOn(usersService, 'findUserByEmail')
.mockResolvedValue(proPlusUserStripe);
jest.spyOn(subscriptionsRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(subscriptionsRepository, 'save')
.mockResolvedValue(rssAddonMonthlyStripe);
await service.updateOnCheckoutSessionCompleted(mockWebhookEvent);
expect(service.getStripeSubscription).toHaveBeenCalledWith('sub_123');
expect(usersService.findUserByEmail).toHaveBeenCalledWith(
proPlusUserStripe.email,
);
expect(subscriptionsRepository.save).toHaveBeenCalled();
});
it('should notify subscriber if subscription type is audience', async () => {
const mockWebhookEvent: any = {
data: {
object: {
subscription: audienceMonthlyStripe.stripeId,
payment_status: 'paid',
},
},
};
const mockStripeSubscription = {
subscription: {
id: audienceMonthlyStripe.stripeId,
items: {
data: [
{
price: { id: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID },
},
],
},
customer: { email: audienceUserStripe.email },
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
};
jest
.spyOn(service, 'getStripeSubscription')
.mockResolvedValue(mockStripeSubscription as any);
jest
.spyOn(usersService, 'findUserByEmail')
.mockResolvedValue(audienceUserStripe);
jest
.spyOn(subscriptionsRepository, 'findOne')
.mockResolvedValue(audienceMonthlyStripe);
jest.spyOn(service, 'notifySubscriptor').mockResolvedValue();
await service.updateOnCheckoutSessionCompleted(mockWebhookEvent);
expect(service.getStripeSubscription).toHaveBeenCalledWith(
audienceMonthlyStripe.stripeId,
);
expect(usersService.findUserByEmail).toHaveBeenCalledWith(
audienceUserStripe.email,
);
expect(service.notifySubscriptor).toHaveBeenCalledWith(
audienceMonthlyStripe.stripeId,
'd-62fb0e9d71304edfaed4f4f1781dc6f9',
);
});
});
describe('updateOnInvoicePaymentFailed', () => {
it('should update subscription status to rejected on invoice payment failed', async () => {
const mockWebhookEvent: any = {
data: {
object: {
subscription: audienceMonthlyRejectedStripe.stripeId,
},
},
};
const mockStripeSubscription = {
subscription: {
id: audienceMonthlyRejectedStripe.stripeId,
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
};
jest
.spyOn(service, 'getStripeSubscription')
.mockResolvedValue(mockStripeSubscription as any);
jest
.spyOn(subscriptionsRepository, 'findOneOrFail')
.mockResolvedValue(audienceMonthlyRejectedStripe);
jest
.spyOn(subscriptionsRepository, 'save')
.mockResolvedValue(audienceMonthlyRejectedStripe);
await service.updateOnInvoicePaymentFailed(mockWebhookEvent);
expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({
where: { stripeId: audienceMonthlyRejectedStripe.stripeId },
});
expect(subscriptionsRepository.save).toHaveBeenCalledWith({
...audienceMonthlyRejectedStripe,
status: 'rejected',
periodEnd: new Date(
mockStripeSubscription.subscription.current_period_end * 1000,
),
});
});
it('should log an error if subscription is not found on invoice payment failed', async () => {
const mockWebhookEvent: any = {
data: {
object: {
subscription: 'non_existent_subscription_id',
},
},
};
jest
.spyOn(subscriptionsRepository, 'findOneOrFail')
.mockRejectedValue(new Error('No subscription found'));
await service.updateOnInvoicePaymentFailed(mockWebhookEvent);
expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({
where: { stripeId: 'non_existent_subscription_id' },
});
expect(Logger.error).toHaveBeenCalledTimes(2);
});
});
describe('createBilling', () => {
it('should create a billing portal session', async () => {
const mockStripeSubscription = {
subscription: {
id: rssAddonMonthlyStripe.stripeId,
customer: { id: 'cus_123', email: proPlusUserStripe.email },
},
};
const mockSession = { url: 'https://billing.portal.url' };
jest
.spyOn(subscriptionsRepository, 'findOneOrFail')
.mockResolvedValue(rssAddonMonthlyStripe);
jest
.spyOn(service, 'getStripeSubscription')
.mockResolvedValue(mockStripeSubscription as any);
jest
.spyOn(stripe.billingPortal.configurations, 'create')
.mockResolvedValue({
id: 'config_123',
} as any);
jest
.spyOn(stripe.billingPortal.sessions, 'create')
.mockImplementation()
.mockResolvedValue(mockSession as any);
const result = await service.createBilling(proPlusUserStripe, 'pro-plus');
expect(result).toBe(mockSession.url);
expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({
where: { userId: proPlusUserStripe.id },
order: { createdAt: 'DESC' },
});
expect(service.getStripeSubscription).toHaveBeenCalledWith(
rssAddonMonthlyStripe.stripeId,
);
expect(
service['stripe'].billingPortal.sessions.create,
).toHaveBeenCalledWith({
customer: 'cus_123',
return_url: expect.stringContaining('?refresh=true'),
configuration: expect.any(String),
});
});
it('should delete the subscription and throw an error if creating billing portal fails', async () => {
jest
.spyOn(subscriptionsRepository, 'findOneOrFail')
.mockResolvedValue(verificationAddonMonthlyStripe);
jest
.spyOn(service, 'getStripeSubscription')
.mockRejectedValue(new Error('Stripe error'));
jest.spyOn(subscriptionsRepository, 'delete').mockResolvedValue({
affected: 1,
raw: [],
});
await expect(
service.createBilling(ultimateUserStripe, 'ultimate'),
).rejects.toThrow('Error creating billing portal');
expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({
where: { userId: ultimateUserStripe.id },
order: { createdAt: 'DESC' },
});
expect(service.getStripeSubscription).toHaveBeenCalledWith(
verificationAddonMonthlyStripe.stripeId,
);
expect(subscriptionsRepository.delete).toHaveBeenCalledWith(
verificationAddonMonthlyStripe.id,
);
});
});
describe('updateOnInvoicePaid', () => {
it('should update subscription status to succeeded on invoice paid', async () => {
const mockWebhookEvent: any = {
data: {
object: {
subscription: audienceMonthlyStripe.stripeId,
},
},
};
const mockStripeSubscription = {
subscription: {
id: audienceMonthlyStripe.stripeId,
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
};
jest
.spyOn(service, 'getStripeSubscription')
.mockResolvedValue(mockStripeSubscription as any);
jest
.spyOn(subscriptionsRepository, 'update')
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] });
await service.updateOnInvoicePaid(mockWebhookEvent);
expect(subscriptionsRepository.update).toHaveBeenCalledWith(
{ stripeId: audienceMonthlyStripe.stripeId },
{
status: 'succeeded',
periodEnd: new Date(
mockStripeSubscription.subscription.current_period_end * 1000 +
24 * 60 * 60 * 1000,
),
},
);
});
it('should log an error if subscription is not found on invoice paid', async () => {
const mockWebhookEvent: any = {
data: {
object: {
subscription: 'non_existent_subscription_id',
},
},
};
jest
.spyOn(service, 'getStripeSubscription')
.mockRejectedValue(new Error('No subscription found'));
await service.updateOnInvoicePaid(mockWebhookEvent);
expect(Logger.error).toHaveBeenCalledWith('No subscription found');
});
});
});