From 0118cbabc4e594068173ab44a56de82af02c5af1 Mon Sep 17 00:00:00 2001 From: quantum-grit <91589884+quantum-grit@users.noreply.github.com> Date: Wed, 30 Aug 2023 09:13:08 +0300 Subject: [PATCH] fix campaign completion transaction (#536) * deleted manual donation create method as it is not allowed * added db transaction when updatingDonation that encapsulates the completion of campaign when targetAmount is reached * fixed existing tests * fixed tests with the expected update vault * linter cleanup * removed logging of the full CreateCheckoutSession * simplified updateDonationPayment function by breaking it into smaller ones, so now it is more readable * Update apps/api/src/donations/events/stripe-payment.service.spec.ts Co-authored-by: Slavcho Ivanov * added find vault in the donation transaction scope * fixed tests failing due to rename --------- Co-authored-by: igoychev Co-authored-by: Slavcho Ivanov --- apps/api/src/auth/auth.service.spec.ts | 1 + apps/api/src/campaign/campaign.service.ts | 254 ++++++++++-------- .../donations/donations.controller.spec.ts | 34 ++- .../api/src/donations/donations.controller.ts | 19 -- apps/api/src/donations/donations.service.ts | 140 +++++----- .../events/stripe-payment.service.spec.ts | 85 +++--- .../events/stripe-payment.service.ts | 9 +- .../notifications/notification.service.ts | 1 + apps/api/src/vault/vault.service.ts | 13 +- 9 files changed, 294 insertions(+), 262 deletions(-) diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index bb7aee974..e1e0b9727 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -19,6 +19,7 @@ import { ProviderDto } from './dto/provider.dto' import { EmailService } from '../email/email.service' import { JwtService } from '@nestjs/jwt' import { TemplateService } from '../email/template.service' + import { SendGridNotificationsProvider } from '../notifications/providers/notifications.sendgrid.provider' import { NotificationsProviderInterface } from '../notifications/providers/notifications.interface.providers' import { MarketingNotificationsModule } from '../notifications/notifications.module' diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index a1f9f9809..23dfd820b 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -549,95 +549,71 @@ export class CampaignService { paymentData: PaymentData, newDonationStatus: DonationStatus, metadata?: DonationMetadata, - ): Promise { + ): Promise { const campaignId = campaign.id Logger.debug('Update donation to status: ' + newDonationStatus, { campaignId, paymentIntentId: paymentData.paymentIntentId, }) - /** - * Create or connect campaign vault - */ - const vault = await this.prisma.vault.findFirst({ where: { campaignId } }) - const targetVaultData = vault - ? // Connect the existing vault to this donation - { connect: { id: vault.id } } - : // Create new vault for the campaign - { create: { campaignId, currency: campaign.currency, name: campaign.title } } - - // Find donation by extPaymentIntentId and update if status allows - - let donation = await this.prisma.donation.findUnique({ - where: { extPaymentIntentId: paymentData.paymentIntentId }, - select: donationNotificationSelect, - }) - - // 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 this.prisma.donation.findFirst({ - where: { - status: DonationStatus.initial, - personId: paymentData.personId, - chargedAmount: paymentData.chargedAmount, - extPaymentMethodId: 'subscription', - }, - select: donationNotificationSelect, - }) - - if (donation && newDonationStatus == DonationStatus.succeeded) { - donation.status = newDonationStatus - this.notificationService.sendNotification('successfulDonation', donation) + //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 } - - Logger.debug('Donation found by subscription: ', donation) - } - - //if missing create the donation with the incoming status - if (!donation) { - Logger.debug( - 'No donation exists with extPaymentIntentId: ' + - paymentData.paymentIntentId + - ' Creating new donation with status: ' + + //donation exists, so check if it is safe to update it + else { + const updatedDonation = await this.updateDonationIfAllowed( + tx, + existingDonation, newDonationStatus, - ) + paymentData, + ) + donationId = updatedDonation?.id + } - try { - donation = await this.prisma.donation.create({ - data: { - amount: paymentData.netAmount, - chargedAmount: paymentData.chargedAmount, - currency: campaign.currency, - targetVault: targetVaultData, - provider: paymentData.paymentProvider, - type: DonationType.donation, - status: newDonationStatus, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: paymentData.paymentMethodId ?? '', - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - person: paymentData.personId ? { connect: { id: paymentData.personId } } : {}, - }, - select: donationNotificationSelect, - }) - if (newDonationStatus === DonationStatus.succeeded) { - this.notificationService.sendNotification('successfulDonation', donation) + //For successful donations we will also need to link them to user if not marked as anonymous + if (donationId && newDonationStatus === DonationStatus.succeeded) { + if (metadata?.isAnonymous !== 'true') { + await tx.donation.update({ + where: { id: donationId }, + data: { + person: { + connect: { + email: paymentData.billingEmail, + }, + }, + }, + }) } - } catch (error) { - Logger.error( - `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, - ) - throw new InternalServerErrorException(error) } - } - //donation exists, so check if it is safe to update it - else if (shouldAllowStatusChange(donation.status, newDonationStatus)) { + + return donationId + }) //end of the transaction scope + } + + private async updateDonationIfAllowed( + tx: Prisma.TransactionClient, + donation: Donation, + newDonationStatus: DonationStatus, + paymentData: PaymentData, + ) { + if (shouldAllowStatusChange(donation.status, newDonationStatus)) { try { - const updatedDonation = await this.prisma.donation.update({ + const updatedDonation = await tx.donation.update({ where: { id: donation.id, }, @@ -652,12 +628,17 @@ export class CampaignService { }, select: donationNotificationSelect, }) + + //if donation is switching to successful, increment the vault amount and send notification if (newDonationStatus === DonationStatus.succeeded) { + await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount, tx) this.notificationService.sendNotification('successfulDonation', { ...updatedDonation, - person: donation.person, + person: updatedDonation.person, }) } + + return updatedDonation } catch (error) { Logger.error( `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, @@ -672,26 +653,88 @@ export class CampaignService { and status: ${newDonationStatus} because the event comes after existing donation with status: ${donation.status}`, ) } + } - //For successful donations we will also need to link them to user and add donation wish: - if (newDonationStatus === DonationStatus.succeeded) { - Logger.debug('metadata?.isAnonymous = ' + metadata?.isAnonymous) + private async createIncomingDonation( + tx: Prisma.TransactionClient, + paymentData: PaymentData, + newDonationStatus: DonationStatus, + campaign: Campaign, + ) { + Logger.debug( + 'No donation exists with extPaymentIntentId: ' + + paymentData.paymentIntentId + + ' Creating new donation with status: ' + + newDonationStatus, + ) - if (metadata?.isAnonymous != 'true') { - await this.prisma.donation.update({ - where: { id: donation.id }, - data: { - person: { - connect: { - email: paymentData.billingEmail, - }, - }, - }, - }) + /** + * Create or connect campaign vault + */ + const vault = await tx.vault.findFirst({ where: { campaignId: campaign.id } }) + const targetVaultData = vault + ? // Connect the existing vault to this donation + { connect: { id: vault.id } } + : // Create new vault for the campaign + { create: { campaignId: campaign.id, currency: campaign.currency, name: campaign.title } } + + try { + const donation = await tx.donation.create({ + data: { + amount: paymentData.netAmount, + chargedAmount: paymentData.chargedAmount, + currency: campaign.currency, + targetVault: targetVaultData, + provider: paymentData.paymentProvider, + type: DonationType.donation, + status: newDonationStatus, + extCustomerId: paymentData.stripeCustomerId ?? '', + extPaymentIntentId: paymentData.paymentIntentId, + extPaymentMethodId: paymentData.paymentMethodId ?? '', + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + person: paymentData.personId ? { connect: { id: paymentData.personId } } : {}, + }, + select: donationNotificationSelect, + }) + + if (newDonationStatus === DonationStatus.succeeded) { + await this.vaultService.incrementVaultAmount(donation.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.donation.findUnique({ + where: { extPaymentIntentId: paymentData.paymentIntentId }, + }) - return donation.id + // 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.donation.findFirst({ + where: { + status: DonationStatus.initial, + personId: paymentData.personId, + chargedAmount: paymentData.chargedAmount, + extPaymentMethodId: 'subscription', + }, + }) + Logger.debug('Donation found by subscription: ', donation) + } + return donation } async createDonationWish(wish: string, donationId: string, campaignId: string) { @@ -706,22 +749,6 @@ export class CampaignService { }) } - async donateToCampaign(campaign: Campaign, paymentData: PaymentData) { - Logger.debug('Update amounts with successful donation', { - campaignId: campaign.id, - paymentIntentId: paymentData.paymentIntentId, - netAmount: paymentData.netAmount, - chargedAmount: paymentData.chargedAmount, - }) - - const vault = await this.getCampaignVault(campaign.id) - if (vault) { - await this.vaultService.incrementVaultAmount(vault.id, paymentData.netAmount) - } else { - //vault is already checked and created if not existing in updateDonationPayment() above - } - } - async validateCampaignId(campaignId: string): Promise { const campaign = await this.getCampaignById(campaignId) return this.validateCampaign(campaign) @@ -754,8 +781,11 @@ export class CampaignService { * Call after executing a successful donation and adding the amount to a vault. * This will set the campaign state to 'complete' if the campaign's target amount has been reached */ - public async updateCampaignStatusIfTargetReached(campaignId: string) { - const campaign = await this.prisma.campaign.findFirst({ + public async updateCampaignStatusIfTargetReached( + campaignId: string, + tx: Prisma.TransactionClient, + ) { + const campaign = await tx.campaign.findFirst({ where: { id: campaignId, }, @@ -775,7 +805,7 @@ export class CampaignService { if (campaign && campaign.state !== CampaignState.complete && campaign.targetAmount) { const actualAmount = campaign.vaults.map((vault) => vault.amount).reduce((a, b) => a + b, 0) if (actualAmount >= campaign.targetAmount) { - await this.prisma.campaign.update({ + await tx.campaign.update({ where: { id: campaign.id, }, diff --git a/apps/api/src/donations/donations.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts index bb9685c6b..99fb20391 100644 --- a/apps/api/src/donations/donations.controller.spec.ts +++ b/apps/api/src/donations/donations.controller.spec.ts @@ -9,6 +9,7 @@ import { DonationStatus, DonationType, PaymentProvider, + Vault, } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { ExportService } from '../export/export.service' @@ -25,6 +26,8 @@ import { MarketingNotificationsModule } from '../notifications/notifications.mod describe('DonationsController', () => { let controller: DonationsController + let vaultService: VaultService + const stripeMock = { checkout: { sessions: { create: jest.fn() } }, } @@ -38,9 +41,6 @@ describe('DonationsController', () => { cancelUrl: 'http://test.com', isAnonymous: true, } as CreateSessionDto - const vaultMock = { - incrementVaultAmount: jest.fn(), - } const mockDonation = { id: '123', @@ -78,10 +78,7 @@ describe('DonationsController', () => { }, CampaignService, DonationsService, - { - provide: VaultService, - useValue: vaultMock, - }, + VaultService, MockPrismaService, { provide: STRIPE_CLIENT_TOKEN, @@ -94,6 +91,7 @@ describe('DonationsController', () => { }).compile() controller = module.get(DonationsController) + vaultService = module.get(VaultService) }) afterEach(() => { @@ -196,6 +194,11 @@ describe('DonationsController', () => { picture: 'string', } + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) + const mockedIncrementVaultAmount = jest + .spyOn(vaultService, 'incrementVaultAmount') + .mockImplementation() + prismaMock.donation.findFirst.mockResolvedValueOnce(existingDonation) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) @@ -211,7 +214,7 @@ describe('DonationsController', () => { updatedAt: existingDonation.updatedAt, }, }) - expect(vaultMock.incrementVaultAmount).toHaveBeenCalledTimes(0) + expect(mockedIncrementVaultAmount).toHaveBeenCalledTimes(0) }) it('should update a donation status, when it is changed', async () => { @@ -245,9 +248,12 @@ describe('DonationsController', () => { const existingDonation = { ...mockDonation, status: DonationStatus.initial } const expectedUpdatedDonation = { ...existingDonation, status: DonationStatus.succeeded } + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) + prismaMock.donation.findFirst.mockResolvedValueOnce(existingDonation) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) prismaMock.donation.update.mockResolvedValueOnce(expectedUpdatedDonation) + prismaMock.vault.update.mockResolvedValueOnce({ id: '1000', campaignId: '111' } as Vault) // act await controller.update('123', updatePaymentDto) @@ -262,9 +268,13 @@ describe('DonationsController', () => { updatedAt: expectedUpdatedDonation.updatedAt, }, }) - expect(vaultMock.incrementVaultAmount).toHaveBeenCalledWith( - existingDonation.targetVaultId, - existingDonation.amount, - ) + expect(prismaMock.vault.update).toHaveBeenCalledWith({ + where: { id: existingDonation.targetVaultId }, + data: { + amount: { + increment: existingDonation.amount, + }, + }, + }) }) }) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index 24c026bd9..1d153de38 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -22,7 +22,6 @@ import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types import { isAdmin, KeycloakTokenParsed } from '../auth/keycloak' import { DonationsService } from './donations.service' import { CreateSessionDto } from './dto/create-session.dto' -import { CreatePaymentDto } from './dto/create-payment.dto' import { UpdatePaymentDto } from './dto/update-payment.dto' import { CreateBankPaymentDto } from './dto/create-bank-payment.dto' import { UpdatePaymentIntentDto } from './dto/update-payment-intent.dto' @@ -191,24 +190,6 @@ export class DonationsController { } } - @Post('create-payment') - @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], - mode: RoleMatchingMode.ANY, - }) - create( - @AuthenticatedUser() - user: KeycloakTokenParsed, - @Body() - createPaymentDto: CreatePaymentDto, - ) { - if (!user) { - throw new UnauthorizedException() - } - - return this.donationsService.create(createPaymentDto, user) - } - @Post('payment-intent') @Public() createPaymentIntent( diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 076260ded..33ac3c344 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -13,14 +13,12 @@ import { import { Response } from 'express' import { getTemplateByTable } from '../export/helpers/exportableData' -import { KeycloakTokenParsed } from '../auth/keycloak' 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 { CreatePaymentDto } from './dto/create-payment.dto' import { CreateSessionDto } from './dto/create-session.dto' import { UpdatePaymentDto } from './dto/update-payment.dto' import { Person } from '../person/entities/person.entity' @@ -201,8 +199,6 @@ export class DonationsService { tax_id_collection: { enabled: true }, } - Logger.debug('[ CreateCheckoutSession ]', createSessionRequest) - const sessionResponse = await this.stripeClient.checkout.sessions .create(createSessionRequest) .then( @@ -459,22 +455,6 @@ export class DonationsService { }) } - /** Describe the function below - * @param inputDto - * @param user - * @returns {Promise} - * - */ - async create(inputDto: CreatePaymentDto, user: KeycloakTokenParsed): Promise { - const donation = await this.prisma.donation.create({ data: inputDto.toEntity(user) }) - - if (donation.status === DonationStatus.succeeded) { - await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount) - } - - return donation - } - /** * Create a payment intent for a donation * @param inputDto Payment intent create params @@ -565,69 +545,75 @@ export class DonationsService { */ async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { try { - const currentDonation = await this.prisma.donation.findFirst({ - where: { id }, - }) - if (!currentDonation) { - throw new NotFoundException(`Update failed. No donation found with ID: ${id}`) - } + // execute the below in prisma transaction + return await this.prisma.$transaction(async (tx) => { + const currentDonation = await tx.donation.findFirst({ + where: { id }, + }) + if (!currentDonation) { + throw new NotFoundException(`Update failed. No donation found with ID: ${id}`) + } - if ( - currentDonation.status === DonationStatus.succeeded && - updatePaymentDto.status && - updatePaymentDto.status !== DonationStatus.succeeded - ) { - throw new BadRequestException('Succeeded donations cannot be updated.') - } + if ( + currentDonation.status === DonationStatus.succeeded && + updatePaymentDto.status && + updatePaymentDto.status !== DonationStatus.succeeded + ) { + throw new BadRequestException('Succeeded donations cannot be updated.') + } + + const status = updatePaymentDto.status || currentDonation.status + let donorId = currentDonation.personId + let billingEmail = '' + if ( + (updatePaymentDto.targetPersonId && + currentDonation.personId !== updatePaymentDto.targetPersonId) || + updatePaymentDto.billingEmail + ) { + const targetDonor = await tx.person.findFirst({ + where: { + OR: [ + { id: updatePaymentDto.targetPersonId }, + { email: updatePaymentDto.billingEmail }, + ], + }, + }) + if (!targetDonor) { + throw new NotFoundException( + `Update failed. No person found with ID: ${updatePaymentDto.targetPersonId}`, + ) + } + donorId = targetDonor.id + billingEmail = targetDonor.email + } - const status = updatePaymentDto.status || currentDonation.status - let donorId = currentDonation.personId - let billingEmail = '' - if ( - (updatePaymentDto.targetPersonId && - currentDonation.personId !== updatePaymentDto.targetPersonId) || - updatePaymentDto.billingEmail - ) { - const targetDonor = await this.prisma.person.findFirst({ - where: { - OR: [{ id: updatePaymentDto.targetPersonId }, { email: updatePaymentDto.billingEmail }], + const donation = await tx.donation.update({ + where: { id }, + data: { + status: status, + personId: updatePaymentDto.targetPersonId ? donorId : undefined, + billingEmail: updatePaymentDto.billingEmail ? billingEmail : undefined, + //In case of personId or billingEmail change, take the last updatedAt property to prevent any changes to updatedAt property + updatedAt: + updatePaymentDto.targetPersonId || updatePaymentDto.billingEmail + ? currentDonation.updatedAt + : undefined, }, }) - if (!targetDonor) { - throw new NotFoundException( - `Update failed. No person found with ID: ${updatePaymentDto.targetPersonId}`, + + if ( + currentDonation.status !== DonationStatus.succeeded && + updatePaymentDto.status === DonationStatus.succeeded && + donation.status === DonationStatus.succeeded + ) { + await this.vaultService.incrementVaultAmount( + currentDonation.targetVaultId, + currentDonation.amount, + tx, ) } - donorId = targetDonor.id - billingEmail = targetDonor.email - } - - const donation = await this.prisma.donation.update({ - where: { id }, - data: { - status: status, - personId: updatePaymentDto.targetPersonId ? donorId : undefined, - billingEmail: updatePaymentDto.billingEmail ? billingEmail : undefined, - //In case of personId or billingEmail change, take the last updatedAt property to prevent any changes to updatedAt property - updatedAt: - updatePaymentDto.targetPersonId || updatePaymentDto.billingEmail - ? currentDonation.updatedAt - : undefined, - }, - }) - - if ( - currentDonation.status !== DonationStatus.succeeded && - updatePaymentDto.status === DonationStatus.succeeded && - donation.status === DonationStatus.succeeded - ) { - await this.vaultService.incrementVaultAmount( - currentDonation.targetVaultId, - currentDonation.amount, - ) - } - - return donation + return donation + }) //end of transaction } catch (err) { Logger.warn(err.message || err) throw err diff --git a/apps/api/src/donations/events/stripe-payment.service.spec.ts b/apps/api/src/donations/events/stripe-payment.service.spec.ts index 52679882f..8586aade3 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -11,7 +11,7 @@ import { INestApplication } from '@nestjs/common' import request from 'supertest' import { StripeModule, StripeModuleConfig, StripePayloadService } from '@golevelup/nestjs-stripe' -import { DonationType, RecurringDonationStatus } from '@prisma/client' +import { Donation, DonationType, RecurringDonationStatus } from '@prisma/client' import { campaignId, @@ -127,7 +127,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventCreated.data.object as Stripe.PaymentIntent) - const mockedupdateDonationPayment = jest + const mockedUpdateDonationPayment = jest .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -145,7 +145,7 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedcreateDonationWish).not.toHaveBeenCalled() - expect(mockedupdateDonationPayment).toHaveBeenCalledWith( + expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, DonationStatus.waiting, @@ -170,7 +170,7 @@ describe('StripePaymentService', () => { mockPaymentEventCancelled.data.object as Stripe.PaymentIntent, ) - const mockedupdateDonationPayment = jest + const mockedUpdateDonationPayment = jest .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -183,7 +183,7 @@ describe('StripePaymentService', () => { .expect(201) .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event - expect(mockedupdateDonationPayment).toHaveBeenCalledWith( + expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, DonationStatus.cancelled, @@ -203,6 +203,8 @@ describe('StripePaymentService', () => { }) const campaignService = app.get(CampaignService) + const vaultService = app.get(VaultService) + const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -235,9 +237,14 @@ describe('StripePaymentService', () => { personId: 'donation-person', }) - const mockedDonateToCampaign = jest - .spyOn(campaignService, 'donateToCampaign') - .mockName('donateToCampaign') + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) + const mockedUpdateDonationPayment = jest + .spyOn(campaignService, 'updateDonationPayment') + .mockName('updateDonationPayment') + + const mockedIncrementVaultAmount = jest + .spyOn(vaultService, 'incrementVaultAmount') + .mockImplementation() return request(app.getHttpServer()) .post(defaultStripeWebhookEndpoint) @@ -247,9 +254,9 @@ describe('StripePaymentService', () => { .expect(201) .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event - expect(mockedDonateToCampaign).toHaveBeenCalled() - // expect(mockedupdateDonationPayment).toHaveBeenCalled() + expect(mockedUpdateDonationPayment).toHaveBeenCalled() expect(prismaMock.donation.create).toHaveBeenCalled() + expect(mockedIncrementVaultAmount).toHaveBeenCalled() expect(prismaMock.donation.update).toHaveBeenCalledWith({ where: { id: 'test-donation-id' }, data: { @@ -276,6 +283,8 @@ describe('StripePaymentService', () => { }) const campaignService = app.get(CampaignService) + const vaultService = app.get(VaultService) + const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -284,6 +293,15 @@ describe('StripePaymentService', () => { mockChargeEventSucceeded.data.object as Stripe.Charge, ) + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) + const mockedUpdateDonationPayment = jest + .spyOn(campaignService, 'updateDonationPayment') + .mockName('updateDonationPayment') + + const mockedIncrementVaultAmount = jest + .spyOn(vaultService, 'incrementVaultAmount') + .mockImplementation() + const mockedcreateDonationWish = jest .spyOn(campaignService, 'createDonationWish') .mockName('createDonationWish') @@ -308,10 +326,6 @@ describe('StripePaymentService', () => { personId: 'donation-person', }) - const mockedDonateToCampaign = jest - .spyOn(campaignService, 'donateToCampaign') - .mockName('donateToCampaign') - return request(app.getHttpServer()) .post(defaultStripeWebhookEndpoint) .set('stripe-signature', header) @@ -320,10 +334,10 @@ describe('StripePaymentService', () => { .expect(201) .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event - expect(mockedDonateToCampaign).toHaveBeenCalled() - // expect(mockedupdateDonationPayment).toHaveBeenCalled() + expect(mockedUpdateDonationPayment).toHaveBeenCalled() expect(prismaMock.donation.create).toHaveBeenCalled() expect(prismaMock.donation.update).not.toHaveBeenCalled() + expect(mockedIncrementVaultAmount).toHaveBeenCalled() expect(mockedcreateDonationWish).toHaveBeenCalled() }) }) @@ -411,19 +425,35 @@ describe('StripePaymentService', () => { }) const campaignService = app.get(CampaignService) + const vaultService = app.get(VaultService) + const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) - const mockedDonateToCampaign = jest - .spyOn(campaignService, 'donateToCampaign') - .mockImplementation(() => Promise.resolve()) + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - const mockedupdateDonationPayment = jest + const mockedUpdateDonationPayment = jest .spyOn(campaignService, 'updateDonationPayment') - .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') + prismaMock.donation.findFirst.mockResolvedValue({ + targetVaultId: '1', + amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, + status: 'initial', + } as Donation) + + prismaMock.donation.update.mockResolvedValue({ + targetVaultId: '1', + amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, + status: 'initial', + person: {}, + } as Donation & { person: unknown }) + + const mockedIncrementVaultAmount = jest + .spyOn(vaultService, 'incrementVaultAmount') + .mockImplementation() + return request(app.getHttpServer()) .post(defaultStripeWebhookEndpoint) .set('stripe-signature', header) @@ -435,8 +465,8 @@ describe('StripePaymentService', () => { (mockCustomerSubscriptionCreated.data.object as Stripe.SubscriptionItem).metadata .campaignId, ) //campaignId from the Stripe Event - expect(mockedDonateToCampaign).toHaveBeenCalled() - expect(mockedupdateDonationPayment).toHaveBeenCalled() + expect(mockedUpdateDonationPayment).toHaveBeenCalled() + expect(mockedIncrementVaultAmount).toHaveBeenCalled() }) }) @@ -459,11 +489,7 @@ describe('StripePaymentService', () => { .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaignCompeleted)) - const mockedDonateToCampaign = jest - .spyOn(campaignService, 'donateToCampaign') - .mockImplementation(() => Promise.resolve()) - - const mockedupdateDonationPayment = jest + const mockedUpdateDonationPayment = jest .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -483,8 +509,7 @@ describe('StripePaymentService', () => { (mockCustomerSubscriptionCreated.data.object as Stripe.SubscriptionItem).metadata .campaignId, ) //campaignId from the Stripe Event - expect(mockedDonateToCampaign).toHaveBeenCalled() - expect(mockedupdateDonationPayment).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/donations/events/stripe-payment.service.ts index 7f41ce4f4..3a214299b 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -117,9 +117,11 @@ export class StripePaymentService { DonationStatus.succeeded, metadata, ) - await this.campaignService.donateToCampaign(campaign, billingData) + //updateDonationPayment will mark the campaign as completed if amount is reached await this.checkForCompletedCampaign(metadata.campaignId) - if (metadata?.wish) { + + //and finally save the donation wish + if (donationId && metadata?.wish) { await this.campaignService.createDonationWish(metadata.wish, donationId, campaign.id) } } @@ -302,7 +304,8 @@ export class StripePaymentService { paymentData, DonationStatus.succeeded, ) - await this.campaignService.donateToCampaign(campaign, paymentData) + + //updateDonationPayment will mark the campaign as completed if amount is reached await this.checkForCompletedCampaign(metadata.campaignId) } diff --git a/apps/api/src/sockets/notifications/notification.service.ts b/apps/api/src/sockets/notifications/notification.service.ts index 1ae0348ef..1eaea26ef 100644 --- a/apps/api/src/sockets/notifications/notification.service.ts +++ b/apps/api/src/sockets/notifications/notification.service.ts @@ -8,6 +8,7 @@ export const donationNotificationSelect = { amount: true, createdAt: true, extPaymentMethodId: true, + targetVaultId: true, person: { select: { firstName: true, diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index ee0287722..1e03986a5 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -110,13 +110,12 @@ export class VaultService { } /** - * Increment vault amount - * TODO: Vault amount increment to happen in transacton only + * Increment vault amount as part of donation in prisma transaction */ public async incrementVaultAmount( vaultId: string, amount: number, - tx?: Prisma.TransactionClient, + tx: Prisma.TransactionClient, ): Promise { if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') @@ -131,12 +130,8 @@ export class VaultService { }, } - //TODO: here we should only use the transaction mode - const vault = tx - ? await tx.vault.update(updateStatement) - : await this.prisma.vault.update(updateStatement) - - await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId) + const vault = await tx.vault.update(updateStatement) + await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx) return vault }