From 77eee1b954ff5e4a9c73f2b43c11a5c7acf61e22 Mon Sep 17 00:00:00 2001 From: Ivor Baric Date: Tue, 19 Dec 2023 10:49:19 +0100 Subject: [PATCH 1/2] initial feature push --- package.json | 1 + src/common/constants.ts | 1 + src/modules/licence/licence.entity.ts | 3 + src/modules/song/song.entity.ts | 3 + src/modules/song/song.module.ts | 3 +- src/modules/song/song.service.spec.ts | 7 + src/modules/song/song.service.ts | 8 +- src/modules/stripe/dto/create-session.dto.ts | 12 ++ src/modules/stripe/stripe.controller.ts | 45 ++++ src/modules/stripe/stripe.module.ts | 14 ++ src/modules/stripe/stripe.service.ts | 214 +++++++++++++++++++ 11 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/modules/stripe/dto/create-session.dto.ts create mode 100644 src/modules/stripe/stripe.controller.ts create mode 100644 src/modules/stripe/stripe.module.ts create mode 100644 src/modules/stripe/stripe.service.ts 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/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/modules/licence/licence.entity.ts b/src/modules/licence/licence.entity.ts index 63f7104..a08d0b1 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 invoiceId: string; } diff --git a/src/modules/song/song.entity.ts b/src/modules/song/song.entity.ts index 56d7448..a681f5e 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 }) + priceId: 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..5086745 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> { @@ -127,7 +129,11 @@ export class SongService { const new_song = this.songRepository.create( createSongMapper(dto, user, album, music_file, art_file), ); - + const stripePrice = await this.stripeService.createPrice(dto.price); + if (!stripePrice) { + throw new Error('Failed to create price in Stripe'); + } + new_song.priceId = stripePrice.id; await this.songRepository.save(new_song); const song = await this.songRepository.findOne(findOneSong(new_song.id)); diff --git a/src/modules/stripe/dto/create-session.dto.ts b/src/modules/stripe/dto/create-session.dto.ts new file mode 100644 index 0000000..ba0e95c --- /dev/null +++ b/src/modules/stripe/dto/create-session.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateSessionDto { + @ApiProperty() + songId: string; + + @ApiProperty() + userId: string; + + @ApiProperty() + priceId: string; +} diff --git a/src/modules/stripe/stripe.controller.ts b/src/modules/stripe/stripe.controller.ts new file mode 100644 index 0000000..7ca5916 --- /dev/null +++ b/src/modules/stripe/stripe.controller.ts @@ -0,0 +1,45 @@ +import { + BadRequestException, + Controller, + Post, + Req, + UseFilters, + HttpCode, + Body, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { StripeService } from './stripe.service'; +import { HttpExceptionFilter } from '../../helpers/filters/http-exception.filter'; +import { CommonApiResponse } from '../../helpers/decorators/api-response-swagger.decorator'; +import { CreateSessionDto } from './dto/create-session.dto'; + +@ApiTags('stripe') +@Controller('stripe') +export class StripeController { + constructor(private readonly stripeService: StripeService) {} + + @Post('session') + @UseFilters(new HttpExceptionFilter()) + @CommonApiResponse({ type: CreateSessionDto }) // Adjust response type if needed + @HttpCode(200) + async createSession(@Body() createSessionDto: CreateSessionDto) { + return this.stripeService.createCheckoutSession( + createSessionDto.songId, + createSessionDto.userId, + createSessionDto.priceId, + ); + } + + @Post('webhook') + @UseFilters(new HttpExceptionFilter()) + @HttpCode(200) + async handleWebhook(@Req() request: any) { + if (!request.headers['stripe-signature']) { + throw new BadRequestException('Missing stripe-signature header'); + } + return 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..4e394f9 --- /dev/null +++ b/src/modules/stripe/stripe.module.ts @@ -0,0 +1,14 @@ +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'; + +@Module({ + imports: [TypeOrmModule.forFeature([User, Song, Licence])], + controllers: [StripeController], + providers: [StripeService], +}) +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..92ce65c --- /dev/null +++ b/src/modules/stripe/stripe.service.ts @@ -0,0 +1,214 @@ +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'; + +// 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, + ) { + 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: amount * 100, + currency: 'usd', + product: 'prod_generic_song', + }); + return price; + } catch (error) { + this.logger.error('StripeService - createPrice', error); + return null; + } + } + + async createCheckoutSession( + songId: string, + userId: string, + priceId: string, + ): Promise> { + try { + //TODO UPDATE THE SUCCESS AND CANCEL URL + const session = await this.stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [{ price: priceId, quantity: 1 }], + metadata: { songId, userId }, + mode: 'payment', + success_url: 'raidar.us', + cancel_url: 'raidar.us', + }); + 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); + } + + if (event.type === 'charge.refunded') { + return await this.chargeRefunded(event.data.object.invoice); + } + + return new ServiceResult(true); + } + + async checkoutSessionCompleted( + session: any, + ): Promise> { + try { + if (!session.client_reference_id) { + return new BadRequest(`User not found!`); + } + + const user_id = session.client_reference_id; + + const user = await this.userRepository.findOne({ where: user_id }); + if (!user) { + return new NotFound(`User not found!`); + } + + let invoice_pdf = ''; + if (session.invoice) { + const invoice = await this.stripe.invoices.retrieve(session.invoice); + + if (invoice) { + invoice_pdf = invoice.invoice_pdf; + } + } + const song = await this.songRepository.findOne({ + where: { id: session.songId }, + }); + + if (!song) { + return new NotFound(`Song not found`); + } + + const buyer = await this.userRepository.findOne({ + where: { id: session.userId }, + }); + + if (!buyer) { + return new NotFound(`Buyer not found!`); + } + + const existingLicence = await this.licenceRepository.findOne({ + where: { song: { id: song.id }, buyer: { id: buyer.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 licence = this.licenceRepository.create(); + licence.song = song; + licence.seller = seller; + licence.buyer = buyer; + licence.invoiceId = session.invoice.id; + + 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); + + //TODO Email sa invoice-om + + return new ServiceResult(true); + } catch (error) { + this.logger.error('StripeService - checkoutSessionCompleted', error); + return new ServerError(`Checkout session completed error`); + } + } + + private async chargeRefunded( + invoice_id: string, + ): Promise> { + try { + const licence = await this.licenceRepository.findOne({ + where: { invoiceId: invoice_id }, + }); + + if (!licence) { + this.logger.error('No licence found for the given invoice ID'); + return new NotFound( + 'Licence not found for the given invoice ID', + ); + } + + await this.licenceRepository.remove(licence); + + return new ServiceResult(true); + } catch (error) { + this.logger.error('StripeService - handleChargeRefunded', error); + throw new Error(`Charge Refunded Error: ${error.message}`); + } + } +} From f7b805cf00344bef329fe729bcd2f6e473492c07 Mon Sep 17 00:00:00 2001 From: Ivor Baric Date: Tue, 19 Dec 2023 15:42:27 +0100 Subject: [PATCH 2/2] fixing issues and adding email send --- src/common/config/configuration.ts | 8 + .../email-templates/invoice-template.ts | 58 +++++++ src/main.ts | 7 +- .../1702992129177-stripe_attributes.ts | 16 ++ src/modules/app/app.module.ts | 2 + src/modules/licence/licence.entity.ts | 2 +- src/modules/song/song.entity.ts | 2 +- src/modules/song/song.service.ts | 12 +- src/modules/stripe/dto/create-session.dto.ts | 12 -- src/modules/stripe/stripe.controller.ts | 35 +++-- src/modules/stripe/stripe.module.ts | 3 +- src/modules/stripe/stripe.service.ts | 145 +++++++++--------- 12 files changed, 193 insertions(+), 109 deletions(-) create mode 100644 src/common/email-templates/invoice-template.ts create mode 100644 src/migrations/1702992129177-stripe_attributes.ts delete mode 100644 src/modules/stripe/dto/create-session.dto.ts 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/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 a08d0b1..a4cf2f1 100644 --- a/src/modules/licence/licence.entity.ts +++ b/src/modules/licence/licence.entity.ts @@ -42,5 +42,5 @@ export class Licence extends BaseEntity { sold_price: string; @Column({ type: 'varchar', length: 255, nullable: true }) - public invoiceId: string; + public invoice_id: string; } diff --git a/src/modules/song/song.entity.ts b/src/modules/song/song.entity.ts index a681f5e..082df5c 100644 --- a/src/modules/song/song.entity.ts +++ b/src/modules/song/song.entity.ts @@ -112,7 +112,7 @@ export class Song extends BaseEntity { price: number; @Column({ type: 'varchar', length: 255, nullable: true }) - priceId: string; + price_id: string; @Column({ type: 'integer', diff --git a/src/modules/song/song.service.ts b/src/modules/song/song.service.ts index 5086745..5b01ad5 100644 --- a/src/modules/song/song.service.ts +++ b/src/modules/song/song.service.ts @@ -121,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, ); @@ -129,11 +135,7 @@ export class SongService { const new_song = this.songRepository.create( createSongMapper(dto, user, album, music_file, art_file), ); - const stripePrice = await this.stripeService.createPrice(dto.price); - if (!stripePrice) { - throw new Error('Failed to create price in Stripe'); - } - new_song.priceId = stripePrice.id; + 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/dto/create-session.dto.ts b/src/modules/stripe/dto/create-session.dto.ts deleted file mode 100644 index ba0e95c..0000000 --- a/src/modules/stripe/dto/create-session.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class CreateSessionDto { - @ApiProperty() - songId: string; - - @ApiProperty() - userId: string; - - @ApiProperty() - priceId: string; -} diff --git a/src/modules/stripe/stripe.controller.ts b/src/modules/stripe/stripe.controller.ts index 7ca5916..b9b077f 100644 --- a/src/modules/stripe/stripe.controller.ts +++ b/src/modules/stripe/stripe.controller.ts @@ -5,41 +5,46 @@ import { Req, UseFilters, HttpCode, - Body, + Param, + RawBodyRequest, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { StripeService } from './stripe.service'; import { HttpExceptionFilter } from '../../helpers/filters/http-exception.filter'; -import { CommonApiResponse } from '../../helpers/decorators/api-response-swagger.decorator'; -import { CreateSessionDto } from './dto/create-session.dto'; +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') + @Post('session/:songId') + @Auth(Role.User) @UseFilters(new HttpExceptionFilter()) - @CommonApiResponse({ type: CreateSessionDto }) // Adjust response type if needed @HttpCode(200) - async createSession(@Body() createSessionDto: CreateSessionDto) { - return this.stripeService.createCheckoutSession( - createSessionDto.songId, - createSessionDto.userId, - createSessionDto.priceId, + async createSession( + @Req() request: AuthRequest, + @Param('songId') songId: string, + ) { + return handle( + await this.stripeService.createCheckoutSession(songId, request.user.id), ); } @Post('webhook') @UseFilters(new HttpExceptionFilter()) - @HttpCode(200) - async handleWebhook(@Req() request: any) { + async chargeCaptured(@Req() request: RawBodyRequest) { if (!request.headers['stripe-signature']) { throw new BadRequestException('Missing stripe-signature header'); } - return this.stripeService.constructEventFromPayload( - request.headers['stripe-signature'], - request.rawBody, + 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 index 4e394f9..478bd18 100644 --- a/src/modules/stripe/stripe.module.ts +++ b/src/modules/stripe/stripe.module.ts @@ -5,10 +5,11 @@ 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], + providers: [StripeService, EmailService], }) export class StripeModule {} diff --git a/src/modules/stripe/stripe.service.ts b/src/modules/stripe/stripe.service.ts index 92ce65c..eb76699 100644 --- a/src/modules/stripe/stripe.service.ts +++ b/src/modules/stripe/stripe.service.ts @@ -14,6 +14,11 @@ 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'); @@ -32,6 +37,7 @@ export class StripeService { @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'), @@ -41,9 +47,9 @@ export class StripeService { async createPrice(amount: number): Promise { try { const price = await this.stripe.prices.create({ - unit_amount: amount * 100, + unit_amount: Math.round(amount * 100), currency: 'usd', - product: 'prod_generic_song', + product: this.configService.get('stripe.base_product'), }); return price; } catch (error) { @@ -53,19 +59,52 @@ export class StripeService { } async createCheckoutSession( - songId: string, - userId: string, - priceId: string, + song_id: string, + user_id: string, ): Promise> { try { - //TODO UPDATE THE SUCCESS AND CANCEL URL + 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: priceId, quantity: 1 }], - metadata: { songId, userId }, + line_items: [{ price: song.price_id, quantity: 1 }], + metadata: { song_id, user_id }, mode: 'payment', - success_url: 'raidar.us', - cancel_url: 'raidar.us', + 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) { @@ -101,11 +140,6 @@ export class StripeService { if (event.type === 'checkout.session.completed') { return await this.checkoutSessionCompleted(event.data.object); } - - if (event.type === 'charge.refunded') { - return await this.chargeRefunded(event.data.object.invoice); - } - return new ServiceResult(true); } @@ -113,17 +147,6 @@ export class StripeService { session: any, ): Promise> { try { - if (!session.client_reference_id) { - return new BadRequest(`User not found!`); - } - - const user_id = session.client_reference_id; - - const user = await this.userRepository.findOne({ where: user_id }); - if (!user) { - return new NotFound(`User not found!`); - } - let invoice_pdf = ''; if (session.invoice) { const invoice = await this.stripe.invoices.retrieve(session.invoice); @@ -132,45 +155,26 @@ export class StripeService { invoice_pdf = invoice.invoice_pdf; } } - const song = await this.songRepository.findOne({ - where: { id: session.songId }, - }); + 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.userId }, - }); - - if (!buyer) { - return new NotFound(`Buyer not found!`); - } - - const existingLicence = await this.licenceRepository.findOne({ - where: { song: { id: song.id }, buyer: { id: buyer.id } }, + where: { id: session.metadata.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 licence = this.licenceRepository.create(); licence.song = song; licence.seller = seller; licence.buyer = buyer; - licence.invoiceId = session.invoice.id; + licence.invoice_id = session.invoice; const near_usd = await this.cacheManager.get('near-usd'); licence.sold_price = nearAPI.utils.format.parseNearAmount( @@ -179,36 +183,31 @@ export class StripeService { await this.licenceRepository.save(licence); - //TODO Email sa invoice-om - - return new ServiceResult(true); - } catch (error) { - this.logger.error('StripeService - checkoutSessionCompleted', error); - return new ServerError(`Checkout session completed error`); - } - } - - private async chargeRefunded( - invoice_id: string, - ): Promise> { - try { - const licence = await this.licenceRepository.findOne({ - where: { invoiceId: invoice_id }, + await this.emailService.send({ + to: seller.email, + from: this.configService.get('sendgrid.email'), + subject: 'Your Song Has Been Sold', + html: songBoughtTemplate(song.title), }); - if (!licence) { - this.logger.error('No licence found for the given invoice ID'); - return new NotFound( - 'Licence not found for the given invoice ID', - ); - } + 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.licenceRepository.remove(licence); + 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 - handleChargeRefunded', error); - throw new Error(`Charge Refunded Error: ${error.message}`); + this.logger.error('StripeService - checkoutSessionCompleted', error); + return new ServerError(`Checkout session completed error`); } } }