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 + + + +
+ Berklee Logo +

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

+ Built on NEAR Logo +
+ + +`; 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`); + } + } +}