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:
@@ -0,0 +1,5 @@
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AllowedSubscriptionType } from '../enums/types.enum';
|
||||
|
||||
export const Subscriptions =
|
||||
Reflector.createDecorator<AllowedSubscriptionType[]>();
|
||||
@@ -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;
|
||||
}
|
||||
11
backend/src/subscriptions/dto/create-subscription.dto.ts
Normal file
11
backend/src/subscriptions/dto/create-subscription.dto.ts
Normal 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;
|
||||
}
|
||||
37
backend/src/subscriptions/dto/flash-webhook-event.dto.ts
Normal file
37
backend/src/subscriptions/dto/flash-webhook-event.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
29
backend/src/subscriptions/dto/response/subscription.dto.ts
Normal file
29
backend/src/subscriptions/dto/response/subscription.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
backend/src/subscriptions/entities/subscription.entity.ts
Normal file
54
backend/src/subscriptions/entities/subscription.entity.ts
Normal 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;
|
||||
}
|
||||
7
backend/src/subscriptions/enums/flash-event.enum.ts
Normal file
7
backend/src/subscriptions/enums/flash-event.enum.ts
Normal 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];
|
||||
2
backend/src/subscriptions/enums/periods.enum.ts
Normal file
2
backend/src/subscriptions/enums/periods.enum.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const subscriptionPeriods = ['yearly', 'monthly'] as const;
|
||||
export type SubscriptionPeriod = (typeof subscriptionPeriods)[number];
|
||||
10
backend/src/subscriptions/enums/status.enum.ts
Normal file
10
backend/src/subscriptions/enums/status.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const paymentStatus = [
|
||||
'created',
|
||||
'processing',
|
||||
'succeeded',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
'paused',
|
||||
'expired',
|
||||
] as const;
|
||||
export type PaymentStatus = (typeof paymentStatus)[number];
|
||||
20
backend/src/subscriptions/enums/types.enum.ts
Normal file
20
backend/src/subscriptions/enums/types.enum.ts
Normal 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];
|
||||
252
backend/src/subscriptions/flash-subscription.service.ts
Normal file
252
backend/src/subscriptions/flash-subscription.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
60
backend/src/subscriptions/guards/pay-with-flash.guard.ts
Normal file
60
backend/src/subscriptions/guards/pay-with-flash.guard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/src/subscriptions/guards/subscription.guard.ts
Normal file
41
backend/src/subscriptions/guards/subscription.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
35
backend/src/subscriptions/helpers/subscription.helper.ts
Normal file
35
backend/src/subscriptions/helpers/subscription.helper.ts
Normal 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(' ');
|
||||
};
|
||||
84
backend/src/subscriptions/subscription-email.service.ts
Normal file
84
backend/src/subscriptions/subscription-email.service.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
86
backend/src/subscriptions/subscriptions.controller.ts
Normal file
86
backend/src/subscriptions/subscriptions.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
backend/src/subscriptions/subscriptions.module.ts
Normal file
20
backend/src/subscriptions/subscriptions.module.ts
Normal 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 {}
|
||||
172
backend/src/subscriptions/subscriptions.service.ts
Normal file
172
backend/src/subscriptions/subscriptions.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
1279
backend/src/subscriptions/tests/flash-subscription.service.spec.ts
Normal file
1279
backend/src/subscriptions/tests/flash-subscription.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
514
backend/src/subscriptions/tests/subscriptions.service.spec.ts
Normal file
514
backend/src/subscriptions/tests/subscriptions.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user