diff --git a/package.json b/package.json
index 878ad77..bb9e55f 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"pg": "^8.11.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
+ "stripe": "^14.9.0",
"tweetnacl": "^1.0.3",
"typeorm": "^0.3.17",
"uuid": "^9.0.0"
diff --git a/src/common/config/configuration.ts b/src/common/config/configuration.ts
index 57dcb55..cc48b6e 100644
--- a/src/common/config/configuration.ts
+++ b/src/common/config/configuration.ts
@@ -36,6 +36,14 @@ export const configuration = () => ({
api_key: process.env.SENDGRID_API_KEY,
email: process.env.SENDGRID_EMAIL,
},
+ stripe: {
+ api_key: process.env.STRIPE_API_KEY,
+ api_version: process.env.STRIPE_API_VERSION,
+ webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
+ base_product: process.env.STRIPE_BASE_PRODUCT,
+ success_url: process.env.STRIPE_SUCCESS_URL,
+ error_url: process.env.STRIPE_ERROR_URL,
+ },
email_domains: process.env.EMAIL_DOMAINS,
storage_cost_usd: process.env.STORAGE_COST_USD,
});
diff --git a/src/common/constants.ts b/src/common/constants.ts
index 1a1c466..9306ab5 100644
--- a/src/common/constants.ts
+++ b/src/common/constants.ts
@@ -3,3 +3,4 @@ export const MAX_FILE_SIZE_1000MB = 1048576000;
export const NEAR_PRICE_USD_COINGECKO_URL =
'https://api.coingecko.com/api/v3/simple/price?ids=near&vs_currencies=usd';
export const MIME_TYPE_WAV = 'audio/wav';
+export const default_currency = 'usd';
diff --git a/src/common/email-templates/invoice-template.ts b/src/common/email-templates/invoice-template.ts
new file mode 100644
index 0000000..bac9880
--- /dev/null
+++ b/src/common/email-templates/invoice-template.ts
@@ -0,0 +1,58 @@
+export const invoiceLinkTemplate = (invoiceUrl: string) => `
+
+
+
+
+
+ Your Invoice Link
+
+
+
+
+
+
Your Invoice from Raidar
+
Thank you for your purchase. You can view and download your invoice by clicking the link below:
+
View Invoice
+
If you have any questions, please contact support.
+
Sincerely,
+
The Raidar Team
+
+
+
+
+`;
diff --git a/src/main.ts b/src/main.ts
index 9ddc751..bc108ac 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -16,9 +16,13 @@ import { TasksModule } from './modules/task/task.module';
import { CoingeckoModule } from './modules/coingecko/coingecko.module';
import { MarketplaceModule } from './modules/marketplace/marketplace.module';
import { ContractModule } from './modules/contract/contract.module';
+import { StripeModule } from './modules/stripe/stripe.module';
+import { NestExpressApplication } from '@nestjs/platform-express';
async function bootstrap() {
- const app = await NestFactory.create(AppModule);
+ const app = await NestFactory.create(AppModule, {
+ rawBody: true,
+ });
const configService = app.get(ConfigService);
app.useGlobalPipes(new ValidationPipe());
@@ -40,6 +44,7 @@ async function bootstrap() {
FileModule,
MarketplaceModule,
ContractModule,
+ StripeModule,
],
});
diff --git a/src/migrations/1702992129177-stripe_attributes.ts b/src/migrations/1702992129177-stripe_attributes.ts
new file mode 100644
index 0000000..95ad3d1
--- /dev/null
+++ b/src/migrations/1702992129177-stripe_attributes.ts
@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class StripeAttributes1702992129177 implements MigrationInterface {
+ name = 'StripeAttributes1702992129177'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "licence" ADD "invoice_id" character varying(255)`);
+ await queryRunner.query(`ALTER TABLE "song" ADD "price_id" character varying(255)`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "song" DROP COLUMN "price_id"`);
+ await queryRunner.query(`ALTER TABLE "licence" DROP COLUMN "invoice_id"`);
+ }
+
+}
diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts
index 749381c..8c09de9 100644
--- a/src/modules/app/app.module.ts
+++ b/src/modules/app/app.module.ts
@@ -21,6 +21,7 @@ import { CoingeckoModule } from '../coingecko/coingecko.module';
import { ScheduleModule } from '@nestjs/schedule';
import { MarketplaceModule } from '../marketplace/marketplace.module';
import { ContractModule } from '../contract/contract.module';
+import { StripeModule } from '../stripe/stripe.module';
dotenv.config({
path: existsSync(`.env.${process.env.MODE}`)
@@ -48,6 +49,7 @@ dotenv.config({
CoingeckoModule,
MarketplaceModule,
ContractModule,
+ StripeModule,
],
controllers: [AppController],
providers: [
diff --git a/src/modules/licence/licence.entity.ts b/src/modules/licence/licence.entity.ts
index 63f7104..a4cf2f1 100644
--- a/src/modules/licence/licence.entity.ts
+++ b/src/modules/licence/licence.entity.ts
@@ -40,4 +40,7 @@ export class Licence extends BaseEntity {
@Column({ type: 'varchar', length: 255, nullable: true })
sold_price: string;
+
+ @Column({ type: 'varchar', length: 255, nullable: true })
+ public invoice_id: string;
}
diff --git a/src/modules/song/song.entity.ts b/src/modules/song/song.entity.ts
index 56d7448..082df5c 100644
--- a/src/modules/song/song.entity.ts
+++ b/src/modules/song/song.entity.ts
@@ -111,6 +111,9 @@ export class Song extends BaseEntity {
})
price: number;
+ @Column({ type: 'varchar', length: 255, nullable: true })
+ price_id: string;
+
@Column({
type: 'integer',
nullable: false,
diff --git a/src/modules/song/song.module.ts b/src/modules/song/song.module.ts
index 8da10ac..ee67c73 100644
--- a/src/modules/song/song.module.ts
+++ b/src/modules/song/song.module.ts
@@ -10,6 +10,7 @@ import { User } from '../user/user.entity';
import { EmailService } from '../email/email.service';
import { HttpModule } from '@nestjs/axios';
import { CoingeckoModule } from '../coingecko/coingecko.module';
+import { StripeService } from '../stripe/stripe.service';
@Module({
imports: [
@@ -17,7 +18,7 @@ import { CoingeckoModule } from '../coingecko/coingecko.module';
TypeOrmModule.forFeature([Song, File, Album, User, Licence]),
CoingeckoModule,
],
- providers: [SongService, EmailService],
+ providers: [SongService, EmailService, StripeService],
controllers: [SongController],
})
export class SongModule {}
diff --git a/src/modules/song/song.service.spec.ts b/src/modules/song/song.service.spec.ts
index 525368a..f51acd4 100644
--- a/src/modules/song/song.service.spec.ts
+++ b/src/modules/song/song.service.spec.ts
@@ -21,6 +21,7 @@ import { ServiceResult } from '../../helpers/response/result';
import { EmailService } from '../email/email.service';
import { ConfigService } from '@nestjs/config';
import { CoingeckoService } from '../coingecko/coingecko.service';
+import { StripeService } from '../stripe/stripe.service';
describe('SongService', () => {
let songService: SongService;
@@ -125,6 +126,12 @@ describe('SongService', () => {
send: jest.fn().mockReturnValue(true),
},
},
+ {
+ provide: StripeService,
+ useValue: {
+ createPrice: jest.fn().mockReturnValue('1'),
+ },
+ },
{
provide: ConfigService,
useValue: {
diff --git a/src/modules/song/song.service.ts b/src/modules/song/song.service.ts
index 5a6b158..5b01ad5 100644
--- a/src/modules/song/song.service.ts
+++ b/src/modules/song/song.service.ts
@@ -41,6 +41,7 @@ import { ConfigService } from '@nestjs/config';
import { songDownloadTemplate } from '../../common/email-templates/song-dowload-template';
import { songBoughtTemplate } from '../../common/email-templates/song-bought-notif-template';
import { CoingeckoService } from '../coingecko/coingecko.service';
+import { StripeService } from '../stripe/stripe.service';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nearAPI = require('near-api-js');
@@ -64,6 +65,7 @@ export class SongService {
private readonly emailService: EmailService,
private readonly configService: ConfigService,
private readonly coingeckoService: CoingeckoService,
+ private readonly stripeService: StripeService,
) {}
async createSong(dto: CreateSongDto): Promise> {
@@ -119,6 +121,12 @@ export class SongService {
if (!user) {
return new NotFound(`User not found!`);
}
+
+ const stripePrice = await this.stripeService.createPrice(dto.price);
+ if (!stripePrice) {
+ throw new Error('Failed to create price in Stripe');
+ }
+
const priceInNear = await this.coingeckoService.convertUsdToNear(
dto.price,
);
@@ -127,7 +135,7 @@ export class SongService {
const new_song = this.songRepository.create(
createSongMapper(dto, user, album, music_file, art_file),
);
-
+ new_song.price_id = stripePrice.id;
await this.songRepository.save(new_song);
const song = await this.songRepository.findOne(findOneSong(new_song.id));
diff --git a/src/modules/stripe/stripe.controller.ts b/src/modules/stripe/stripe.controller.ts
new file mode 100644
index 0000000..b9b077f
--- /dev/null
+++ b/src/modules/stripe/stripe.controller.ts
@@ -0,0 +1,50 @@
+import {
+ BadRequestException,
+ Controller,
+ Post,
+ Req,
+ UseFilters,
+ HttpCode,
+ Param,
+ RawBodyRequest,
+} from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { StripeService } from './stripe.service';
+import { HttpExceptionFilter } from '../../helpers/filters/http-exception.filter';
+import { AuthRequest } from '../../common/types/auth-request.type';
+import { Auth } from '../../helpers/decorators/auth.decorator';
+import { Role } from '../../common/enums/enum';
+import { handle } from '../../helpers/response/handle';
+
+@ApiTags('stripe')
+@Controller('stripe')
+export class StripeController {
+ constructor(private readonly stripeService: StripeService) {}
+
+ @Post('session/:songId')
+ @Auth(Role.User)
+ @UseFilters(new HttpExceptionFilter())
+ @HttpCode(200)
+ async createSession(
+ @Req() request: AuthRequest,
+ @Param('songId') songId: string,
+ ) {
+ return handle(
+ await this.stripeService.createCheckoutSession(songId, request.user.id),
+ );
+ }
+
+ @Post('webhook')
+ @UseFilters(new HttpExceptionFilter())
+ async chargeCaptured(@Req() request: RawBodyRequest) {
+ if (!request.headers['stripe-signature']) {
+ throw new BadRequestException('Missing stripe-signature header');
+ }
+ return handle(
+ await this.stripeService.constructEventFromPayload(
+ request.headers['stripe-signature'],
+ request.rawBody,
+ ),
+ );
+ }
+}
diff --git a/src/modules/stripe/stripe.module.ts b/src/modules/stripe/stripe.module.ts
new file mode 100644
index 0000000..478bd18
--- /dev/null
+++ b/src/modules/stripe/stripe.module.ts
@@ -0,0 +1,15 @@
+import { Module } from '@nestjs/common';
+import { StripeService } from './stripe.service';
+import { StripeController } from './stripe.controller';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { User } from '../user/user.entity';
+import { Song } from '../song/song.entity';
+import { Licence } from '../licence/licence.entity';
+import { EmailService } from '../email/email.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([User, Song, Licence])],
+ controllers: [StripeController],
+ providers: [StripeService, EmailService],
+})
+export class StripeModule {}
diff --git a/src/modules/stripe/stripe.service.ts b/src/modules/stripe/stripe.service.ts
new file mode 100644
index 0000000..eb76699
--- /dev/null
+++ b/src/modules/stripe/stripe.service.ts
@@ -0,0 +1,213 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Song } from '../song/song.entity';
+import { ServiceResult } from '../../helpers/response/result';
+import {
+ BadRequest,
+ NotFound,
+ ServerError,
+} from '../../helpers/response/errors';
+import { User } from '../user/user.entity';
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+import { Cache } from 'cache-manager';
+import { ConfigService } from '@nestjs/config';
+import Stripe from 'stripe';
+import { Licence } from '../licence/licence.entity';
+import { EmailService } from '../email/email.service';
+import { songBoughtTemplate } from '../../common/email-templates/song-bought-notif-template';
+import { songDownloadTemplate } from '../../common/email-templates/song-dowload-template';
+import { invoiceLinkTemplate } from '../../common/email-templates/invoice-template';
+import { findSongWithUser } from '../song/queries/song.queries';
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const nearAPI = require('near-api-js');
+
+@Injectable()
+export class StripeService {
+ private readonly logger = new Logger(StripeService.name);
+ private stripe: Stripe;
+
+ constructor(
+ private readonly configService: ConfigService,
+ @InjectRepository(User)
+ private userRepository: Repository,
+ @InjectRepository(Song)
+ private songRepository: Repository,
+ @InjectRepository(Licence)
+ private licenceRepository: Repository,
+ @Inject(CACHE_MANAGER) private cacheManager: Cache,
+ private readonly emailService: EmailService,
+ ) {
+ this.stripe = new Stripe(this.configService.get('stripe.api_key'), {
+ apiVersion: this.configService.get('stripe.api_version'),
+ });
+ }
+
+ async createPrice(amount: number): Promise {
+ try {
+ const price = await this.stripe.prices.create({
+ unit_amount: Math.round(amount * 100),
+ currency: 'usd',
+ product: this.configService.get('stripe.base_product'),
+ });
+ return price;
+ } catch (error) {
+ this.logger.error('StripeService - createPrice', error);
+ return null;
+ }
+ }
+
+ async createCheckoutSession(
+ song_id: string,
+ user_id: string,
+ ): Promise> {
+ try {
+ const songQuery = findSongWithUser(song_id);
+ const song = await this.songRepository.findOne(songQuery);
+
+ if (!song) {
+ return new NotFound(`Song not found`);
+ }
+
+ const user = await this.userRepository.findOne({
+ where: { id: user_id },
+ });
+ if (!user) {
+ return new NotFound(`User not found!`);
+ }
+
+ const existingLicence = await this.licenceRepository.findOne({
+ where: { song: { id: song.id }, buyer: { id: user.id } },
+ });
+
+ if (existingLicence) {
+ return new BadRequest(
+ `Buyer already owns a licence for this song!`,
+ );
+ }
+
+ const seller = await this.userRepository.findOne({
+ where: { id: song.user.id },
+ });
+
+ if (!seller) {
+ return new NotFound(`Seller not found!`);
+ }
+
+ const session = await this.stripe.checkout.sessions.create({
+ payment_method_types: ['card'],
+ line_items: [{ price: song.price_id, quantity: 1 }],
+ metadata: { song_id, user_id },
+ mode: 'payment',
+ success_url: this.configService.get('stripe.success_url'),
+ cancel_url: this.configService.get('stripe.error_url'),
+ invoice_creation: {
+ enabled: true,
+ },
+ });
+ return new ServiceResult(session);
+ } catch (error) {
+ this.logger.error('StripeService - createCheckoutSession', error);
+ return new ServerError(
+ `Can't create checkout session`,
+ );
+ }
+ }
+
+ public async constructEventFromPayload(
+ signature: string | string[],
+ payload: Buffer,
+ ) {
+ try {
+ const webhookSecret = this.configService.get('stripe.webhook_secret');
+ const event = this.stripe.webhooks.constructEvent(
+ payload,
+ signature,
+ webhookSecret,
+ );
+ return await this.stripeWebhook(event);
+ } catch (error) {
+ this.logger.error(
+ 'StripeWebhook - constructEventFromPayload',
+ error.message,
+ );
+ throw new Error(`Webhook Error: ${error.message}`);
+ }
+ }
+
+ async stripeWebhook(event: any): Promise> {
+ if (event.type === 'checkout.session.completed') {
+ return await this.checkoutSessionCompleted(event.data.object);
+ }
+ return new ServiceResult(true);
+ }
+
+ async checkoutSessionCompleted(
+ session: any,
+ ): Promise> {
+ try {
+ let invoice_pdf = '';
+ if (session.invoice) {
+ const invoice = await this.stripe.invoices.retrieve(session.invoice);
+
+ if (invoice) {
+ invoice_pdf = invoice.invoice_pdf;
+ }
+ }
+ const songQuery = findSongWithUser(session.metadata.song_id);
+ const song = await this.songRepository.findOne(songQuery);
+
+ if (!song) {
+ return new NotFound(`Song not found`);
+ }
+
+ const buyer = await this.userRepository.findOne({
+ where: { id: session.metadata.user_id },
+ });
+
+ const seller = await this.userRepository.findOne({
+ where: { id: song.user.id },
+ });
+
+ const licence = this.licenceRepository.create();
+ licence.song = song;
+ licence.seller = seller;
+ licence.buyer = buyer;
+ licence.invoice_id = session.invoice;
+
+ const near_usd = await this.cacheManager.get('near-usd');
+ licence.sold_price = nearAPI.utils.format.parseNearAmount(
+ (song.price / Number(near_usd)).toString(),
+ );
+
+ await this.licenceRepository.save(licence);
+
+ await this.emailService.send({
+ to: seller.email,
+ from: this.configService.get('sendgrid.email'),
+ subject: 'Your Song Has Been Sold',
+ html: songBoughtTemplate(song.title),
+ });
+
+ await this.emailService.send({
+ to: buyer.email,
+ from: this.configService.get('sendgrid.email'),
+ subject: 'Download Your Raidar Song',
+ html: songDownloadTemplate(song.title, song.music.url),
+ });
+
+ await this.emailService.send({
+ to: buyer.email,
+ from: this.configService.get('sendgrid.email'),
+ subject: 'Your Raidar Invoice',
+ html: invoiceLinkTemplate(invoice_pdf),
+ });
+
+ return new ServiceResult(true);
+ } catch (error) {
+ this.logger.error('StripeService - checkoutSessionCompleted', error);
+ return new ServerError(`Checkout session completed error`);
+ }
+ }
+}