From f96d503d7286b9802a9e46f59a90b95438eaedc1 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Wed, 9 Oct 2024 18:44:57 +0300 Subject: [PATCH] refactor(stripe): Use PaymentIntent and subscriptions instead of Checkout sessions (#627) * feat: Add StripeModule to encapsulate stripe related logic * src/stripe: Add DTO for subscription payments * feat(stripe): Add support for recurring payment - Added endpoint to create stripe subscriptions - Add services to: Create stripe products Create stripe customers Create stripe subscriptions * refactor: Move everything Stripe related to Stripe module * refactor: Move updateDonationPayment fn to DonationService This is where it should belong * refactor: Adjust tests to latest changes * refactor(paypal.service.spec): Remove duplicate CacheModule import * feat(stripe): Don't initiate intent/subscription if campaign is completed * refactor(stripe): Create subscription from setupIntent object * chore(donations): Use card country when calculating netAmount for subscription * chore(donations): Use idempotency key when creating stripe resources * tests: Adjust tests to lates changes * src/donations: Add endpoint to get first donation by paymentIntentId Needed for creating wishes after payment has been made * stripe: Add endpoint for setupintent cancellation * chore: Address review changes * fix: App not starting --------- Co-authored-by: dimitar.nizamov --- apps/api/src/app/app.module.ts | 10 +- apps/api/src/campaign/campaign.module.ts | 10 +- apps/api/src/campaign/campaign.service.ts | 221 --------- apps/api/src/config/validation.config.ts | 2 +- .../donations/donations.controller.spec.ts | 101 +--- .../api/src/donations/donations.controller.ts | 101 +--- apps/api/src/donations/donations.module.ts | 11 +- apps/api/src/donations/donations.service.ts | 446 +++++++++-------- .../helpers/payment-intent-helpers.ts | 11 +- apps/api/src/paypal/paypal.controller.spec.ts | 6 +- apps/api/src/paypal/paypal.module.ts | 3 +- apps/api/src/paypal/paypal.service.spec.ts | 8 +- apps/api/src/paypal/paypal.service.ts | 6 +- .../recurring-donation.controller.ts | 20 - .../recurring-donation.module.ts | 1 + .../recurring-donation.service.spec.ts | 10 - .../recurring-donation.service.ts | 28 +- .../stripe/dto/cancel-payment-intent.dto.ts | 11 + .../stripe/dto/create-payment-intent.dto.ts | 21 + .../src/stripe/dto/create-setup-intent.dto.ts | 9 + .../dto/create-subscription-payment.dto.ts | 33 ++ .../stripe/dto/update-payment-intent.dto.ts | 20 + .../src/stripe/dto/update-setup-intent.dto.ts | 9 + .../events/stripe-payment.service.spec.ts | 52 +- .../events/stripe-payment.service.ts | 54 ++- .../events/stripe-payment.testdata.ts | 104 +++- .../stripe-metadata.interface.ts} | 2 +- apps/api/src/stripe/stripe.controller.spec.ts | 238 ++++++++++ apps/api/src/stripe/stripe.controller.ts | 164 +++++++ apps/api/src/stripe/stripe.module.ts | 31 ++ apps/api/src/stripe/stripe.service.spec.ts | 101 ++++ apps/api/src/stripe/stripe.service.ts | 449 ++++++++++++++++++ apps/api/src/vault/vault.controller.spec.ts | 2 +- apps/api/src/vault/vault.service.ts | 1 - package.json | 2 +- yarn.lock | 55 ++- 36 files changed, 1585 insertions(+), 768 deletions(-) create mode 100644 apps/api/src/stripe/dto/cancel-payment-intent.dto.ts create mode 100644 apps/api/src/stripe/dto/create-payment-intent.dto.ts create mode 100644 apps/api/src/stripe/dto/create-setup-intent.dto.ts create mode 100644 apps/api/src/stripe/dto/create-subscription-payment.dto.ts create mode 100644 apps/api/src/stripe/dto/update-payment-intent.dto.ts create mode 100644 apps/api/src/stripe/dto/update-setup-intent.dto.ts rename apps/api/src/{donations => stripe}/events/stripe-payment.service.spec.ts (93%) rename apps/api/src/{donations => stripe}/events/stripe-payment.service.ts (87%) rename apps/api/src/{donations => stripe}/events/stripe-payment.testdata.ts (94%) rename apps/api/src/{donations/dontation-metadata.interface.ts => stripe/stripe-metadata.interface.ts} (76%) create mode 100644 apps/api/src/stripe/stripe.controller.spec.ts create mode 100644 apps/api/src/stripe/stripe.controller.ts create mode 100644 apps/api/src/stripe/stripe.module.ts create mode 100644 apps/api/src/stripe/stripe.service.spec.ts create mode 100644 apps/api/src/stripe/stripe.service.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index ea73ef222..70efbe397 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -59,6 +59,7 @@ import { MarketingNotificationsModule } from '../notifications/notifications.mod import { StatisticsModule } from '../statistics/statistics.module' import { AffiliateModule } from '../affiliate/affiliate.module' +import { StripeModule } from '../stripe/stripe.module' import { LoggerModule } from '../logger/logger.module' import { PrismaModule } from '../prisma/prisma.module' @@ -66,7 +67,11 @@ import { CampaignApplicationModule } from '../campaign-application/campaign-appl @Module({ imports: [ - ConfigModule.forRoot({ validationSchema, isGlobal: true, load: [configuration] }), + ConfigModule.forRoot({ + validationSchema, + isGlobal: true, + load: [configuration], + }), /* External modules */ SentryModule.forRootAsync({ imports: [ConfigModule], @@ -117,7 +122,7 @@ import { CampaignApplicationModule } from '../campaign-application/campaign-appl BankTransactionsModule, StatisticsModule, CacheModule.registerAsync({ - imports: [ConfigModule], + imports: [ConfigModule, AppModule], useFactory: async (config: ConfigService) => ({ ttl: Number(config.get('CACHE_TTL', 30 * 1000 /* ms */)), }), @@ -129,6 +134,7 @@ import { CampaignApplicationModule } from '../campaign-application/campaign-appl MarketingNotificationsModule, LoggerModule, CampaignApplicationModule, + StripeModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/campaign/campaign.module.ts b/apps/api/src/campaign/campaign.module.ts index f478d2472..3ab66c607 100644 --- a/apps/api/src/campaign/campaign.module.ts +++ b/apps/api/src/campaign/campaign.module.ts @@ -12,16 +12,10 @@ import { CampaignNewsModule } from '../campaign-news/campaign-news.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' import { PrismaModule } from '../prisma/prisma.module' @Module({ - imports: [ - forwardRef(() => VaultModule), - MarketingNotificationsModule, - NotificationModule, - CampaignNewsModule, - PrismaModule, - ], + imports: [MarketingNotificationsModule, NotificationModule, CampaignNewsModule], controllers: [CampaignController, CampaignTypeController], - providers: [CampaignService, VaultService, PersonService, ConfigService], + providers: [CampaignService, PrismaService, PersonService, ConfigService], exports: [CampaignService], }) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 6d1857d19..917dfe7ec 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -56,8 +56,6 @@ import type { PaymentWithDonation } from '../donations/types/donation' export class CampaignService { constructor( private prisma: PrismaService, - private notificationService: NotificationService, - @Inject(forwardRef(() => VaultService)) private vaultService: VaultService, @Inject(forwardRef(() => PersonService)) private personService: PersonService, @Inject(forwardRef(() => MarketingNotificationsService)) private marketingNotificationsService: MarketingNotificationsService, @@ -578,225 +576,6 @@ export class CampaignService { return this.prisma.payment.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) } - /** - * Creates or Updates an incoming donation depending on the newDonationStatus attribute - * @param campaign - * @param paymentData - * @param newDonationStatus - * @param metadata - * @returns donation.id of the created/updated donation - */ - async updateDonationPayment( - campaign: Campaign, - paymentData: PaymentData, - newDonationStatus: PaymentStatus, - ): Promise { - const campaignId = campaign.id - Logger.debug('Update donation to status: ' + newDonationStatus, { - campaignId, - paymentIntentId: paymentData.paymentIntentId, - }) - - //Update existing donation or create new in a transaction that - //also increments the vault amount and marks campaign as completed - //if target amount is reached - return await this.prisma.$transaction(async (tx) => { - let donationId - // Find donation by extPaymentIntentId - const existingPayment = await this.findExistingDonation(tx, paymentData) - - //if missing create the donation with the incoming status - if (!existingPayment) { - const newDonation = await this.createIncomingDonation( - tx, - paymentData, - newDonationStatus, - campaign, - ) - donationId = newDonation.donations[0].id - } - //donation exists, so check if it is safe to update it - else { - const updatedDonation = await this.updateDonationIfAllowed( - tx, - existingPayment, - newDonationStatus, - paymentData, - ) - donationId = updatedDonation?.donations[0].id - } - return donationId - }) //end of the transaction scope - } - - private async updateDonationIfAllowed( - tx: Prisma.TransactionClient, - payment: PaymentWithDonation, - newDonationStatus: PaymentStatus, - paymentData: PaymentData, - ) { - if (shouldAllowStatusChange(payment.status, newDonationStatus)) { - try { - const updatedDonation = await tx.payment.update({ - where: { - id: payment.id, - }, - data: { - status: newDonationStatus, - amount: paymentData.netAmount, - extCustomerId: paymentData.stripeCustomerId, - extPaymentMethodId: paymentData.paymentMethodId, - extPaymentIntentId: paymentData.paymentIntentId, - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - donations: { - updateMany: { - where: { paymentId: payment.id }, - data: { - amount: paymentData.netAmount, - }, - }, - }, - }, - select: donationNotificationSelect, - }) - - //if donation is switching to successful, increment the vault amount and send notification - if ( - payment.status != PaymentStatus.succeeded && - newDonationStatus === PaymentStatus.succeeded - ) { - await this.vaultService.incrementVaultAmount( - payment.donations[0].targetVaultId, - paymentData.netAmount, - tx, - ) - this.notificationService.sendNotification('successfulDonation', { - ...updatedDonation, - person: updatedDonation.donations[0].person, - }) - } else if ( - payment.status === PaymentStatus.succeeded && - newDonationStatus === PaymentStatus.refund - ) { - await this.vaultService.decrementVaultAmount( - payment.donations[0].targetVaultId, - paymentData.netAmount, - tx, - ) - this.notificationService.sendNotification('successfulRefund', { - ...updatedDonation, - person: updatedDonation.donations[0].person, - }) - } - return updatedDonation - } catch (error) { - Logger.error( - `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, - ) - throw new InternalServerErrorException(error) - } - } - //donation exists but we need to skip because previous status is from later event than the incoming - else { - Logger.warn( - `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} - and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, - ) - } - } - - private async createIncomingDonation( - tx: Prisma.TransactionClient, - paymentData: PaymentData, - newDonationStatus: PaymentStatus, - campaign: Campaign, - ) { - Logger.debug( - 'No donation exists with extPaymentIntentId: ' + - paymentData.paymentIntentId + - ' Creating new donation with status: ' + - newDonationStatus, - ) - - const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) - const targetVaultData = { connect: { id: vault.id } } - - try { - const donation = await tx.payment.create({ - data: { - amount: paymentData.netAmount, - chargedAmount: paymentData.chargedAmount, - currency: campaign.currency, - provider: paymentData.paymentProvider, - type: PaymentType.single, - status: newDonationStatus, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: paymentData.paymentMethodId ?? '', - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - donations: { - create: { - amount: paymentData.netAmount, - type: paymentData.type as DonationType, - person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, - targetVault: targetVaultData, - }, - }, - }, - select: donationNotificationSelect, - }) - - if (newDonationStatus === PaymentStatus.succeeded) { - await this.vaultService.incrementVaultAmount( - donation.donations[0].targetVaultId, - donation.amount, - tx, - ) - this.notificationService.sendNotification('successfulDonation', donation) - } - - return donation - } catch (error) { - Logger.error( - `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, - ) - throw new InternalServerErrorException(error) - } - } - - private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { - //first try to find by paymentIntentId - let donation = await tx.payment.findUnique({ - where: { extPaymentIntentId: paymentData.paymentIntentId }, - include: { donations: true }, - }) - - // if not found by paymentIntent, check for if this is payment on subscription - // check for UUID length of personId - // subscriptions always have a personId - if (!donation && paymentData.personId && paymentData.personId.length === 36) { - // search for a subscription donation - // for subscriptions, we don't have a paymentIntentId - donation = await tx.payment.findFirst({ - where: { - status: PaymentStatus.initial, - chargedAmount: paymentData.chargedAmount, - extPaymentMethodId: 'subscription', - donations: { - some: { - personId: paymentData.personId, - }, - }, - }, - include: { donations: true }, - }) - Logger.debug('Donation found by subscription: ', donation) - } - return donation - } - async createDonationWish(wish: string, donationId: string, campaignId: string) { const person = await this.prisma.donation.findUnique({ where: { id: donationId } }).person() await this.prisma.donationWish.upsert({ diff --git a/apps/api/src/config/validation.config.ts b/apps/api/src/config/validation.config.ts index 6819acc64..c3b38643e 100644 --- a/apps/api/src/config/validation.config.ts +++ b/apps/api/src/config/validation.config.ts @@ -12,7 +12,7 @@ const globalValidationPipe = new ValidationPipe({ exposeDefaultValues: true, }, stopAtFirstError: false, - forbidUnknownValues: true, + forbidUnknownValues: false, disableErrorMessages: false, exceptionFactory: (errors) => new BadRequestException(errors), validationError: { target: false, value: false }, diff --git a/apps/api/src/donations/donations.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts index 43d06895d..44231284d 100644 --- a/apps/api/src/donations/donations.controller.spec.ts +++ b/apps/api/src/donations/donations.controller.spec.ts @@ -21,7 +21,7 @@ import { NotificationModule } from '../sockets/notifications/notification.module import { VaultService } from '../vault/vault.service' import { DonationsController } from './donations.controller' import { DonationsService } from './donations.service' -import { CreateSessionDto } from './dto/create-session.dto' + import { UpdatePaymentDto } from './dto/update-payment.dto' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { MarketingNotificationsModule } from '../notifications/notifications.module' @@ -32,26 +32,6 @@ describe('DonationsController', () => { let controller: DonationsController let vaultService: VaultService - const stripeMock = { - checkout: { sessions: { create: jest.fn() } }, - paymentIntents: { retrieve: jest.fn() }, - refunds: { create: jest.fn() }, - } - stripeMock.checkout.sessions.create.mockResolvedValue({ payment_intent: 'unique-intent' }) - stripeMock.paymentIntents.retrieve.mockResolvedValue({ - payment_intent: 'unique-intent', - metadata: { campaignId: 'unique-campaign' }, - }) - - const mockSession = { - mode: 'payment', - amount: 100, - campaignId: 'testCampaignId', - successUrl: 'http://test.com', - cancelUrl: 'http://test.com', - isAnonymous: true, - } as CreateSessionDto - const mockDonation = { id: '1234', paymentId: '123', @@ -101,10 +81,6 @@ describe('DonationsController', () => { DonationsService, VaultService, MockPrismaService, - { - provide: STRIPE_CLIENT_TOKEN, - useValue: stripeMock, - }, PersonService, ExportService, { provide: CACHE_MANAGER, useValue: {} }, @@ -123,71 +99,6 @@ describe('DonationsController', () => { expect(controller).toBeDefined() }) - it('createCheckoutSession should create stripe session for active campaign', async () => { - prismaMock.campaign.findFirst.mockResolvedValue({ - allowDonationOnComplete: false, - state: CampaignState.active, - } as Campaign) - - await expect(controller.createCheckoutSession(mockSession)).resolves.toBeObject() - expect(prismaMock.campaign.findFirst).toHaveBeenCalled() - expect(stripeMock.checkout.sessions.create).toHaveBeenCalledWith({ - mode: mockSession.mode, - line_items: [ - { - price_data: { - currency: undefined, - product_data: { - name: undefined, - }, - unit_amount: 100, - }, - quantity: 1, - }, - ], - payment_method_types: ['card'], - payment_intent_data: { - metadata: { - campaignId: mockSession.campaignId, - isAnonymous: 'true', - personId: undefined, - wish: null, - }, - }, - subscription_data: undefined, - success_url: mockSession.successUrl, - cancel_url: mockSession.cancelUrl, - customer_email: undefined, - tax_id_collection: { - enabled: true, - }, - }) - }) - - it('createCheckoutSession should not create stripe session for completed campaign', async () => { - prismaMock.campaign.findFirst.mockResolvedValue({ - allowDonationOnComplete: false, - state: CampaignState.complete, - } as Campaign) - - await expect(controller.createCheckoutSession(mockSession)).rejects.toThrow( - new NotAcceptableException('Campaign cannot accept donations in state: complete'), - ) - expect(prismaMock.campaign.findFirst).toHaveBeenCalled() - expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled() - }) - - it('createCheckoutSession should create stripe session for completed campaign if allowed', async () => { - prismaMock.campaign.findFirst.mockResolvedValue({ - allowDonationOnComplete: true, - state: CampaignState.complete, - } as Campaign) - - await expect(controller.createCheckoutSession(mockSession)).resolves.toBeObject() - expect(prismaMock.campaign.findFirst).toHaveBeenCalled() - expect(stripeMock.checkout.sessions.create).toHaveBeenCalled() - }) - it('should update a donations donor, when it is changed', async () => { const updatePaymentDto = { amount: 10, @@ -279,16 +190,6 @@ describe('DonationsController', () => { }) }) - it('should request refund for donation', async () => { - await controller.refundStripePaymet('unique-intent') - - expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('unique-intent') - expect(stripeMock.refunds.create).toHaveBeenCalledWith({ - payment_intent: 'unique-intent', - reason: 'requested_by_customer', - }) - }) - it('should invalidate a donation and update the vault if needed', async () => { const existingPayment = { ...mockPayment, status: PaymentStatus.succeeded } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index f73ac80b2..5abb9ddbb 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -61,54 +61,6 @@ export class DonationsController { } } - @Post('create-checkout-session') - @Public() - async createCheckoutSession(@Body() sessionDto: CreateSessionDto) { - if ( - sessionDto.mode === 'subscription' && - (sessionDto.personId === null || sessionDto.personId.length === 0) - ) { - // in case of a intermediate (step 2) login, we might end up with no personId - // not able to fetch the current logged user here (due to @Public()) - sessionDto.personId = await this.donationsService.getUserId(sessionDto.personEmail) - } - - if ( - sessionDto.mode == 'subscription' && - (sessionDto.personId == null || sessionDto.personId.length == 0) - ) { - Logger.error( - `No personId found for email ${sessionDto.personEmail}. Unable to create a checkout session for a recurring donation`, - ) - throw new UnauthorizedException('You must be logged in to create a recurring donation') - } - - Logger.debug(`Creating checkout session with data ${JSON.stringify(sessionDto)}`) - - return this.donationsService.createCheckoutSession(sessionDto) - } - - @Get('prices') - @UseInterceptors(CacheInterceptor) - @Public() - findPrices() { - return this.donationsService.listPrices() - } - - @Get('prices/single') - @UseInterceptors(CacheInterceptor) - @Public() - findSinglePrices() { - return this.donationsService.listPrices('one_time') - } - - @Get('prices/recurring') - @UseInterceptors(CacheInterceptor) - @Public() - findRecurringPrices() { - return this.donationsService.listPrices('recurring') - } - @Get('user-donations') async userDonations(@AuthenticatedUser() user: KeycloakTokenParsed) { return await this.donationsService.getDonationsByUser(user.sub, user.email) @@ -204,59 +156,18 @@ export class DonationsController { return this.donationsService.getPaymentById(paymentId) } + @Get('payment-intent') + @Public() + async findDonationByPaymentIntent(@Query('id') paymentIntentId: string) { + return await this.donationsService.getDonationByPaymentIntent(paymentIntentId) + } + @Get('user/:id') async userDonationById(@Param('id') id: string, @AuthenticatedUser() user: KeycloakTokenParsed) { const donation = await this.donationsService.getUserDonationById(id, user.sub, user.email) return donation } - @Post('payment-intent') - @Public() - createPaymentIntent( - @Body() - createPaymentIntentDto: CreatePaymentIntentDto, - ) { - return this.donationsService.createPaymentIntent(createPaymentIntentDto) - } - - @Post('payment-intent/:id') - @Public() - updatePaymentIntent( - @Param('id') id: string, - @Body() - updatePaymentIntentDto: UpdatePaymentIntentDto, - ) { - return this.donationsService.updatePaymentIntent(id, updatePaymentIntentDto) - } - - @Post('payment-intent/:id/cancel') - @Public() - cancelPaymentIntent( - @Param('id') id: string, - @Body() - cancelPaymentIntentDto: CancelPaymentIntentDto, - ) { - return this.donationsService.cancelPaymentIntent(id, cancelPaymentIntentDto) - } - - @Post('create-stripe-payment') - @Public() - createStripePayment( - @Body() - stripePaymentDto: CreateStripePaymentDto, - ) { - return this.donationsService.createStripePayment(stripePaymentDto) - } - - @Post('/refund-stripe-payment/:id') - @Roles({ - roles: [EditFinancialsRequests.role], - mode: RoleMatchingMode.ANY, - }) - refundStripePaymet(@Param('id') paymentIntentId: string) { - return this.donationsService.refundStripePayment(paymentIntentId) - } - @Post('create-bank-payment') @Roles({ roles: [RealmViewSupporters.role, ViewSupporters.role], diff --git a/apps/api/src/donations/donations.module.ts b/apps/api/src/donations/donations.module.ts index 7ae327401..a2c26b843 100644 --- a/apps/api/src/donations/donations.module.ts +++ b/apps/api/src/donations/donations.module.ts @@ -1,6 +1,6 @@ import { StripeModule } from '@golevelup/nestjs-stripe' import { Module } from '@nestjs/common' -import { ConfigService } from '@nestjs/config' +import { ConfigModule, ConfigService } from '@nestjs/config' import { StripeConfigFactory } from './helpers/stripe-config-factory' import { CampaignModule } from '../campaign/campaign.module' import { CampaignService } from '../campaign/campaign.service' @@ -13,7 +13,7 @@ import { VaultModule } from '../vault/vault.module' import { VaultService } from '../vault/vault.service' import { DonationsController } from './donations.controller' import { DonationsService } from './donations.service' -import { StripePaymentService } from './events/stripe-payment.service' +import { StripePaymentService } from '../stripe/events/stripe-payment.service' import { HttpModule } from '@nestjs/axios' import { ExportModule } from './../export/export.module' import { NotificationModule } from '../sockets/notifications/notification.module' @@ -23,10 +23,6 @@ import { PrismaModule } from '../prisma/prisma.module' @Module({ imports: [ - StripeModule.forRootAsync(StripeModule, { - inject: [ConfigService], - useFactory: StripeConfigFactory.useFactory, - }), VaultModule, CampaignModule, PersonModule, @@ -34,13 +30,12 @@ import { PrismaModule } from '../prisma/prisma.module' ExportModule, NotificationModule, MarketingNotificationsModule, + ConfigModule, PrismaModule, ], controllers: [DonationsController], providers: [ DonationsService, - StripePaymentService, - CampaignService, RecurringDonationService, VaultService, PersonService, diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 6acd6590f..232006749 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -1,7 +1,13 @@ import Stripe from 'stripe' import { ConfigService } from '@nestjs/config' -import { InjectStripeClient } from '@golevelup/nestjs-stripe' -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' + +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common' import { Campaign, PaymentStatus, @@ -19,7 +25,7 @@ import { CampaignService } from '../campaign/campaign.service' import { PrismaService } from '../prisma/prisma.service' import { VaultService } from '../vault/vault.service' import { ExportService } from '../export/export.service' -import { DonationMetadata } from './dontation-metadata.interface' + import { CreateBankPaymentDto } from './dto/create-bank-payment.dto' import { CreateSessionDto } from './dto/create-session.dto' import { UpdatePaymentDto } from './dto/update-payment.dto' @@ -33,40 +39,22 @@ import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-do import { VaultUpdate } from '../vault/types/vault' import { PaymentWithDonation } from './types/donation' import type { DonationWithPersonAndVault, PaymentWithDonationCount } from './types/donation' +import { + NotificationService, + donationNotificationSelect, +} from '../sockets/notifications/notification.service' +import { PaymentData } from './helpers/payment-intent-helpers' +import { shouldAllowStatusChange } from './helpers/donation-status-updates' @Injectable() export class DonationsService { constructor( - @InjectStripeClient() private stripeClient: Stripe, - private config: ConfigService, private campaignService: CampaignService, private prisma: PrismaService, private vaultService: VaultService, private exportService: ExportService, + private notificationService: NotificationService, ) {} - async listPrices(type?: Stripe.PriceListParams.Type, active?: boolean): Promise { - const listResponse = await this.stripeClient.prices.list({ active, type, limit: 100 }).then( - function (list) { - Logger.debug('[Stripe] Prices received: ' + list.data.length) - return { list } - }, - function (error) { - if (error instanceof Stripe.errors.StripeError) - Logger.error( - '[Stripe] Error while getting price list. Error type: ' + - error.type + - ' message: ' + - error.message + - ' full error: ' + - JSON.stringify(error), - ) - }, - ) - - if (listResponse) { - return listResponse.list.data.filter((price) => price.active) - } else return new Array() - } /** * Create initial donation object for tracking purposes @@ -166,107 +154,6 @@ export class DonationsService { return donation } - async createCheckoutSession( - sessionDto: CreateSessionDto, - ): Promise { - const campaign = await this.campaignService.validateCampaignId(sessionDto.campaignId) - const { mode } = sessionDto - const appUrl = this.config.get('APP_URL') - - const metadata: DonationMetadata = { - campaignId: sessionDto.campaignId, - personId: sessionDto.personId, - isAnonymous: sessionDto.isAnonymous ? 'true' : 'false', - wish: sessionDto.message ?? null, - type: sessionDto.type, - } - - const items = await this.prepareSessionItems(sessionDto, campaign) - const createSessionRequest: Stripe.Checkout.SessionCreateParams = { - mode, - customer_email: sessionDto.personEmail, - line_items: items, - payment_method_types: ['card'], - payment_intent_data: mode == 'payment' ? { metadata } : undefined, - subscription_data: mode == 'subscription' ? { metadata } : undefined, - success_url: sessionDto.successUrl ?? `${appUrl}/success`, - cancel_url: sessionDto.cancelUrl ?? `${appUrl}/canceled`, - tax_id_collection: { enabled: true }, - } - - const sessionResponse = await this.stripeClient.checkout.sessions - .create(createSessionRequest) - .then( - function (session) { - Logger.debug('[Stripe] Checkout session created.') - return { session } - }, - function (error) { - if (error instanceof Stripe.errors.StripeError) - Logger.error( - '[Stripe] Error while creating checkout session. Error type: ' + - error.type + - ' message: ' + - error.message + - ' full error: ' + - JSON.stringify(error), - ) - }, - ) - - if (sessionResponse) { - this.createInitialDonationFromSession( - campaign, - sessionDto, - (sessionResponse.session.payment_intent as string) ?? sessionResponse.session.id, - ) - } - - return sessionResponse - } - - private async prepareSessionItems( - sessionDto: CreateSessionDto, - campaign: Campaign, - ): Promise { - if (sessionDto.mode == 'subscription') { - // the membership campaign is internal only - // we need to make the subscriptions once a year - const isMembership = await this.campaignService.isMembershipCampaign(campaign.campaignTypeId) - const interval = isMembership ? 'year' : 'month' - - //use an inline price for subscriptions - const stripeItem = { - price_data: { - currency: campaign.currency, - unit_amount: sessionDto.amount, - recurring: { - interval: interval as Stripe.Price.Recurring.Interval, - interval_count: 1, - }, - product_data: { - name: campaign.title, - }, - }, - quantity: 1, - } - return [stripeItem] - } - // Create donation with custom amount - return [ - { - price_data: { - currency: campaign.currency, - unit_amount: sessionDto.amount, - product_data: { - name: campaign.title, - }, - }, - quantity: 1, - }, - ] - } - /** * Lists all donations without confidential fields * @param campaignId (Optional) Filter by campaign id @@ -501,6 +388,12 @@ export class DonationsService { } } + async getDonationByPaymentIntent(id: string): Promise<{ id: string } | null> { + return await this.prisma.donation.findFirst({ + where: { payment: { extPaymentIntentId: id } }, + select: { id: true }, + }) + } async getAffiliateDonationById(donationId: string, affiliateCode: string) { try { const donation = await this.prisma.payment.findFirstOrThrow({ @@ -548,81 +441,6 @@ export class DonationsService { }) } - /** - * Create a payment intent for a donation - * @param inputDto Payment intent create params - * @returns {Promise>} - */ - async createPaymentIntent( - inputDto: Stripe.PaymentIntentCreateParams, - ): Promise> { - return await this.stripeClient.paymentIntents.create(inputDto) - } - - /** - * Create a payment intent for a donation - * https://stripe.com/docs/api/payment_intents/create - * @param inputDto Payment intent create params - * @returns {Promise>} - */ - async createStripePayment(inputDto: CreateStripePaymentDto): Promise { - const intent = await this.stripeClient.paymentIntents.retrieve(inputDto.paymentIntentId) - if (!intent.metadata.campaignId) { - throw new BadRequestException('Campaign id is missing from payment intent metadata') - } - const campaignId = intent.metadata.camapaignId - const campaign = await this.campaignService.validateCampaignId(campaignId) - return this.createInitialDonationFromIntent(campaign, inputDto, intent) - } - - /** - * Refund a stipe payment donation - * https://stripe.com/docs/api/refunds/create - * @param inputDto Refund-stripe params - * @returns {Promise>} - */ - async refundStripePayment(paymentIntentId: string): Promise> { - const intent = await this.stripeClient.paymentIntents.retrieve(paymentIntentId) - if (!intent) { - throw new BadRequestException('Payment Intent is missing from stripe') - } - - if (!intent.metadata.campaignId) { - throw new BadRequestException('Campaign id is missing from payment intent metadata') - } - - return await this.stripeClient.refunds.create({ - payment_intent: paymentIntentId, - reason: 'requested_by_customer', - }) - } - - /** - * Update a payment intent for a donation - * https://stripe.com/docs/api/payment_intents/update - * @param inputDto Payment intent create params - * @returns {Promise>} - */ - async updatePaymentIntent( - id: string, - inputDto: Stripe.PaymentIntentUpdateParams, - ): Promise> { - return this.stripeClient.paymentIntents.update(id, inputDto) - } - - /** - * Cancel a payment intent for a donation - * https://stripe.com/docs/api/payment_intents/cancel - * @param inputDto Payment intent create params - * @returns {Promise>} - */ - async cancelPaymentIntent( - id: string, - inputDto: Stripe.PaymentIntentCancelParams, - ): Promise> { - return this.stripeClient.paymentIntents.cancel(id, inputDto) - } - async createUpdateBankPayment(donationDto: CreateBankPaymentDto): Promise { return await this.prisma.$transaction(async (tx) => { //to avoid incrementing vault amount twice we first check if there is such donation @@ -932,4 +750,224 @@ export class DonationsService { }) }) } + + /** + * Creates or Updates an incoming donation depending on the newDonationStatus attribute + * @param campaign + * @param paymentData + * @param newDonationStatus + * @param metadata + * @returns donation.id of the created/updated donation + */ + async updateDonationPayment( + campaign: Campaign, + paymentData: PaymentData, + newDonationStatus: PaymentStatus, + ): Promise { + const campaignId = campaign.id + Logger.debug('Update donation to status: ' + newDonationStatus, { + campaignId, + paymentIntentId: paymentData.paymentIntentId, + }) + + //Update existing donation or create new in a transaction that + //also increments the vault amount and marks campaign as completed + //if target amount is reached + return await this.prisma.$transaction(async (tx) => { + let donationId + // Find donation by extPaymentIntentId + const existingDonation = await this.findExistingDonation(tx, paymentData) + + //if missing create the donation with the incoming status + if (!existingDonation) { + const newDonation = await this.createIncomingDonation( + tx, + paymentData, + newDonationStatus, + campaign, + ) + donationId = newDonation.id + } + //donation exists, so check if it is safe to update it + else { + const updatedDonation = await this.updateDonationIfAllowed( + tx, + existingDonation, + newDonationStatus, + paymentData, + ) + donationId = updatedDonation?.id + } + + return donationId + }) //end of the transaction scope + } + + private async updateDonationIfAllowed( + tx: Prisma.TransactionClient, + payment: PaymentWithDonation, + newDonationStatus: PaymentStatus, + paymentData: PaymentData, + ) { + if (shouldAllowStatusChange(payment.status, newDonationStatus)) { + try { + const updatedDonation = await tx.payment.update({ + where: { + id: payment.id, + }, + data: { + status: newDonationStatus, + amount: paymentData.netAmount, + extCustomerId: paymentData.stripeCustomerId, + extPaymentMethodId: paymentData.paymentMethodId, + extPaymentIntentId: paymentData.paymentIntentId, + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + donations: { + updateMany: { + where: { paymentId: payment.id }, + data: { + amount: paymentData.netAmount, + }, + }, + }, + }, + select: donationNotificationSelect, + }) + + //if donation is switching to successful, increment the vault amount and send notification + if ( + payment.status != PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.succeeded + ) { + await this.vaultService.incrementVaultAmount( + payment.donations[0].targetVaultId, + paymentData.netAmount, + tx, + ) + this.notificationService.sendNotification('successfulDonation', { + ...updatedDonation, + person: updatedDonation.donations[0].person, + }) + } else if ( + payment.status === PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.refund + ) { + await this.vaultService.decrementVaultAmount( + payment.donations[0].targetVaultId, + paymentData.netAmount, + tx, + ) + this.notificationService.sendNotification('successfulRefund', { + ...updatedDonation, + person: updatedDonation.donations[0].person, + }) + } + return updatedDonation + } catch (error) { + Logger.error( + `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, + ) + throw new InternalServerErrorException(error) + } + } + //donation exists but we need to skip because previous status is from later event than the incoming + else { + Logger.warn( + `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} + and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, + ) + } + } + + private async createIncomingDonation( + tx: Prisma.TransactionClient, + paymentData: PaymentData, + newDonationStatus: PaymentStatus, + campaign: Campaign, + ) { + Logger.debug( + 'No donation exists with extPaymentIntentId: ' + + paymentData.paymentIntentId + + ' Creating new donation with status: ' + + newDonationStatus, + ) + + const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) + const targetVaultData = { connect: { id: vault.id } } + + try { + const donation = await tx.payment.create({ + data: { + amount: paymentData.netAmount, + chargedAmount: paymentData.chargedAmount, + currency: campaign.currency, + provider: paymentData.paymentProvider, + type: PaymentType.single, + status: newDonationStatus, + extCustomerId: paymentData.stripeCustomerId ?? '', + extPaymentIntentId: paymentData.paymentIntentId, + extPaymentMethodId: paymentData.paymentMethodId ?? '', + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + donations: { + create: { + amount: paymentData.netAmount, + type: paymentData.type as DonationType, + person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, + targetVault: targetVaultData, + }, + }, + }, + select: donationNotificationSelect, + }) + + if (newDonationStatus === PaymentStatus.succeeded) { + await this.vaultService.incrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) + this.notificationService.sendNotification('successfulDonation', donation) + } + + return donation + } catch (error) { + Logger.error( + `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, + ) + throw new InternalServerErrorException(error) + } + } + + private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { + //first try to find by paymentIntentId + let donation = await tx.payment.findUnique({ + where: { extPaymentIntentId: paymentData.paymentIntentId }, + include: { donations: true }, + }) + + // if not found by paymentIntent, check for if this is payment on subscription + // check for UUID length of personId + // subscriptions always have a personId + if (!donation && paymentData.personId && paymentData.personId.length === 36) { + // search for a subscription donation + // for subscriptions, we don't have a paymentIntentId + donation = await tx.payment.findFirst({ + where: { + status: PaymentStatus.initial, + chargedAmount: paymentData.chargedAmount, + extPaymentMethodId: 'subscription', + donations: { + some: { + personId: paymentData.personId, + }, + }, + }, + include: { donations: true }, + }) + Logger.debug('Donation found by subscription: ', donation) + } + return donation + } } diff --git a/apps/api/src/donations/helpers/payment-intent-helpers.ts b/apps/api/src/donations/helpers/payment-intent-helpers.ts index e29d808c2..19ed35386 100644 --- a/apps/api/src/donations/helpers/payment-intent-helpers.ts +++ b/apps/api/src/donations/helpers/payment-intent-helpers.ts @@ -63,6 +63,7 @@ export function getPaymentData( export function getPaymentDataFromCharge(charge: Stripe.Charge): PaymentData { const isAnonymous = charge.metadata.isAnonymous === 'true' + return { paymentProvider: PaymentProvider.stripe, paymentIntentId: charge.payment_intent as string, @@ -85,9 +86,8 @@ export function getPaymentDataFromCharge(charge: Stripe.Charge): PaymentData { } } -export function getInvoiceData(invoice: Stripe.Invoice): PaymentData { +export function getInvoiceData(invoice: Stripe.Invoice, charge: Stripe.Charge): PaymentData { const lines: Stripe.InvoiceLineItem[] = invoice.lines.data as Stripe.InvoiceLineItem[] - const country = invoice.account_country as string let personId = '' let type = '' @@ -108,8 +108,11 @@ export function getInvoiceData(invoice: Stripe.Invoice): PaymentData { lines.length === 0 ? 0 : Math.round( - invoice.amount_paid - - stripeFeeCalculator(invoice.amount_paid, getCountryRegion(country)), + charge.amount - + stripeFeeCalculator( + charge.amount, + getCountryRegion(charge?.payment_method_details?.card?.country as string), + ), ), chargedAmount: invoice.amount_paid, currency: invoice.currency.toUpperCase(), diff --git a/apps/api/src/paypal/paypal.controller.spec.ts b/apps/api/src/paypal/paypal.controller.spec.ts index cf46e3f4e..f2740af5a 100644 --- a/apps/api/src/paypal/paypal.controller.spec.ts +++ b/apps/api/src/paypal/paypal.controller.spec.ts @@ -8,6 +8,8 @@ import { HttpModule } from '@nestjs/axios' import { NotificationModule } from '../sockets/notifications/notification.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { DonationsModule } from '../donations/donations.module' +import { CACHE_MANAGER, CacheModule } from '@nestjs/cache-manager' describe('PaypalController', () => { let controller: PaypalController @@ -15,15 +17,17 @@ describe('PaypalController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ + CacheModule.register({ isGlobal: true }), PaypalModule, CampaignModule, MarketingNotificationsModule, ConfigModule, HttpModule, + DonationsModule, NotificationModule, ], controllers: [PaypalController], - providers: [PaypalService], + providers: [PaypalService, { provide: CACHE_MANAGER, useValue: {} }], }).compile() controller = module.get(PaypalController) diff --git a/apps/api/src/paypal/paypal.module.ts b/apps/api/src/paypal/paypal.module.ts index a48c04ad2..59e07e5bb 100644 --- a/apps/api/src/paypal/paypal.module.ts +++ b/apps/api/src/paypal/paypal.module.ts @@ -4,9 +4,10 @@ import { PaypalService } from './paypal.service' import { HttpModule } from '@nestjs/axios' import { CampaignModule } from '../campaign/campaign.module' import { ConfigService } from '@nestjs/config' +import { DonationsModule } from '../donations/donations.module' @Module({ - imports: [HttpModule, CampaignModule], + imports: [HttpModule, CampaignModule, DonationsModule], controllers: [PaypalController], providers: [PaypalService, ConfigService], exports: [PaypalService], diff --git a/apps/api/src/paypal/paypal.service.spec.ts b/apps/api/src/paypal/paypal.service.spec.ts index 8b8bd87b2..8123ec352 100644 --- a/apps/api/src/paypal/paypal.service.spec.ts +++ b/apps/api/src/paypal/paypal.service.spec.ts @@ -6,6 +6,8 @@ import { NotificationModule } from '../sockets/notifications/notification.module import { PaypalModule } from './paypal.module' import { PaypalService } from './paypal.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { DonationsModule } from '../donations/donations.module' +import { CACHE_MANAGER, CacheModule } from '@nestjs/cache-manager' describe('PaypalService', () => { let service: PaypalService @@ -13,14 +15,16 @@ describe('PaypalService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ + CacheModule.register({ isGlobal: true }), PaypalModule, - CampaignModule, ConfigModule, HttpModule, + DonationsModule, + CampaignModule, NotificationModule, MarketingNotificationsModule, ], - providers: [PaypalService], + providers: [PaypalService, { provide: CACHE_MANAGER, useValue: {} }], }).compile() service = module.get(PaypalService) diff --git a/apps/api/src/paypal/paypal.service.ts b/apps/api/src/paypal/paypal.service.ts index dc5c303de..e20452250 100644 --- a/apps/api/src/paypal/paypal.service.ts +++ b/apps/api/src/paypal/paypal.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config' import { CampaignService } from '../campaign/campaign.service' import { HttpService } from '@nestjs/axios' import { PaymentStatus, DonationType, PaymentProvider, PaymentType } from '@prisma/client' +import { DonationsService } from '../donations/donations.service' @Injectable() export class PaypalService { @@ -10,6 +11,7 @@ export class PaypalService { private campaignService: CampaignService, private config: ConfigService, private httpService: HttpService, + private donationService: DonationsService, ) {} /* @@ -26,7 +28,7 @@ export class PaypalService { // get campaign by id const campaign = await this.campaignService.getCampaignById(billingDetails.campaignId) - await this.campaignService.updateDonationPayment( + await this.donationService.updateDonationPayment( campaign, billingDetails, PaymentStatus.waiting, @@ -49,7 +51,7 @@ export class PaypalService { // get campaign by id const campaign = await this.campaignService.getCampaignById(billingDetails.campaignId) - await this.campaignService.updateDonationPayment( + await this.donationService.updateDonationPayment( campaign, billingDetails, PaymentStatus.succeeded, diff --git a/apps/api/src/recurring-donation/recurring-donation.controller.ts b/apps/api/src/recurring-donation/recurring-donation.controller.ts index 06bdb339f..dce283ef6 100644 --- a/apps/api/src/recurring-donation/recurring-donation.controller.ts +++ b/apps/api/src/recurring-donation/recurring-donation.controller.ts @@ -54,26 +54,6 @@ export class RecurringDonationController { return this.recurringDonationService.update(id, updateRecurringDonationDto) } - @Get('cancel/:id') - async cancel(@Param('id') id: string, @AuthenticatedUser() user: KeycloakTokenParsed) { - Logger.log(`Cancelling recurring donation with id ${id}`) - const rd = await this.recurringDonationService.findOne(id) - if (!rd) { - throw new Error(`Recurring donation with id ${id} not found`) - } - - const isAdmin = user.realm_access?.roles.includes(RealmViewSupporters.role) - - if (!isAdmin && !this.recurringDonationService.donationBelongsTo(rd.id, user.sub)) { - throw new Error( - `User ${user.sub} is not allowed to cancel recurring donation with id ${id} of person: ${rd.personId}`, - ) - } - - Logger.log(`Cancelling recurring donation to stripe with id ${id}`) - return this.recurringDonationService.cancelSubscription(rd.extSubscriptionId) - } - @Delete(':id') @Roles({ roles: [RealmViewSupporters.role], diff --git a/apps/api/src/recurring-donation/recurring-donation.module.ts b/apps/api/src/recurring-donation/recurring-donation.module.ts index 3a7847f32..cd56abbd5 100644 --- a/apps/api/src/recurring-donation/recurring-donation.module.ts +++ b/apps/api/src/recurring-donation/recurring-donation.module.ts @@ -19,5 +19,6 @@ import { PrismaModule } from '../prisma/prisma.module' controllers: [RecurringDonationController], providers: [RecurringDonationService], + exports: [RecurringDonationService], }) export class RecurringDonationModule {} diff --git a/apps/api/src/recurring-donation/recurring-donation.service.spec.ts b/apps/api/src/recurring-donation/recurring-donation.service.spec.ts index 6fc68025d..0ea8c6c0e 100644 --- a/apps/api/src/recurring-donation/recurring-donation.service.spec.ts +++ b/apps/api/src/recurring-donation/recurring-donation.service.spec.ts @@ -86,16 +86,6 @@ describe('RecurringDonationService', () => { expect(result).toStrictEqual(mockRecurring) }) - it('should call stripe cancel service my subscription id', async () => { - const cancelSubscriptionSpy = jest - .spyOn(stripeMock.subscriptions, 'cancel') - .mockImplementation(() => { - return Promise.resolve({ status: 'canceled' }) - }) - await service.cancelSubscription('sub1') - expect(cancelSubscriptionSpy).toHaveBeenCalledWith('sub1') - }) - it('should cancel a subscription in db', async () => { prismaMock.recurringDonation.update.mockResolvedValueOnce(mockRecurring) await service.cancel('1') diff --git a/apps/api/src/recurring-donation/recurring-donation.service.ts b/apps/api/src/recurring-donation/recurring-donation.service.ts index 285605844..16cb9728b 100644 --- a/apps/api/src/recurring-donation/recurring-donation.service.ts +++ b/apps/api/src/recurring-donation/recurring-donation.service.ts @@ -5,19 +5,9 @@ import { CreateRecurringDonationDto } from './dto/create-recurring-donation.dto' import { UpdateRecurringDonationDto } from './dto/update-recurring-donation.dto' import { RecurringDonationStatus } from '@prisma/client' -import { HttpService } from '@nestjs/axios' -import { ConfigService } from '@nestjs/config' -import { InjectStripeClient } from '@golevelup/nestjs-stripe' -import Stripe from 'stripe' - @Injectable() export class RecurringDonationService { - constructor( - private prisma: PrismaService, - private config: ConfigService, - private httpService: HttpService, - @InjectStripeClient() private stripeClient: Stripe, - ) {} + constructor(private prisma: PrismaService) {} async create(CreateRecurringDonationDto: CreateRecurringDonationDto): Promise { return await this.prisma.recurringDonation.create({ @@ -173,20 +163,4 @@ export class RecurringDonationService { } return result } - - async cancelSubscription(subscriptionId: string) { - Logger.log(`Canceling subscription with api request to cancel: ${subscriptionId}`) - const result = await this.stripeClient.subscriptions.cancel(subscriptionId) - if (result.status !== 'canceled') { - Logger.log(`Subscription cancel attempt failed with status of ${result.id}: ${result.status}`) - return - } - - // the webhook will handle this as well. - // but we cancel it here, in case the webhook is slow. - const rd = await this.findSubscriptionByExtId(result.id) - if (rd) { - return this.cancel(rd.id) - } - } } diff --git a/apps/api/src/stripe/dto/cancel-payment-intent.dto.ts b/apps/api/src/stripe/dto/cancel-payment-intent.dto.ts new file mode 100644 index 000000000..16812bdc8 --- /dev/null +++ b/apps/api/src/stripe/dto/cancel-payment-intent.dto.ts @@ -0,0 +1,11 @@ +import Stripe from 'stripe' +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsOptional } from 'class-validator' + +export class CancelPaymentIntentDto implements Stripe.PaymentIntentCancelParams { + @ApiProperty() + @Expose() + @IsOptional() + cancellation_reason: Stripe.PaymentIntentCancelParams.CancellationReason +} diff --git a/apps/api/src/stripe/dto/create-payment-intent.dto.ts b/apps/api/src/stripe/dto/create-payment-intent.dto.ts new file mode 100644 index 000000000..88beb8863 --- /dev/null +++ b/apps/api/src/stripe/dto/create-payment-intent.dto.ts @@ -0,0 +1,21 @@ +import Stripe from 'stripe' +import { ApiProperty } from '@nestjs/swagger' +import { Currency } from '@prisma/client' +import { Expose } from 'class-transformer' +import { IsEnum, IsNumber } from 'class-validator' + +export class CreatePaymentIntentDto implements Stripe.PaymentIntentCreateParams { + @ApiProperty() + @Expose() + @IsNumber() + amount: number + + @ApiProperty() + @Expose() + @IsEnum(Currency) + currency: Currency + + @ApiProperty() + @Expose() + metadata: Stripe.MetadataParam +} diff --git a/apps/api/src/stripe/dto/create-setup-intent.dto.ts b/apps/api/src/stripe/dto/create-setup-intent.dto.ts new file mode 100644 index 000000000..b9fcd8696 --- /dev/null +++ b/apps/api/src/stripe/dto/create-setup-intent.dto.ts @@ -0,0 +1,9 @@ +import Stripe from 'stripe' +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' + +export class CreateSetupIntentDto implements Stripe.SetupIntentCreateParams { + @ApiProperty() + @Expose() + metadata: Stripe.MetadataParam +} diff --git a/apps/api/src/stripe/dto/create-subscription-payment.dto.ts b/apps/api/src/stripe/dto/create-subscription-payment.dto.ts new file mode 100644 index 000000000..35ae16304 --- /dev/null +++ b/apps/api/src/stripe/dto/create-subscription-payment.dto.ts @@ -0,0 +1,33 @@ +import { Expose } from 'class-transformer' +import { ApiProperty } from '@nestjs/swagger' +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator' +import { Currency, DonationType } from '@prisma/client' + +export class CreateSubscriptionPaymentDto { + @Expose() + @ApiProperty() + @IsString() + @IsOptional() + campaignId: string + + @ApiProperty() + @Expose() + @IsEnum(DonationType) + type: DonationType + + @Expose() + @ApiProperty() + @IsNumber() + amount: number + + @ApiProperty() + @Expose() + @IsEnum(Currency) + currency: Currency + + @Expose() + @ApiProperty() + @IsString() + @IsOptional() + email: string +} diff --git a/apps/api/src/stripe/dto/update-payment-intent.dto.ts b/apps/api/src/stripe/dto/update-payment-intent.dto.ts new file mode 100644 index 000000000..6c345da04 --- /dev/null +++ b/apps/api/src/stripe/dto/update-payment-intent.dto.ts @@ -0,0 +1,20 @@ +import Stripe from 'stripe' +import { ApiProperty } from '@nestjs/swagger' +import { Currency } from '@prisma/client' +import { Expose } from 'class-transformer' +import { IsNumber } from 'class-validator' + +export class UpdatePaymentIntentDto implements Stripe.PaymentIntentUpdateParams { + @ApiProperty() + @Expose() + @IsNumber() + amount: number + + @ApiProperty() + @Expose() + currency: Currency + + @ApiProperty() + @Expose() + metadata: Stripe.MetadataParam +} diff --git a/apps/api/src/stripe/dto/update-setup-intent.dto.ts b/apps/api/src/stripe/dto/update-setup-intent.dto.ts new file mode 100644 index 000000000..6b7a5545a --- /dev/null +++ b/apps/api/src/stripe/dto/update-setup-intent.dto.ts @@ -0,0 +1,9 @@ +import Stripe from 'stripe' +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' + +export class UpdateSetupIntentDto implements Stripe.SetupIntentUpdateParams { + @ApiProperty() + @Expose() + metadata: Stripe.MetadataParam +} diff --git a/apps/api/src/donations/events/stripe-payment.service.spec.ts b/apps/api/src/stripe/events/stripe-payment.service.spec.ts similarity index 93% rename from apps/api/src/donations/events/stripe-payment.service.spec.ts rename to apps/api/src/stripe/events/stripe-payment.service.spec.ts index 499f82bc6..09d4b7df0 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/stripe/events/stripe-payment.service.spec.ts @@ -2,7 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing' import { ConfigService } from '@nestjs/config' import { CampaignService } from '../../campaign/campaign.service' import { StripePaymentService } from './stripe-payment.service' -import { getPaymentData, getPaymentDataFromCharge } from '../helpers/payment-intent-helpers' +import { + getPaymentData, + getPaymentDataFromCharge, +} from '../../donations/helpers/payment-intent-helpers' import Stripe from 'stripe' import { VaultService } from '../../vault/vault.service' import { PersonService } from '../../person/person.service' @@ -40,6 +43,7 @@ import { mockChargeEventSucceeded, mockPaymentEventFailed, mockChargeRefundEventSucceeded, + mockCharge, } from './stripe-payment.testdata' import { PaymentStatus } from '@prisma/client' import { RecurringDonationService } from '../../recurring-donation/recurring-donation.service' @@ -51,13 +55,19 @@ import { SendGridNotificationsProvider } from '../../notifications/providers/not import { MarketingNotificationsService } from '../../notifications/notifications.service' import { EmailService } from '../../email/email.service' import { TemplateService } from '../../email/template.service' -import type { PaymentWithDonation } from '../types/donation' +import type { PaymentWithDonation } from '../../donations/types/donation' +import { DonationsService } from '../../donations/donations.service' +import { StripeService } from '../stripe.service' +import { ExportService } from '../../export/export.service' const defaultStripeWebhookEndpoint = '/stripe/webhook' const stripeSecret = 'wh_123' describe('StripePaymentService', () => { let stripePaymentService: StripePaymentService + let campaignService: CampaignService + let donationService: DonationsService + let stripeService: StripeService let app: INestApplication const stripe = new Stripe(stripeSecret, { apiVersion: '2022-11-15' }) @@ -133,6 +143,9 @@ describe('StripePaymentService', () => { VaultService, PersonService, RecurringDonationService, + StripeService, + DonationsService, + ExportService, { provide: HttpService, useValue: mockDeep(), @@ -147,6 +160,9 @@ describe('StripePaymentService', () => { await app.init() stripePaymentService = app.get(StripePaymentService) + donationService = app.get(DonationsService) + campaignService = app.get(CampaignService) + stripeService = app.get(StripeService) //this intercepts the request raw body and removes the exact signature check const stripePayloadService = app.get(StripePayloadService) @@ -170,7 +186,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -178,7 +193,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventCreated.data.object as Stripe.PaymentIntent) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -211,7 +226,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -221,7 +235,7 @@ describe('StripePaymentService', () => { ) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -249,7 +263,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -257,7 +270,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventFailed.data.object as Stripe.PaymentIntent) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -288,7 +301,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -323,7 +335,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') @@ -398,7 +410,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') @@ -458,7 +470,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockDecremementVaultAmount = jest.spyOn(vaultService, 'decrementVaultAmount') @@ -568,10 +580,14 @@ describe('StripePaymentService', () => { .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) + const stripeChargeRetrieveMock = jest + .spyOn(stripeService, 'findChargeById') + .mockResolvedValue(mockCharge) + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') prismaMock.payment.findFirst.mockResolvedValue({ @@ -601,6 +617,7 @@ describe('StripePaymentService', () => { (mockCustomerSubscriptionCreated.data.object as Stripe.SubscriptionItem).metadata .campaignId, ) //campaignId from the Stripe Event + expect(stripeChargeRetrieveMock).toHaveBeenCalled() expect(mockedUpdateDonationPayment).toHaveBeenCalled() expect(mockedIncrementVaultAmount).toHaveBeenCalled() }) @@ -617,8 +634,12 @@ describe('StripePaymentService', () => { const campaignService = app.get(CampaignService) const recurring = app.get(RecurringDonationService) + const stripeChargeRetrieveMock = jest + .spyOn(stripeService, 'findChargeById') + .mockResolvedValue(mockCharge) + const mockCancelSubscription = jest - .spyOn(recurring, 'cancelSubscription') + .spyOn(stripeService, 'cancelSubscription') .mockImplementation(() => Promise.resolve(null)) const mockedCampaignById = jest @@ -626,7 +647,7 @@ describe('StripePaymentService', () => { .mockImplementation(() => Promise.resolve(mockedCampaignCompeleted)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -645,6 +666,7 @@ describe('StripePaymentService', () => { (mockCustomerSubscriptionCreated.data.object as Stripe.SubscriptionItem).metadata .campaignId, ) //campaignId from the Stripe Event + expect(stripeChargeRetrieveMock).toHaveBeenCalled() expect(mockedUpdateDonationPayment).toHaveBeenCalled() expect(mockCancelSubscription).toHaveBeenCalledWith( mockedRecurringDonation.extSubscriptionId, diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/stripe/events/stripe-payment.service.ts similarity index 87% rename from apps/api/src/donations/events/stripe-payment.service.ts rename to apps/api/src/stripe/events/stripe-payment.service.ts index db327edf5..7eb9bbdf8 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/stripe/events/stripe-payment.service.ts @@ -2,7 +2,7 @@ import Stripe from 'stripe' import { BadRequestException, Injectable, Logger } from '@nestjs/common' import { StripeWebhookHandler } from '@golevelup/nestjs-stripe' -import { DonationMetadata } from '../dontation-metadata.interface' +import { StripeMetadata } from '../stripe-metadata.interface' import { CampaignService } from '../../campaign/campaign.service' import { RecurringDonationService } from '../../recurring-donation/recurring-donation.service' import { CreateRecurringDonationDto } from '../../recurring-donation/dto/create-recurring-donation.dto' @@ -14,11 +14,13 @@ import { getInvoiceData, getPaymentDataFromCharge, PaymentData, -} from '../helpers/payment-intent-helpers' +} from '../../donations/helpers/payment-intent-helpers' import { PaymentStatus, CampaignState } from '@prisma/client' import { EmailService } from '../../email/email.service' import { RefundDonationEmailDto } from '../../email/template.interface' import { PrismaService } from '../../prisma/prisma.service' +import { StripeService } from '../stripe.service' +import { DonationsService } from '../../donations/donations.service' /** Testing Stripe on localhost is described here: * https://github.com/podkrepi-bg/api/blob/master/TESTING.md#testing-stripe @@ -29,7 +31,8 @@ export class StripePaymentService { private campaignService: CampaignService, private recurringDonationService: RecurringDonationService, private sendEmail: EmailService, - private prismaService: PrismaService, + private stripeService: StripeService, + private donationService: DonationsService, ) {} @StripeWebhookHandler('payment_intent.created') @@ -39,10 +42,10 @@ export class StripePaymentService { Logger.debug( '[ handlePaymentIntentCreated ]', paymentIntent, - paymentIntent.metadata as DonationMetadata, + paymentIntent.metadata as StripeMetadata, ) - const metadata: DonationMetadata = paymentIntent.metadata as DonationMetadata + const metadata: StripeMetadata = paymentIntent.metadata as StripeMetadata if (!metadata.campaignId) { Logger.debug('[ handlePaymentIntentCreated ] No campaignId in metadata ' + paymentIntent.id) @@ -63,7 +66,7 @@ export class StripePaymentService { /* * Handle the create event */ - await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) + await this.donationService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) } @StripeWebhookHandler('payment_intent.canceled') @@ -72,7 +75,7 @@ export class StripePaymentService { Logger.log( '[ handlePaymentIntentCancelled ]', paymentIntent, - paymentIntent.metadata as DonationMetadata, + paymentIntent.metadata as StripeMetadata, ) const billingData = getPaymentData(paymentIntent) @@ -86,7 +89,7 @@ export class StripePaymentService { Logger.log( '[ handlePaymentIntentFailed ]', paymentIntent, - paymentIntent.metadata as DonationMetadata, + paymentIntent.metadata as StripeMetadata, ) const billingData = getPaymentData(paymentIntent) @@ -99,7 +102,7 @@ export class StripePaymentService { billingData: PaymentData, PaymentStatus: PaymentStatus, ) { - const metadata: DonationMetadata = paymentIntent.metadata as DonationMetadata + const metadata: StripeMetadata = paymentIntent.metadata as StripeMetadata if (!metadata.campaignId) { throw new BadRequestException( 'Payment intent metadata does not contain target campaignId. Probably wrong session initiation. Payment intent is: ' + @@ -109,15 +112,15 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus) + await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus) } @StripeWebhookHandler('charge.succeeded') async handleChargeSucceeded(event: Stripe.Event) { const charge: Stripe.Charge = event.data.object as Stripe.Charge - Logger.log('[ handleChargeSucceeded ]', charge, charge.metadata as DonationMetadata) + Logger.log('[ handleChargeSucceeded ]', charge, charge.metadata as StripeMetadata) - const metadata: DonationMetadata = charge.metadata as DonationMetadata + const metadata: StripeMetadata = charge.metadata as StripeMetadata if (!metadata.campaignId) { Logger.debug('[ handleChargeSucceeded ] No campaignId in metadata ' + charge.id) @@ -136,7 +139,7 @@ export class StripePaymentService { const billingData = getPaymentDataFromCharge(charge) - const donationId = await this.campaignService.updateDonationPayment( + const donationId = await this.donationService.updateDonationPayment( campaign, billingData, PaymentStatus.succeeded, @@ -156,10 +159,10 @@ export class StripePaymentService { Logger.log( '[ handleRefundCreated ]', chargePaymentIntent, - chargePaymentIntent.metadata as DonationMetadata, + chargePaymentIntent.metadata as StripeMetadata, ) - const metadata: DonationMetadata = chargePaymentIntent.metadata as DonationMetadata + const metadata: StripeMetadata = chargePaymentIntent.metadata as StripeMetadata if (!metadata.campaignId) { Logger.debug('[ handleRefundCreated ] No campaignId in metadata ' + chargePaymentIntent.id) @@ -170,7 +173,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) + await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } @@ -208,7 +211,7 @@ export class StripePaymentService { Logger.log('[ handleSubscriptionCreated ]', subscription) - const metadata: DonationMetadata = subscription.metadata as DonationMetadata + const metadata: StripeMetadata = subscription.metadata as StripeMetadata if (!metadata.campaignId) { throw new BadRequestException( 'Subscription metadata does not contain target campaignId. Subscription id: ' + @@ -266,7 +269,7 @@ export class StripePaymentService { const subscription: Stripe.Subscription = event.data.object as Stripe.Subscription Logger.log('[ handleSubscriptionUpdated ]', subscription) - const metadata: DonationMetadata = subscription.metadata as DonationMetadata + const metadata: StripeMetadata = subscription.metadata as StripeMetadata if (!metadata.campaignId) { throw new BadRequestException( 'Subscription metadata does not contain target campaignId. Subscription id: ' + @@ -301,7 +304,7 @@ export class StripePaymentService { const subscription: Stripe.Subscription = event.data.object as Stripe.Subscription Logger.log('[ handleSubscriptionDeleted ]', subscription) - const metadata: DonationMetadata = subscription.metadata as DonationMetadata + const metadata: StripeMetadata = subscription.metadata as StripeMetadata if (!metadata.campaignId) { throw new BadRequestException( 'Subscription metadata does not contain target campaignId. Subscription is: ' + @@ -328,9 +331,9 @@ export class StripePaymentService { @StripeWebhookHandler('invoice.paid') async handleInvoicePaid(event: Stripe.Event) { const invoice: Stripe.Invoice = event.data.object as Stripe.Invoice - Logger.log('[ handleInvoicePaid ]', invoice) - let metadata: DonationMetadata = { + Logger.log('[ handleInvoicePaid ]', invoice) + let metadata: StripeMetadata = { type: null, campaignId: null, personId: null, @@ -340,7 +343,7 @@ export class StripePaymentService { invoice.lines.data.forEach((line: Stripe.InvoiceLineItem) => { if (line.type === 'subscription') { - metadata = line.metadata as DonationMetadata + metadata = line.metadata as StripeMetadata } }) @@ -361,9 +364,10 @@ export class StripePaymentService { ) } - const paymentData = getInvoiceData(invoice) + const charge = await this.stripeService.findChargeById(invoice.charge as string) + const paymentData = getInvoiceData(invoice, charge) - await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.succeeded) + await this.donationService.updateDonationPayment(campaign, paymentData, PaymentStatus.succeeded) //updateDonationPayment will mark the campaign as completed if amount is reached await this.cancelSubscriptionsIfCompletedCampaign(metadata.campaignId) @@ -376,7 +380,7 @@ export class StripePaymentService { const recurring = await this.recurringDonationService.findAllActiveRecurringDonationsByCampaignId(campaignId) for (const r of recurring) { - await this.recurringDonationService.cancelSubscription(r.extSubscriptionId) + await this.stripeService.cancelSubscription(r.extSubscriptionId) } } } diff --git a/apps/api/src/donations/events/stripe-payment.testdata.ts b/apps/api/src/stripe/events/stripe-payment.testdata.ts similarity index 94% rename from apps/api/src/donations/events/stripe-payment.testdata.ts rename to apps/api/src/stripe/events/stripe-payment.testdata.ts index f7954b595..86268297d 100644 --- a/apps/api/src/donations/events/stripe-payment.testdata.ts +++ b/apps/api/src/stripe/events/stripe-payment.testdata.ts @@ -135,7 +135,7 @@ export const mockPaymentEventCreated: Stripe.Event = { export const mockPaymentEventCancelled: Stripe.Event = { id: 'evt_3LUzB4KApGjVGa9t0lyGsAk8', object: 'event', - api_version: '2020-08-27', + api_version: '2022-11-15', created: 1660163846, data: { object: { @@ -209,7 +209,7 @@ export const mockPaymentEventCancelled: Stripe.Event = { export const mockPaymentEventFailed: Stripe.Event = { id: 'evt_3LUzB4KApGjVGa9t0lyGsAk8', object: 'event', - api_version: '2020-08-27', + api_version: '2022-11-15', created: 1660163846, data: { object: { @@ -1138,7 +1138,7 @@ export const mockPaymentIntentUKIncluded: Stripe.PaymentIntent = { export const mockCustomerSubscriptionCreated: Stripe.Event = { id: 'evt_1MCs25FIrMXL5nkaYRibZXAc', object: 'event', - api_version: '2022-08-01', + api_version: '2022-11-15', created: 1670536411, data: { object: { @@ -1306,7 +1306,7 @@ export const mockedRecurringDonation: RecurringDonation = { export const mockInvoicePaidEvent: Stripe.Event = { id: 'evt_1MDz4SFIrMXL5nkawzOfj5Uh', object: 'event', - api_version: '2022-08-01', + api_version: '2022-11-15', created: 1670801796, data: { object: { @@ -1501,3 +1501,99 @@ export const mockInvoicePaidEvent: Stripe.Event = { }, type: 'invoice.paid', } + +export const mockCharge: Stripe.Charge = { + id: 'ch_3LNwijKApGjVGa9t1tuRzvbL', + object: 'charge', + amount: 1063, + amount_captured: 1063, + amount_refunded: 0, + application: null, + application_fee: null, + application_fee_amount: null, + balance_transaction: 'txn_3LNwijKApGjVGa9t100xnggj', + billing_details: { + address: { + city: null, + country: 'BG', + line1: null, + line2: null, + postal_code: null, + state: null, + }, + email: 'test@gmail.com', + name: 'First Last', + phone: null, + }, + calculated_statement_descriptor: 'PODKREPI.BG', + captured: true, + created: 1658399779, + currency: 'bgn', + customer: 'cus_M691kVNYuUp4po', + description: null, + destination: null, + dispute: null, + disputed: false, + failure_balance_transaction: null, + failure_code: null, + failure_message: null, + fraud_details: {}, + invoice: null, + livemode: false, + metadata: { + campaignId: '4c1616b0-1284-4b7d-8b89-9098e7ded2c4', + }, + on_behalf_of: null, + outcome: { + network_status: 'approved_by_network', + reason: null, + risk_level: 'normal', + risk_score: 33, + seller_message: 'Payment complete.', + type: 'authorized', + }, + paid: true, + payment_intent: 'pi_3LNwijKApGjVGa9t1F9QYd5s', + payment_method: 'pm_1LNwjtKApGjVGa9thtth9iu7', + payment_method_details: { + card: { + brand: 'visa', + checks: { + address_line1_check: null, + address_postal_code_check: null, + cvc_check: 'pass', + }, + country: 'BG', + exp_month: 4, + exp_year: 2024, + fingerprint: 'iCySKWAAAZGp2hwr', + funding: 'credit', + installments: null, + last4: '0000', + mandate: null, + network: 'visa', + three_d_secure: null, + wallet: null, + }, + type: 'card', + }, + receipt_email: 'test@gmail.com', + receipt_number: null, + receipt_url: 'https://pay.stripe.com/receipts/', + refunded: false, + refunds: { + object: 'list', + data: [], + has_more: false, + url: '/v1/charges/ch_3LNwijKApGjVGa9t1tuRzvbL/refunds', + }, + review: null, + shipping: null, + source: null, + source_transfer: null, + statement_descriptor: null, + statement_descriptor_suffix: null, + status: 'succeeded', + transfer_data: null, + transfer_group: null, +} diff --git a/apps/api/src/donations/dontation-metadata.interface.ts b/apps/api/src/stripe/stripe-metadata.interface.ts similarity index 76% rename from apps/api/src/donations/dontation-metadata.interface.ts rename to apps/api/src/stripe/stripe-metadata.interface.ts index c979a561f..2b28b13f9 100644 --- a/apps/api/src/donations/dontation-metadata.interface.ts +++ b/apps/api/src/stripe/stripe-metadata.interface.ts @@ -1,7 +1,7 @@ import { DonationType } from '@prisma/client' import Stripe from 'stripe' -export interface DonationMetadata extends Stripe.MetadataParam { +export interface StripeMetadata extends Stripe.MetadataParam { type: DonationType | null campaignId: string | null personId: string | null diff --git a/apps/api/src/stripe/stripe.controller.spec.ts b/apps/api/src/stripe/stripe.controller.spec.ts new file mode 100644 index 000000000..d6334f873 --- /dev/null +++ b/apps/api/src/stripe/stripe.controller.spec.ts @@ -0,0 +1,238 @@ +import { + StripeModule as GoLevelUpStripeModule, + STRIPE_CLIENT_TOKEN, + StripeModuleConfig, +} from '@golevelup/nestjs-stripe' +import { Test, TestingModule } from '@nestjs/testing' +import { StripeController } from './stripe.controller' +import { StripeService } from './stripe.service' +import { MockPrismaService, prismaMock } from '../prisma/prisma-client.mock' +import { Campaign, CampaignState } from '@prisma/client' +import { CreateSessionDto } from '../donations/dto/create-session.dto' +import { NotAcceptableException } from '@nestjs/common' +import { PersonService } from '../person/person.service' +import { CampaignService } from '../campaign/campaign.service' +import { MarketingNotificationsService } from '../notifications/notifications.service' +import { ConfigService } from 'aws-sdk' +import { ConfigModule } from '@nestjs/config' +import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { RecurringDonationModule } from '../recurring-donation/recurring-donation.module' + +import { EmailService } from '../email/email.service' + +import { TemplateService } from '../email/template.service' +import { DonationsService } from '../donations/donations.service' +import { VaultService } from '../vault/vault.service' +import { ExportService } from '../export/export.service' +import { NotificationModule } from '../sockets/notifications/notification.module' + +import { KeycloakTokenParsed } from '../auth/keycloak' +describe('StripeController', () => { + let controller: StripeController + const idempotencyKey = 'test_123' + const stripeMock = { + checkout: { sessions: { create: jest.fn() } }, + paymentIntents: { retrieve: jest.fn() }, + refunds: { create: jest.fn() }, + setupIntents: { retrieve: jest.fn() }, + customers: { create: jest.fn(), list: jest.fn() }, + paymentMethods: { attach: jest.fn() }, + products: { search: jest.fn(), create: jest.fn() }, + subscriptions: { create: jest.fn() }, + } + stripeMock.checkout.sessions.create.mockResolvedValue({ payment_intent: 'unique-intent' }) + stripeMock.paymentIntents.retrieve.mockResolvedValue({ + payment_intent: 'unique-intent', + metadata: { campaignId: 'unique-campaign' }, + }) + stripeMock.products.search.mockResolvedValue({ data: [{ id: 1 }] }) + stripeMock.subscriptions.create.mockResolvedValue({ + latest_invoice: { payment_intent: 'unique_intent' }, + }) + + stripeMock.setupIntents.retrieve.mockResolvedValue({ + payment_intent: 'unique-intent', + metadata: { campaignId: 'unique-campaign', amount: 100, currency: 'BGN' }, + payment_method: { + billing_details: { + email: 'test@podkrepi.bg', + }, + }, + }) + + stripeMock.customers.list.mockResolvedValue({ data: [{ id: 1 }] }) + + const mockSession = { + mode: 'payment', + amount: 100, + campaignId: 'testCampaignId', + successUrl: 'http://test.com', + cancelUrl: 'http://test.com', + isAnonymous: true, + } as CreateSessionDto + + beforeEach(async () => { + const stripeSecret = 'wh_123' + const moduleConfig: StripeModuleConfig = { + apiKey: stripeSecret, + webhookConfig: { + stripeSecrets: { + account: stripeSecret, + }, + loggingConfiguration: { + logMatchingEventHandlers: true, + }, + }, + } + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + GoLevelUpStripeModule.forRootAsync(GoLevelUpStripeModule, { + useFactory: () => moduleConfig, + }), + MarketingNotificationsModule, + NotificationModule, + RecurringDonationModule, + ], + controllers: [StripeController], + providers: [ + EmailService, + TemplateService, + VaultService, + ExportService, + DonationsService, + CampaignService, + PersonService, + StripeService, + MockPrismaService, + { + provide: STRIPE_CLIENT_TOKEN, + useValue: stripeMock, + }, + ], + }).compile() + + controller = module.get(StripeController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + afterEach(() => { + jest.clearAllMocks() + }) + it('createCheckoutSession should create stripe session for active campaign', async () => { + prismaMock.campaign.findFirst.mockResolvedValue({ + id: 'active-campaign', + allowDonationOnComplete: false, + state: CampaignState.active, + } as Campaign) + + await expect(controller.createCheckoutSession(mockSession)).resolves.toBeObject() + expect(prismaMock.campaign.findFirst).toHaveBeenCalled() + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledWith({ + mode: mockSession.mode, + line_items: [ + { + price_data: { + currency: undefined, + product_data: { + name: undefined, + }, + unit_amount: 100, + }, + quantity: 1, + }, + ], + payment_method_types: ['card'], + payment_intent_data: { + metadata: { + campaignId: mockSession.campaignId, + isAnonymous: 'true', + personId: undefined, + wish: null, + }, + }, + subscription_data: undefined, + success_url: mockSession.successUrl, + cancel_url: mockSession.cancelUrl, + customer_email: undefined, + tax_id_collection: { + enabled: true, + }, + }) + }) + + it('createCheckoutSession should not create stripe session for completed campaign', async () => { + prismaMock.campaign.findFirst.mockResolvedValue({ + id: 'complete-campaign', + allowDonationOnComplete: false, + state: CampaignState.complete, + } as Campaign) + + await expect(controller.createCheckoutSession(mockSession)).rejects.toThrow( + new NotAcceptableException('Campaign cannot accept donations in state: complete'), + ) + + expect(prismaMock.campaign.findFirst).toHaveBeenCalled() + expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled() + }) + + it('createCheckoutSession should create stripe session for completed campaign if allowed', async () => { + prismaMock.campaign.findFirst.mockResolvedValue({ + id: 'complete-campaignp-pass', + allowDonationOnComplete: true, + state: CampaignState.complete, + } as Campaign) + + await expect(controller.createCheckoutSession(mockSession)).resolves.toBeObject() + expect(prismaMock.campaign.findFirst).toHaveBeenCalled() + expect(stripeMock.checkout.sessions.create).toHaveBeenCalled() + }) + + it('should request refund for donation', async () => { + await controller.refundStripePaymet('unique-intent') + + expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('unique-intent') + expect(stripeMock.refunds.create).toHaveBeenCalledWith({ + payment_intent: 'unique-intent', + reason: 'requested_by_customer', + }) + }) + it(`should not call setupintents.update if campaign can't accept donations`, async () => { + prismaMock.campaign.findFirst.mockResolvedValue({ + id: 'complete-campaign', + allowDonationOnComplete: false, + state: CampaignState.complete, + } as Campaign) + + const payload = { + metadata: { + campaignId: 'complete-campaign', + }, + } + + await expect(controller.updateSetupIntent('123', idempotencyKey, payload)).rejects.toThrow( + new NotAcceptableException('Campaign cannot accept donations in state: complete'), + ) + }) + it(`should subscription without creating new customer,products`, async () => { + const user: KeycloakTokenParsed = { + sub: '00000000-0000-0000-0000-000000000013', + 'allowed-origins': [], + email: 'test@podkrepi.bg', + } + prismaMock.campaign.findFirst.mockResolvedValue({ + id: 'complete-campaign', + allowDonationOnComplete: false, + state: CampaignState.complete, + title: 'active-campaign', + } as Campaign) + await expect(controller.setupIntentToSubscription('123', idempotencyKey)).toResolve() + expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith('123', { + expand: ['payment_method'], + }) + expect(stripeMock.customers.create).not.toHaveBeenCalled() + expect(stripeMock.products.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/src/stripe/stripe.controller.ts b/apps/api/src/stripe/stripe.controller.ts new file mode 100644 index 000000000..4e3629f59 --- /dev/null +++ b/apps/api/src/stripe/stripe.controller.ts @@ -0,0 +1,164 @@ +import { + Body, + Controller, + Get, + Logger, + NotFoundException, + Param, + Patch, + Post, + Query, + UnauthorizedException, +} from '@nestjs/common' +import { ApiBody, ApiTags } from '@nestjs/swagger' +import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' +import { CancelPaymentIntentDto } from './dto/cancel-payment-intent.dto' +import { CreatePaymentIntentDto } from './dto/create-payment-intent.dto' +import { UpdatePaymentIntentDto } from './dto/update-payment-intent.dto' +import { UpdateSetupIntentDto } from './dto/update-setup-intent.dto' +import { StripeService } from './stripe.service' +import { KeycloakTokenParsed } from '../auth/keycloak' +import { CreateSubscriptionPaymentDto } from './dto/create-subscription-payment.dto' +import { EditFinancialsRequests } from '@podkrepi-bg/podkrepi-types' +import { CreateSessionDto } from '../donations/dto/create-session.dto' +import { PersonService } from '../person/person.service' + +@Controller('stripe') +@ApiTags('stripe') +export class StripeController { + constructor( + private readonly stripeService: StripeService, + private readonly personService: PersonService, + ) {} + + @Post('setup-intent') + @Public() + createSetupIntent(@Body() body: { idempotencyKey: string }) { + return this.stripeService.createSetupIntent(body) + } + + @Post('create-checkout-session') + @Public() + async createCheckoutSession(@Body() sessionDto: CreateSessionDto) { + if ( + sessionDto.mode === 'subscription' && + (sessionDto.personId === null || sessionDto.personId.length === 0) + ) { + // in case of a intermediate (step 2) login, we might end up with no personId + // not able to fetch the current logged user here (due to @Public()) + const person = await this.personService.findByEmail(sessionDto.personEmail) + if (!person) + throw new NotFoundException(`Person with email ${sessionDto.personEmail} not found`) + sessionDto.personId = person.id + } + + if ( + sessionDto.mode == 'subscription' && + (sessionDto.personId == null || sessionDto.personId.length == 0) + ) { + Logger.error( + `No personId found for email ${sessionDto.personEmail}. Unable to create a checkout session for a recurring donation`, + ) + throw new UnauthorizedException('You must be logged in to create a recurring donation') + } + + Logger.debug(`Creating checkout session with data ${JSON.stringify(sessionDto)}`) + + return this.stripeService.createCheckoutSession(sessionDto) + } + + @Post(':id/refund') + @Roles({ + roles: [EditFinancialsRequests.role], + mode: RoleMatchingMode.ANY, + }) + refundStripePaymet(@Param('id') paymentIntentId: string) { + return this.stripeService.refundStripePayment(paymentIntentId) + } + + @Post('setup-intent/:id') + @Public() + updateSetupIntent( + @Param('id') id: string, + @Query('idempotency-key') idempotencyKey: string, + @Body() updateSetupIntentDto: UpdateSetupIntentDto, + ) { + return this.stripeService.updateSetupIntent(id, idempotencyKey, updateSetupIntentDto) + } + + @Patch('setup-intent/:id/cancel') + @Public() + cancelSetupIntent(@Param('id') id: string) { + return this.stripeService.cancelSetupIntent(id) + } + + @Post('setup-intent/:id/payment-intent') + @ApiBody({ + description: 'Create payment intent from setup intent', + }) + @Public() + setupIntentToPaymentIntent( + @Param('id') id: string, + @Query('idempotency-key') idempotencyKey: string, + ) { + return this.stripeService.setupIntentToPaymentIntent(id, idempotencyKey) + } + + @Post('setup-intent/:id/subscription') + @ApiBody({ + description: 'Create payment intent from setup intent', + }) + setupIntentToSubscription( + @Param('id') id: string, + @Query('idempotency-key') idempotencyKey: string, + ) { + return this.stripeService.setupIntentToSubscription(id, idempotencyKey) + } + + @Post('payment-intent') + @Public() + createPaymentIntent( + @Body() + createPaymentIntentDto: CreatePaymentIntentDto, + ) { + return this.stripeService.createPaymentIntent(createPaymentIntentDto) + } + + @Post('payment-intent/:id') + @Public() + updatePaymentIntent( + @Param('id') id: string, + @Body() + updatePaymentIntentDto: UpdatePaymentIntentDto, + ) { + return this.stripeService.updatePaymentIntent(id, updatePaymentIntentDto) + } + + @Post('payment-intent/:id/cancel') + @Public() + cancelPaymentIntent( + @Param('id') id: string, + @Body() + cancelPaymentIntentDto: CancelPaymentIntentDto, + ) { + return this.stripeService.cancelPaymentIntent(id, cancelPaymentIntentDto) + } + + @Get('prices') + @Public() + findPrices() { + return this.stripeService.listPrices() + } + + @Get('prices/single') + @Public() + findSinglePrices() { + return this.stripeService.listPrices('one_time') + } + + @Get('prices/recurring') + @Public() + findRecurringPrices() { + return this.stripeService.listPrices('recurring') + } +} diff --git a/apps/api/src/stripe/stripe.module.ts b/apps/api/src/stripe/stripe.module.ts new file mode 100644 index 000000000..fc60da953 --- /dev/null +++ b/apps/api/src/stripe/stripe.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { StripeModule as StripeClientModule } from '@golevelup/nestjs-stripe' +import { StripeService } from './stripe.service' +import { StripeController } from './stripe.controller' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { StripeConfigFactory } from '../donations/helpers/stripe-config-factory' +import { CampaignModule } from '../campaign/campaign.module' +import { PersonModule } from '../person/person.module' + +import { DonationsModule } from '../donations/donations.module' +import { RecurringDonationModule } from '../recurring-donation/recurring-donation.module' +import { StripePaymentService } from './events/stripe-payment.service' +import { EmailService } from '../email/email.service' +import { TemplateService } from '../email/template.service' + +@Module({ + imports: [ + StripeClientModule.forRootAsync(StripeClientModule, { + inject: [ConfigService], + useFactory: StripeConfigFactory.useFactory, + }), + ConfigModule, + CampaignModule, + PersonModule, + DonationsModule, + RecurringDonationModule, + ], + providers: [StripeService, StripePaymentService, EmailService, TemplateService], + controllers: [StripeController], +}) +export class StripeModule {} diff --git a/apps/api/src/stripe/stripe.service.spec.ts b/apps/api/src/stripe/stripe.service.spec.ts new file mode 100644 index 000000000..c58678650 --- /dev/null +++ b/apps/api/src/stripe/stripe.service.spec.ts @@ -0,0 +1,101 @@ +import { + StripeModule as GoLevelUpStripeModule, + STRIPE_CLIENT_TOKEN, + StripeModuleConfig, +} from '@golevelup/nestjs-stripe' +import { Test, TestingModule } from '@nestjs/testing' +import { StripeService } from './stripe.service' +import { PersonService } from '../person/person.service' +import { CampaignService } from '../campaign/campaign.service' +import { ConfigModule, ConfigService } from '@nestjs/config' + +import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { NotificationModule } from '../sockets/notifications/notification.module' +import { RecurringDonationModule } from '../recurring-donation/recurring-donation.module' +import { EmailService } from '../email/email.service' +import { TemplateService } from '../email/template.service' +import { VaultService } from '../vault/vault.service' +import { ExportService } from '../export/export.service' +import { DonationsService } from '../donations/donations.service' +import { StripeController } from './stripe.controller' +import { MockPrismaService } from '../prisma/prisma-client.mock' + +describe('StripeService', () => { + let service: StripeService + + const stripeMock = { + checkout: { sessions: { create: jest.fn() } }, + paymentIntents: { retrieve: jest.fn() }, + refunds: { create: jest.fn() }, + subscriptions: { cancel: jest.fn() }, + } + stripeMock.checkout.sessions.create.mockResolvedValue({ payment_intent: 'unique-intent' }) + stripeMock.paymentIntents.retrieve.mockResolvedValue({ + payment_intent: 'unique-intent', + metadata: { campaignId: 'unique-campaign' }, + }) + + beforeEach(async () => { + const stripeSecret = 'wh_123' + const moduleConfig: StripeModuleConfig = { + apiKey: stripeSecret, + webhookConfig: { + stripeSecrets: { + account: stripeSecret, + }, + loggingConfiguration: { + logMatchingEventHandlers: true, + }, + }, + } + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + GoLevelUpStripeModule.forRootAsync(GoLevelUpStripeModule, { + useFactory: () => moduleConfig, + }), + MarketingNotificationsModule, + NotificationModule, + RecurringDonationModule, + ], + controllers: [StripeController], + providers: [ + EmailService, + TemplateService, + VaultService, + ExportService, + DonationsService, + CampaignService, + PersonService, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, + StripeService, + MockPrismaService, + { + provide: STRIPE_CLIENT_TOKEN, + useValue: stripeMock, + }, + ], + }).compile() + + service = module.get(StripeService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should call stripe cancel service my subscription id', async () => { + const cancelSubscriptionSpy = jest + .spyOn(stripeMock.subscriptions, 'cancel') + .mockImplementation(() => { + return Promise.resolve({ status: 'canceled' }) + }) + await service.cancelSubscription('sub1') + expect(cancelSubscriptionSpy).toHaveBeenCalledWith('sub1') + }) +}) diff --git a/apps/api/src/stripe/stripe.service.ts b/apps/api/src/stripe/stripe.service.ts new file mode 100644 index 000000000..da9441123 --- /dev/null +++ b/apps/api/src/stripe/stripe.service.ts @@ -0,0 +1,449 @@ +import { InjectStripeClient } from '@golevelup/nestjs-stripe' +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' +import Stripe from 'stripe' +import { UpdateSetupIntentDto } from './dto/update-setup-intent.dto' +import { KeycloakTokenParsed } from '../auth/keycloak' +import { CreateSubscriptionPaymentDto } from './dto/create-subscription-payment.dto' +import { CampaignService } from '../campaign/campaign.service' +import { PersonService } from '../person/person.service' +import { DonationsService } from '../donations/donations.service' +import { CreateSessionDto } from '../donations/dto/create-session.dto' +import { Campaign, DonationMetadata, Payment } from '@prisma/client' +import { ConfigService } from '@nestjs/config' +import { StripeMetadata } from './stripe-metadata.interface' +import { CreateStripePaymentDto } from '../donations/dto/create-stripe-payment.dto' +import { RecurringDonationService } from '../recurring-donation/recurring-donation.service' + +@Injectable() +export class StripeService { + constructor( + @InjectStripeClient() private stripeClient: Stripe, + private personService: PersonService, + private campaignService: CampaignService, + private donationService: DonationsService, + private configService: ConfigService, + private reacurringDonationService: RecurringDonationService, + ) {} + + /** + * Update a setup intent for a donation + * @param inputDto Payment intent update params + * @returns {Promise>} + */ + async updateSetupIntent( + id: string, + idempotencyKey: string, + inputDto: UpdateSetupIntentDto, + ): Promise> { + if (!inputDto.metadata.campaignId) + throw new BadRequestException('campaignId is missing from metadata') + const campaign = await this.campaignService.validateCampaignId( + inputDto.metadata.campaignId as string, + ) + return await this.stripeClient.setupIntents.update(id, inputDto, { idempotencyKey }) + } + /** + * Create a payment intent for a donation + * https://stripe.com/docs/api/payment_intents/create + * @param inputDto Payment intent create params + * @returns {Promise>} + */ + + async cancelSetupIntent(id: string) { + return await this.stripeClient.setupIntents.cancel(id) + } + async findSetupIntentById(setupIntentId: string): Promise { + const setupIntent = await this.stripeClient.setupIntents.retrieve(setupIntentId, { + expand: ['payment_method'], + }) + + if (!setupIntent.payment_method || typeof setupIntent.payment_method === 'string') { + throw new BadRequestException('Payment method is missing from setup intent') + } + const paymentMethod = setupIntent.payment_method + + if (!paymentMethod?.billing_details?.email) { + throw new BadRequestException('Email is required from the payment method') + } + if (!setupIntent.metadata || !setupIntent.metadata.amount || !setupIntent.metadata.currency) { + throw new BadRequestException('Amount and currency are required from the setup intent') + } + return setupIntent + } + + async attachPaymentMethodToCustomer( + paymentMethod: Stripe.PaymentMethod, + customer: Stripe.Customer, + idempotencyKey: string, + ) { + return await this.stripeClient.paymentMethods.attach( + paymentMethod.id, + { + customer: customer.id, + }, + { idempotencyKey: `${idempotencyKey}--pm` }, + ) + } + async setupIntentToPaymentIntent( + setupIntentId: string, + idempotencyKey: string, + ): Promise { + const setupIntent = await this.findSetupIntentById(setupIntentId) + + if (setupIntent instanceof Error) throw new BadRequestException(setupIntent.message) + const paymentMethod = setupIntent.payment_method as Stripe.PaymentMethod + const email = paymentMethod.billing_details.email as string + const name = paymentMethod.billing_details.name as string + const metadata = setupIntent.metadata as Stripe.Metadata + + const customer = await this.createCustomer(email, name, paymentMethod, idempotencyKey) + + await this.attachPaymentMethodToCustomer(paymentMethod, customer, idempotencyKey) + + const paymentIntent = await this.stripeClient.paymentIntents.create( + { + amount: Math.round(Number(metadata.amount)), + currency: metadata.currency, + customer: customer.id, + payment_method: paymentMethod.id, + confirm: true, + metadata: { + ...setupIntent.metadata, + }, + }, + { idempotencyKey: `${idempotencyKey}--pi` }, + ) + return paymentIntent + } + /** + * Create a setup intent for a donation + * @param inputDto Payment intent create params + * @returns {Promise>} + */ + async createSetupIntent({ + idempotencyKey, + }: { + idempotencyKey: string + }): Promise> { + return await this.stripeClient.setupIntents.create({}, { idempotencyKey }) + } + + async setupIntentToSubscription( + setupIntentId: string, + idempotencyKey: string, + ): Promise { + const setupIntent = await this.findSetupIntentById(setupIntentId) + if (setupIntent instanceof Error) throw new BadRequestException(setupIntent.message) + const paymentMethod = setupIntent.payment_method as Stripe.PaymentMethod + const email = paymentMethod.billing_details.email as string + const name = paymentMethod.billing_details.name as string + const metadata = setupIntent.metadata as Stripe.Metadata + + const customer = await this.createCustomer(email, name, paymentMethod, idempotencyKey) + await this.attachPaymentMethodToCustomer(paymentMethod, customer, idempotencyKey) + + const product = await this.createProduct(metadata.campaignId, idempotencyKey) + return await this.createSubscription(metadata, customer, product, paymentMethod, idempotencyKey) + } + + /** + * Update a payment intent for a donation + * https://stripe.com/docs/api/payment_intents/update + * @param inputDto Payment intent create params + * @returns {Promise>} + */ + async updatePaymentIntent( + id: string, + inputDto: Stripe.PaymentIntentUpdateParams, + ): Promise> { + return this.stripeClient.paymentIntents.update(id, inputDto) + } + + /** + * Cancel a payment intent for a donation + * https://stripe.com/docs/api/payment_intents/cancel + * @param inputDto Payment intent create params + * @returns {Promise>} + */ + async cancelPaymentIntent( + id: string, + inputDto: Stripe.PaymentIntentCancelParams, + ): Promise> { + return this.stripeClient.paymentIntents.cancel(id, inputDto) + } + + async listPrices(type?: Stripe.PriceListParams.Type, active?: boolean): Promise { + const listResponse = await this.stripeClient.prices.list({ active, type, limit: 100 }).then( + function (list) { + Logger.debug('[Stripe] Prices received: ' + list.data.length) + return { list } + }, + function (error) { + if (error instanceof Stripe.errors.StripeError) + Logger.error( + '[Stripe] Error while getting price list. Error type: ' + + error.type + + ' message: ' + + error.message + + ' full error: ' + + JSON.stringify(error), + ) + }, + ) + + if (listResponse) { + return listResponse.list.data.filter((price) => price.active) + } else return new Array() + } + + async createCustomer( + email: string, + name: string, + paymentMethod: Stripe.PaymentMethod, + idempotencyKey: string, + ) { + const customerLookup = await this.stripeClient.customers.list({ + email, + }) + const customer = customerLookup.data[0] + //Customer not found. Create new onw + if (!customer) + return await this.stripeClient.customers.create( + { + email, + name, + payment_method: paymentMethod.id, + }, + { idempotencyKey: `${idempotencyKey}--customer` }, + ) + + return customer + } + + async createProduct(campaignId: string, idempotencyKey: string): Promise { + const campaign = await this.campaignService.getCampaignById(campaignId) + if (!campaign) throw new Error(`Campaign with id ${campaignId} not found`) + + const productLookup = await this.stripeClient.products.search({ + query: `-name:'${campaign.title}'`, + }) + + if (productLookup) return productLookup.data[0] + return await this.stripeClient.products.create( + { + name: campaign.title, + description: `Donate to ${campaign.title}`, + }, + { idempotencyKey: `${idempotencyKey}--product` }, + ) + } + async createSubscription( + metadata: Stripe.Metadata, + customer: Stripe.Customer, + product: Stripe.Product, + paymentMethod: Stripe.PaymentMethod, + idempotencyKey: string, + ) { + const subscription = await this.stripeClient.subscriptions.create( + { + customer: customer.id, + items: [ + { + price_data: { + unit_amount: Math.round(Number(metadata.amount)), + currency: metadata.currency, + product: product.id, + recurring: { interval: 'month' }, + }, + }, + ], + default_payment_method: paymentMethod.id, + payment_settings: { + save_default_payment_method: 'on_subscription', + payment_method_types: ['card'], + }, + metadata: { + type: metadata.type, + campaignId: metadata.campaignId, + personId: metadata.personId, + }, + expand: ['latest_invoice.payment_intent'], + }, + { idempotencyKey: `${idempotencyKey}--subscription` }, + ) + //include metadata in payment-intent + const invoice = subscription.latest_invoice as Stripe.Invoice + const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent + return paymentIntent + } + + async createCheckoutSession( + sessionDto: CreateSessionDto, + ): Promise { + const campaign = await this.campaignService.validateCampaignId(sessionDto.campaignId) + const { mode } = sessionDto + const appUrl = this.configService.get('APP_URL') + + const metadata: StripeMetadata = { + campaignId: sessionDto.campaignId, + personId: sessionDto.personId, + isAnonymous: sessionDto.isAnonymous ? 'true' : 'false', + wish: sessionDto.message ?? null, + type: sessionDto.type, + } + + const items = await this.prepareSessionItems(sessionDto, campaign) + const createSessionRequest: Stripe.Checkout.SessionCreateParams = { + mode, + customer_email: sessionDto.personEmail, + line_items: items, + payment_method_types: ['card'], + payment_intent_data: mode == 'payment' ? { metadata } : undefined, + subscription_data: mode == 'subscription' ? { metadata } : undefined, + success_url: sessionDto.successUrl ?? `${appUrl}/success`, + cancel_url: sessionDto.cancelUrl ?? `${appUrl}/canceled`, + tax_id_collection: { enabled: true }, + } + + const sessionResponse = await this.stripeClient.checkout.sessions + .create(createSessionRequest) + .then( + function (session) { + Logger.debug('[Stripe] Checkout session created.') + return { session } + }, + function (error) { + if (error instanceof Stripe.errors.StripeError) + Logger.error( + '[Stripe] Error while creating checkout session. Error type: ' + + error.type + + ' message: ' + + error.message + + ' full error: ' + + JSON.stringify(error), + ) + }, + ) + + if (sessionResponse) { + this.donationService.createInitialDonationFromSession( + campaign, + sessionDto, + (sessionResponse.session.payment_intent as string) ?? sessionResponse.session.id, + ) + } + + return sessionResponse + } + + private async prepareSessionItems( + sessionDto: CreateSessionDto, + campaign: Campaign, + ): Promise { + if (sessionDto.mode == 'subscription') { + // the membership campaign is internal only + // we need to make the subscriptions once a year + const isMembership = await this.campaignService.isMembershipCampaign(campaign.campaignTypeId) + const interval = isMembership ? 'year' : 'month' + + //use an inline price for subscriptions + const stripeItem = { + price_data: { + currency: campaign.currency, + unit_amount: sessionDto.amount, + recurring: { + interval: interval as Stripe.Price.Recurring.Interval, + interval_count: 1, + }, + product_data: { + name: campaign.title, + }, + }, + quantity: 1, + } + return [stripeItem] + } + // Create donation with custom amount + return [ + { + price_data: { + currency: campaign.currency, + unit_amount: sessionDto.amount, + product_data: { + name: campaign.title, + }, + }, + quantity: 1, + }, + ] + } + + /** + * Create a payment intent for a donation + * @param inputDto Payment intent create params + * @returns {Promise>} + */ + async createPaymentIntent( + inputDto: Stripe.PaymentIntentCreateParams, + ): Promise> { + return await this.stripeClient.paymentIntents.create(inputDto) + } + + /** + * Create a payment intent for a donation + * https://stripe.com/docs/api/payment_intents/create + * @param inputDto Payment intent create params + * @returns {Promise>} + */ + async createStripePayment(inputDto: CreateStripePaymentDto): Promise { + const intent = await this.stripeClient.paymentIntents.retrieve(inputDto.paymentIntentId) + if (!intent.metadata.campaignId) { + throw new BadRequestException('Campaign id is missing from payment intent metadata') + } + const campaignId = intent.metadata.camapaignId + const campaign = await this.campaignService.validateCampaignId(campaignId) + return this.donationService.createInitialDonationFromIntent(campaign, inputDto, intent) + } + + /** + * Refund a stipe payment donation + * https://stripe.com/docs/api/refunds/create + * @param inputDto Refund-stripe params + * @returns {Promise>} + */ + async refundStripePayment(paymentIntentId: string): Promise> { + const intent = await this.stripeClient.paymentIntents.retrieve(paymentIntentId) + if (!intent) { + throw new BadRequestException('Payment Intent is missing from stripe') + } + + if (!intent.metadata.campaignId) { + throw new BadRequestException('Campaign id is missing from payment intent metadata') + } + + return await this.stripeClient.refunds.create({ + payment_intent: paymentIntentId, + reason: 'requested_by_customer', + }) + } + + async cancelSubscription(subscriptionId: string) { + Logger.log(`Canceling subscription with api request to cancel: ${subscriptionId}`) + const result = await this.stripeClient.subscriptions.cancel(subscriptionId) + if (result.status !== 'canceled') { + Logger.log(`Subscription cancel attempt failed with status of ${result.id}: ${result.status}`) + return + } + + // the webhook will handle this as well. + // but we cancel it here, in case the webhook is slow. + const rd = await this.reacurringDonationService.findSubscriptionByExtId(result.id) + if (rd) { + return this.reacurringDonationService.cancel(rd.id) + } + } + + async findChargeById(chargeId: string): Promise { + return await this.stripeClient.charges.retrieve(chargeId) + } +} diff --git a/apps/api/src/vault/vault.controller.spec.ts b/apps/api/src/vault/vault.controller.spec.ts index c83e7bb7f..2334bfc4c 100644 --- a/apps/api/src/vault/vault.controller.spec.ts +++ b/apps/api/src/vault/vault.controller.spec.ts @@ -14,7 +14,7 @@ import { TemplateService } from '../email/template.service' import { MarketingNotificationsService } from '../notifications/notifications.service' import { NotificationsProviderInterface } from '../notifications/providers/notifications.interface.providers' import { SendGridNotificationsProvider } from '../notifications/providers/notifications.sendgrid.provider' -import { mockedVault } from '../donations/events/stripe-payment.testdata' +import { mockedVault } from '../stripe/events/stripe-payment.testdata' import { mockVault } from './__mocks__/vault' describe('VaultController', () => { diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 9ed4b7c31..ec83b83aa 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -207,7 +207,6 @@ export class VaultService { }, []) if (failedVaults.length > 0) { - console.log(`errro`) throw new Error( `Updating vaults aborted, due to negative amount in some of the vaults. Invalid vaultIds: ${failedVaults.join(',')}`, diff --git a/package.json b/package.json index a0067bbf4..c2f46551d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.369.0", - "@golevelup/nestjs-stripe": "0.6.0", + "@golevelup/nestjs-stripe": "^0.7.0", "@golevelup/nestjs-webhooks": "0.2.14", "@keycloak/keycloak-admin-client": "18.0.0", "@nestjs/axios": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index 89bb89cce..e094a8aa6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2438,12 +2438,27 @@ __metadata: languageName: node linkType: hard -"@golevelup/nestjs-discovery@npm:^3.0.0": - version: 3.0.0 - resolution: "@golevelup/nestjs-discovery@npm:3.0.0" +"@golevelup/nestjs-common@npm:^2.0.0": + version: 2.0.0 + resolution: "@golevelup/nestjs-common@npm:2.0.0" dependencies: - lodash: ^4.17.15 - checksum: 62d60b3da8604dc131f38fcf965fcbbb0fdce37af7d71c71710fa14b8ac1df80d22b45d3ecbe5da9bc55a233079ae031778f34b07fc5d19bda082a0345cab87f + lodash: ^4.17.21 + nanoid: ^3.3.6 + peerDependencies: + "@nestjs/common": ^10.x + checksum: 3e114dcd981749c12411ae525b734a95ea6a6ab2a556841996cf3d5a1e3e5be437eb13d894d6e08f784a5e024fa44398baaadb49c909cbd58ff96b893ec3ab2c + languageName: node + linkType: hard + +"@golevelup/nestjs-discovery@npm:^4.0.1": + version: 4.0.1 + resolution: "@golevelup/nestjs-discovery@npm:4.0.1" + dependencies: + lodash: ^4.17.21 + peerDependencies: + "@nestjs/common": ^10.x + "@nestjs/core": ^10.x + checksum: 778bd1418869c0e85d7c41ff166c9bc62f99dc6d2303c15195de1be9e80fdac25a4b5913bda040fad1a2980d0825b8d3fd945b714a6ad1794df939e4052a0d95 languageName: node linkType: hard @@ -2459,16 +2474,28 @@ __metadata: languageName: node linkType: hard -"@golevelup/nestjs-stripe@npm:0.6.0": - version: 0.6.0 - resolution: "@golevelup/nestjs-stripe@npm:0.6.0" +"@golevelup/nestjs-modules@npm:^0.7.1": + version: 0.7.1 + resolution: "@golevelup/nestjs-modules@npm:0.7.1" dependencies: - "@golevelup/nestjs-common": ^1.4.4 - "@golevelup/nestjs-discovery": ^3.0.0 - "@golevelup/nestjs-modules": ^0.6.1 + lodash: ^4.17.21 + peerDependencies: + "@nestjs/common": ^10.x + rxjs: ^7.x + checksum: 170707d7daa495656f9604a69720882d1f87188f14a1ef48a217dfd393bff3258f8fc49bfc3ff359435271c27d01bc8b81f067e0f0f962c00d7515a52aefdafe + languageName: node + linkType: hard + +"@golevelup/nestjs-stripe@npm:^0.7.0": + version: 0.7.0 + resolution: "@golevelup/nestjs-stripe@npm:0.7.0" + dependencies: + "@golevelup/nestjs-common": ^2.0.0 + "@golevelup/nestjs-discovery": ^4.0.1 + "@golevelup/nestjs-modules": ^0.7.1 peerDependencies: - stripe: ^10.8.0 - checksum: 7965adb0f4b3f2c8307747b9f1e293a8facc7894ec9bb9d93646e1462dcc8bf92eee6d2880931ccccdf5a30504c95c3f23763351205bff9815312820e8848df2 + stripe: ^14.19.0 + checksum: af8a304b54e9600f1b6a419e239cb4fcfd7c5e1423f9d4e397988ace01d67ab7410aa0541785f4f41ff632c501c58fa4a7722d9c6668d90b17d58227844fcad3 languageName: node linkType: hard @@ -13587,7 +13614,7 @@ __metadata: dependencies: "@aws-sdk/client-s3": ^3.369.0 "@faker-js/faker": 7.6.0 - "@golevelup/nestjs-stripe": 0.6.0 + "@golevelup/nestjs-stripe": ^0.7.0 "@golevelup/nestjs-webhooks": 0.2.14 "@keycloak/keycloak-admin-client": 18.0.0 "@nestjs/axios": ^2.0.0