From 7e37d19780b42c3ba9aeeceecf08524e537e03dd Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Thu, 29 Feb 2024 14:24:25 +0200 Subject: [PATCH] src/vault: Add possibility to decrement many vaults as well In future situations, we might need to decrement many vaults at once as well,(e.g. payment refund etc..) - Added some unit tests to cover vaults updates -Did some refactoring on commonly used types, as well as test mocks --- .../affiliate/affiliate.controller.spec.ts | 64 +++++++-------- apps/api/src/affiliate/types/affiliate.ts | 9 +++ .../bank-transactions.service.ts | 2 +- .../src/campaign/campaign.controller.spec.ts | 38 +++------ apps/api/src/campaign/campaign.service.ts | 16 ++-- .../src/donations/__mocks__/paymentMock.ts | 57 +++++++++++++ apps/api/src/donations/donations.service.ts | 30 ++----- apps/api/src/donations/types/donation.ts | 4 + .../notifications.interface.providers.ts | 4 +- apps/api/src/person/__mock__/peronMock.ts | 21 +++++ .../{mock => __mock__}/person.service.mock.ts | 0 .../bank-import/import-transactions.task.ts | 13 +-- apps/api/src/vault/__mocks__/vault.ts | 13 +++ apps/api/src/vault/types/vault.ts | 11 +++ apps/api/src/vault/vault.controller.spec.ts | 4 +- apps/api/src/vault/vault.service.spec.ts | 52 +++++++++++- apps/api/src/vault/vault.service.ts | 79 ++++++++++++++----- 17 files changed, 288 insertions(+), 129 deletions(-) create mode 100644 apps/api/src/affiliate/types/affiliate.ts create mode 100644 apps/api/src/donations/__mocks__/paymentMock.ts create mode 100644 apps/api/src/donations/types/donation.ts create mode 100644 apps/api/src/person/__mock__/peronMock.ts rename apps/api/src/person/{mock => __mock__}/person.service.mock.ts (100%) create mode 100644 apps/api/src/vault/__mocks__/vault.ts create mode 100644 apps/api/src/vault/types/vault.ts diff --git a/apps/api/src/affiliate/affiliate.controller.spec.ts b/apps/api/src/affiliate/affiliate.controller.spec.ts index 62a523ef5..55316dd9e 100644 --- a/apps/api/src/affiliate/affiliate.controller.spec.ts +++ b/apps/api/src/affiliate/affiliate.controller.spec.ts @@ -11,16 +11,26 @@ import { VaultService } from '../vault/vault.service' import { NotificationModule } from '../sockets/notifications/notification.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' import { ExportService } from '../export/export.service' -import { Affiliate, Campaign, CampaignState, Donation, Prisma, Vault } from '@prisma/client' +import { + Affiliate, + AffiliateStatus, + Campaign, + CampaignState, + PaymentStatus, + Payments, + Prisma, + Vault, +} from '@prisma/client' import { KeycloakTokenParsed } from '../auth/keycloak' import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common' import { AffiliateStatusUpdateDto } from './dto/affiliate-status-update.dto' import * as afCodeGenerator from './utils/affiliateCodeGenerator' import { CreateAffiliateDonationDto } from './dto/create-affiliate-donation.dto' +import { mockPayment } from '../donations/__mocks__/paymentMock' type PersonWithPayload = Prisma.PersonGetPayload<{ include: { company: true } }> type AffiliateWithPayload = Prisma.AffiliateGetPayload<{ - include: { company: { include: { person: true } }; donations: true } + include: { company: { include: { person: true } }; payments: true } }> describe('AffiliateController', () => { @@ -126,28 +136,10 @@ describe('AffiliateController', () => { countryCode: null, person: { ...mockIndividualProfile }, }, - donations: [], + payments: [], } - const donationResponseMock: Donation = { - id: 'donation-id', - type: 'donation', - status: 'guaranteed', - amount: 5000, - affiliateId: activeAffiliateMock.id, - personId: null, - extCustomerId: '', - extPaymentIntentId: '123456', - extPaymentMethodId: '1234', - billingEmail: 'test@podkrepi.bg', - billingName: 'John Doe', - targetVaultId: vaultMock.id, - chargedAmount: 0, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - provider: 'bank', - } + const mockGuaranteedPayment = { ...mockPayment, status: PaymentStatus.guaranteed } const userMock = { sub: 'testKeycloackId', @@ -232,13 +224,13 @@ describe('AffiliateController', () => { const activeAffiliateMock: Affiliate = { ...affiliateMock, - status: 'active', + status: AffiliateStatus.active, id: '12345', affiliateCode: affiliateCodeMock, } const mockCancelledStatus: AffiliateStatusUpdateDto = { - newStatus: 'cancelled', + newStatus: AffiliateStatus.cancelled, } jest.spyOn(service, 'findOneById').mockResolvedValue(activeAffiliateMock) @@ -265,7 +257,7 @@ describe('AffiliateController', () => { jest.spyOn(service, 'findOneById').mockResolvedValue(activeAffiliateMock) const updateStatusDto: AffiliateStatusUpdateDto = { - newStatus: 'active', + newStatus: AffiliateStatus.active, } const codeGenerationSpy = jest .spyOn(afCodeGenerator, 'affiliateCodeGenerator') @@ -305,7 +297,7 @@ describe('AffiliateController', () => { jest.spyOn(service, 'findOneByCode').mockResolvedValue(activeAffiliateMock) const createAffiliateDonationSpy = jest .spyOn(donationService, 'createAffiliateDonation') - .mockResolvedValue(donationResponseMock) + .mockResolvedValue(mockGuaranteedPayment) jest.spyOn(prismaMock.vault, 'findMany').mockResolvedValue([vaultMock]) prismaMock.campaign.findFirst.mockResolvedValue({ id: '123', @@ -318,26 +310,26 @@ describe('AffiliateController', () => { affiliateId: activeAffiliateMock.id, }) expect(await donationService.createAffiliateDonation(affiliateDonationDto)).toEqual( - donationResponseMock, + mockGuaranteedPayment, ) }) it('should cancel', async () => { - const cancelledDonationResponse: Donation = { - ...donationResponseMock, - status: 'cancelled', + const cancelledDonationResponse: Payments = { + ...mockGuaranteedPayment, + status: PaymentStatus.cancelled, } jest .spyOn(donationService, 'getAffiliateDonationById') - .mockResolvedValue(donationResponseMock) + .mockResolvedValue(mockGuaranteedPayment) jest.spyOn(donationService, 'update').mockResolvedValue(cancelledDonationResponse) expect( - await controller.cancelAffiliateDonation(affiliateCodeMock, donationResponseMock.id), + await controller.cancelAffiliateDonation(affiliateCodeMock, mockGuaranteedPayment.id), ).toEqual(cancelledDonationResponse) }) it('should throw error if donation status is succeeded', async () => { - const succeededDonationResponse: Donation = { - ...donationResponseMock, - status: 'succeeded', + const succeededDonationResponse: Payments = { + ...mockGuaranteedPayment, + status: PaymentStatus.succeeded, } jest @@ -345,7 +337,7 @@ describe('AffiliateController', () => { .mockResolvedValue(succeededDonationResponse) const updateDonationStatus = jest.spyOn(donationService, 'update') expect( - controller.cancelAffiliateDonation(affiliateCodeMock, donationResponseMock.id), + controller.cancelAffiliateDonation(affiliateCodeMock, mockGuaranteedPayment.id), ).rejects.toThrow(new BadRequestException("Donation status can't be updated")) expect(updateDonationStatus).not.toHaveBeenCalled() }) diff --git a/apps/api/src/affiliate/types/affiliate.ts b/apps/api/src/affiliate/types/affiliate.ts new file mode 100644 index 000000000..2bfd9cc76 --- /dev/null +++ b/apps/api/src/affiliate/types/affiliate.ts @@ -0,0 +1,9 @@ +import { Prisma } from '@prisma/client' + +export type AffiliateWithDonation = Prisma.AffiliateGetPayload<{ + include: { payments: { include: { donations: true } } } +}> + +export type AffiliateWithCompanyPayload = Prisma.AffiliateGetPayload<{ + include: { company: { include: { person: true } }; donations: true } +}> diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index 3b993b45d..3ee0f237d 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -165,7 +165,7 @@ export class BankTransactionsService { matchedRef: newPaymentRef, }, }) - console.log(`called`) + // Import Donation await this.donationService.createUpdateBankPayment(bankPayment) }) diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index a8bdb2427..ffe30946e 100644 --- a/apps/api/src/campaign/campaign.controller.spec.ts +++ b/apps/api/src/campaign/campaign.controller.spec.ts @@ -23,18 +23,19 @@ import { MarketingNotificationsService } from '../notifications/notifications.se import { EmailService } from '../email/email.service' import { TemplateService } from '../email/template.service' import { CampaignNewsService } from '../campaign-news/campaign-news.service' +import { personMock } from '../person/__mock__/peronMock' describe('CampaignController', () => { let controller: CampaignController let prismaService: PrismaService let campaignService: CampaignService - let marketingProvider: NotificationsProviderInterface + let marketingProvider: NotificationsProviderInterface let marketingService: MarketingNotificationsService const personServiceMock = { findOneByKeycloakId: jest.fn(() => { return { id: personIdMock } }), - findByEmail: jest.fn(async () => { + findByEmail: jest.fn(async (): Promise => { return person }), update: jest.fn(async () => { @@ -51,24 +52,7 @@ describe('CampaignController', () => { }), } - const person: Person | null = { - id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', - firstName: 'John', - lastName: 'Doe', - keycloakId: 'some-id', - email: 'user@email.com', - emailConfirmed: false, - companyId: null, - phone: null, - picture: null, - createdAt: new Date('2021-10-07T13:38:11.097Z'), - updatedAt: new Date('2021-10-07T13:38:11.097Z'), - newsletter: true, - address: null, - birthday: null, - personalNumber: null, - stripeCustomerId: null, - } + const person: Person = personMock const mockCreateCampaign = { slug: 'test-slug', @@ -428,7 +412,7 @@ describe('CampaignController', () => { describe('subscribeToCampaignNotifications', () => { it('should throw if no consent is provided', async () => { const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, consent: false, } @@ -439,7 +423,7 @@ describe('CampaignController', () => { it('should throw if the campaign is not active', async () => { const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, consent: true, } @@ -458,7 +442,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } @@ -497,7 +481,7 @@ describe('CampaignController', () => { jest.spyOn(campaignService, 'createCampaignNotificationList') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } @@ -538,7 +522,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } @@ -566,7 +550,7 @@ describe('CampaignController', () => { expect(marketingService.sendConfirmEmail).not.toHaveBeenCalled() }) - it('should create a saparate notification consent record for non-registered emails', async () => { + it('should create a separate notification consent record for non-registered emails', async () => { jest.spyOn(marketingProvider, 'addContactsToList').mockImplementation(async () => '') jest.spyOn(campaignService, 'createCampaignNotificationList') jest.spyOn(marketingService, 'sendConfirmEmail') @@ -577,7 +561,7 @@ describe('CampaignController', () => { }) const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 20dd2be66..538391c30 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -624,15 +624,15 @@ export class CampaignService { private async updateDonationIfAllowed( tx: Prisma.TransactionClient, - donation: Prisma.PaymentsGetPayload<{ include: { donations: true } }>, + payment: Prisma.PaymentsGetPayload<{ include: { donations: true } }>, newDonationStatus: PaymentStatus, paymentData: PaymentData, ) { - if (shouldAllowStatusChange(donation.status, newDonationStatus)) { + if (shouldAllowStatusChange(payment.status, newDonationStatus)) { try { const updatedDonation = await tx.payments.update({ where: { - id: donation.id, + id: payment.id, }, data: { status: newDonationStatus, @@ -648,11 +648,11 @@ export class CampaignService { //if donation is switching to successful, increment the vault amount and send notification if ( - donation.status != PaymentStatus.succeeded && + payment.status != PaymentStatus.succeeded && newDonationStatus === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - donation.donations[0].targetVaultId, + payment.donations[0].targetVaultId, paymentData.netAmount, tx, ) @@ -661,11 +661,11 @@ export class CampaignService { person: updatedDonation.donations[0].person, }) } else if ( - donation.status === PaymentStatus.succeeded && + payment.status === PaymentStatus.succeeded && newDonationStatus === PaymentStatus.refund ) { await this.vaultService.decrementVaultAmount( - donation.donations[0].targetVaultId, + payment.donations[0].targetVaultId, paymentData.netAmount, tx, ) @@ -686,7 +686,7 @@ export class CampaignService { else { Logger.warn( `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} - and status: ${newDonationStatus} because the event comes after existing donation with status: ${donation.status}`, + and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, ) } } diff --git a/apps/api/src/donations/__mocks__/paymentMock.ts b/apps/api/src/donations/__mocks__/paymentMock.ts new file mode 100644 index 000000000..f3a5d80fd --- /dev/null +++ b/apps/api/src/donations/__mocks__/paymentMock.ts @@ -0,0 +1,57 @@ +import { Currency, DonationType, PaymentProvider, PaymentStatus } from '@prisma/client' +import { PaymentWithDonation } from '../types/donation' +import { DonationWithPerson } from '../types/donation' +import { personMock } from '../../person/__mock__/peronMock' + +export const mockDonation: DonationWithPerson = { + id: '1234', + paymentId: '123', + type: DonationType.donation, + amount: 10, + targetVaultId: 'vault-1', + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-02'), + personId: '1', + person: personMock, +} + +//Mock donation to different vault +const mockDonationWithDiffVaultId: DonationWithPerson = { + ...mockDonation, + targetVaultId: 'vault-2', +} + +//Mock donation to same vault as mockDonation, but different amount +const mockDonationWithDiffAmount: DonationWithPerson = { ...mockDonation, amount: 50 } + +export const mockPayment: PaymentWithDonation = { + id: '123', + provider: PaymentProvider.bank, + currency: Currency.BGN, + type: 'single', + status: PaymentStatus.initial, + amount: 10, + affiliateId: null, + extCustomerId: 'hahaha', + extPaymentIntentId: 'pm1', + extPaymentMethodId: 'bank', + billingEmail: 'test@podkrepi.bg', + billingName: 'Test', + chargedAmount: 10.5, + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-02'), + donations: [mockDonation, mockDonationWithDiffVaultId, mockDonationWithDiffAmount], +} + +export const mockSucceededPayment: PaymentWithDonation = { + ...mockPayment, + status: PaymentStatus.succeeded, +} +export const mockGuaranteedPayment: PaymentWithDonation = { + ...mockPayment, + status: PaymentStatus.guaranteed, +} +export const mockCancelledPayment: PaymentWithDonation = { + ...mockPayment, + status: PaymentStatus.cancelled, +} diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 2483c810a..9f6bf5074 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -34,28 +34,8 @@ import { CreateStripePaymentDto } from './dto/create-stripe-payment.dto' import { ImportStatus } from '../bank-transactions-file/dto/bank-transactions-import-status.dto' import { DonationQueryDto } from '../common/dto/donation-query-dto' import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-donation.dto' - -type PaymentWithDonation = Prisma.PaymentsGetPayload<{ - select: { - donations: { - select: { - metadata: { select: { name: true } } - person: { - select: { - firstName: true - lastName: true - company: { select: { companyName: true } } - } - } - } - } - } -}> - -type TPaymentWithDonations = Prisma.PaymentsGetPayload<{ include: { donations: true } }> -type VaultUpdate = { - [key: string]: number -} +import { VaultUpdate } from '../vault/types/vault' +import { PaymentWithDonation } from './types/donation' @Injectable() export class DonationsService { @@ -496,7 +476,7 @@ export class DonationsService { * @returns {Promise} Donation * @throws NotFoundException if no donation is found */ - async getDonationById(id: string): Promise { + async getDonationById(id: string): Promise { try { const donation = await this.prisma.payments.findFirstOrThrow({ where: { id }, @@ -663,10 +643,10 @@ export class DonationsService { }) } - async updateAffiliateBankPayment(paymentsIds: string[], donationDto: VaultUpdate) { + async updateAffiliateBankPayment(paymentsIds: string[], listOfVaults: VaultUpdate) { return await this.prisma.$transaction(async (tx) => { await Promise.all([ - this.vaultService.updateManyVaultsAmount(donationDto, tx, 'increment'), + this.vaultService.IncrementManyVaults(listOfVaults, tx), tx.payments.updateMany({ where: { id: { in: paymentsIds } }, data: { status: PaymentStatus.succeeded }, diff --git a/apps/api/src/donations/types/donation.ts b/apps/api/src/donations/types/donation.ts new file mode 100644 index 000000000..9c2b75ca6 --- /dev/null +++ b/apps/api/src/donations/types/donation.ts @@ -0,0 +1,4 @@ +import { Prisma } from '@prisma/client' + +export type PaymentWithDonation = Prisma.PaymentsGetPayload<{ include: { donations: true } }> +export type DonationWithPerson = Prisma.DonationGetPayload<{ include: { person: true } }> diff --git a/apps/api/src/notifications/providers/notifications.interface.providers.ts b/apps/api/src/notifications/providers/notifications.interface.providers.ts index 3f17be686..47d4e5a34 100644 --- a/apps/api/src/notifications/providers/notifications.interface.providers.ts +++ b/apps/api/src/notifications/providers/notifications.interface.providers.ts @@ -21,7 +21,9 @@ type NotificationsInterfaceParams = { SendNotificationRes: unknown } -export abstract class NotificationsProviderInterface { +export abstract class NotificationsProviderInterface< + T extends NotificationsInterfaceParams = NotificationsInterfaceParams, +> { abstract createNewContactList(data: T['CreateListParams']): Promise abstract updateContactList(data: T['UpdateListParams']): Promise abstract deleteContactList(data: T['DeleteListParams']): Promise diff --git a/apps/api/src/person/__mock__/peronMock.ts b/apps/api/src/person/__mock__/peronMock.ts new file mode 100644 index 000000000..21b8f148c --- /dev/null +++ b/apps/api/src/person/__mock__/peronMock.ts @@ -0,0 +1,21 @@ +import { Person } from '@prisma/client' + +export const personMock: Person = { + id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', + firstName: 'John', + lastName: 'Doe', + keycloakId: 'some-id', + email: 'user@email.com', + emailConfirmed: false, + companyId: null, + phone: null, + picture: null, + createdAt: new Date('2021-10-07T13:38:11.097Z'), + updatedAt: new Date('2021-10-07T13:38:11.097Z'), + newsletter: true, + address: null, + birthday: null, + personalNumber: null, + stripeCustomerId: null, + profileEnabled: false, +} diff --git a/apps/api/src/person/mock/person.service.mock.ts b/apps/api/src/person/__mock__/person.service.mock.ts similarity index 100% rename from apps/api/src/person/mock/person.service.mock.ts rename to apps/api/src/person/__mock__/person.service.mock.ts diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index ae9e19e2e..0308b9ebe 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -5,12 +5,10 @@ import { SchedulerRegistry } from '@nestjs/schedule' import { BankDonationStatus, Currency, - Donation, DonationType, PaymentProvider, PaymentStatus, PaymentType, - Payments, Prisma, Vault, } from '@prisma/client' @@ -37,14 +35,12 @@ import { import { ImportStatus } from '../../bank-transactions-file/dto/bank-transactions-import-status.dto' import { IrisIbanAccountInfoDto } from '../../bank-transactions/dto/iris-bank-account-info.dto' import { IrisTransactionInfoDto } from '../../bank-transactions/dto/iris-bank-transaction-info.dto' +import { VaultUpdate } from '../../vault/types/vault' type filteredTransaction = Prisma.BankTransactionCreateManyInput type AffiliatePayload = Prisma.AffiliateGetPayload<{ include: { payments: { include: { donations: true } } } }> -type VaultUpdate = { - [key: string]: number -} @Injectable() export class IrisTasks { @@ -487,11 +483,8 @@ export class IrisTasks { if (trx.amount - totalDonated < payment.amount) continue paymentIdsToUpdate.push(payment.id) for (const donation of payment.donations) { - if (vaultsToUpdate.hasOwnProperty(donation.targetVaultId)) { - vaultsToUpdate[donation.targetVaultId] += donation.amount - } else { - vaultsToUpdate[donation.targetVaultId] = donation.amount - } + vaultsToUpdate[donation.targetVaultId] = + (vaultsToUpdate[donation.targetVaultId] || 0) + donation.amount } totalDonated += payment.amount updatedPayments++ diff --git a/apps/api/src/vault/__mocks__/vault.ts b/apps/api/src/vault/__mocks__/vault.ts new file mode 100644 index 000000000..605b51858 --- /dev/null +++ b/apps/api/src/vault/__mocks__/vault.ts @@ -0,0 +1,13 @@ +import { Vault } from '@prisma/client' +import { randomUUID } from 'crypto' + +export const mockVault: Vault = { + id: randomUUID(), + currency: 'BGN', + createdAt: new Date(), + updatedAt: new Date(), + amount: 100, + blockedAmount: 0, + campaignId: 'campaign-id', + name: 'Test vault', +} diff --git a/apps/api/src/vault/types/vault.ts b/apps/api/src/vault/types/vault.ts new file mode 100644 index 000000000..6c4c88368 --- /dev/null +++ b/apps/api/src/vault/types/vault.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client' + +export type VaultUpdate = { + [key: string]: number +} + +export type VaultWithWithdrawalSum = Prisma.VaultGetPayload<{ + include: { campaign: { select: { id: true; title: true } } } +}> & { + withdrawnAmount: number +} diff --git a/apps/api/src/vault/vault.controller.spec.ts b/apps/api/src/vault/vault.controller.spec.ts index a9685890f..c83e7bb7f 100644 --- a/apps/api/src/vault/vault.controller.spec.ts +++ b/apps/api/src/vault/vault.controller.spec.ts @@ -2,7 +2,7 @@ import { ConfigService } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' import { UnauthorizedException } from '@nestjs/common' import { CampaignService } from '../campaign/campaign.service' -import { personServiceMock, PersonServiceMock } from '../person/mock/person.service.mock' +import { personServiceMock, PersonServiceMock } from '../person/__mock__/person.service.mock' import { VaultController } from './vault.controller' import { VaultService } from './vault.service' import { KeycloakTokenParsed } from '../auth/keycloak' @@ -14,6 +14,8 @@ 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 { mockVault } from './__mocks__/vault' describe('VaultController', () => { let controller: VaultController diff --git a/apps/api/src/vault/vault.service.spec.ts b/apps/api/src/vault/vault.service.spec.ts index d7dd49db4..aa0a8ac50 100644 --- a/apps/api/src/vault/vault.service.spec.ts +++ b/apps/api/src/vault/vault.service.spec.ts @@ -3,10 +3,15 @@ import { Test, TestingModule } from '@nestjs/testing' import { CampaignModule } from '../campaign/campaign.module' import { CampaignService } from '../campaign/campaign.service' import { PersonService } from '../person/person.service' -import { MockPrismaService } from '../prisma/prisma-client.mock' +import { MockPrismaService, prismaMock } from '../prisma/prisma-client.mock' import { NotificationModule } from '../sockets/notifications/notification.module' import { VaultService } from './vault.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { mockVault } from './__mocks__/vault' +import { VaultUpdate } from './types/vault' +import { randomUUID } from 'crypto' +import { mockDonation, mockPayment } from '../donations/__mocks__/paymentMock' +import { Donation } from '@prisma/client' describe('VaultService', () => { let service: VaultService @@ -23,4 +28,49 @@ describe('VaultService', () => { it('should be defined', () => { expect(service).toBeDefined() }) + + it('decrementManyVaults should throw an error if one vaults returns negative amount', async () => { + const vaultResponseMock = [ + mockVault, + { ...mockVault, amount: 10, id: randomUUID() }, + { ...mockVault, amount: 20 }, + ] + const listOfVaults: VaultUpdate = { + [vaultResponseMock[0].id]: 90, + [vaultResponseMock[1].id]: 20, + } + + prismaMock.vault.findMany.mockResolvedValue(vaultResponseMock) + const updateSpy = jest.spyOn(service, 'updateManyVaults').mockImplementation() + expect(updateSpy).not.toHaveBeenCalled() + await expect(service.decrementManyVaults(listOfVaults, prismaMock)).rejects + .toThrow(`Updating vaults aborted, due to negative amount in some of the vaults. + Invalid vaultIds: ${vaultResponseMock[1].id}`) + }) + + it('prepareVaultObjectFromDonation should return VaultUpdate object', () => { + const [mockDonation, mockDonationToDiffVault, mockDonationToSameVault] = mockPayment.donations + const vaultId1 = mockDonation.targetVaultId + const vaultId2 = mockDonationToDiffVault.targetVaultId + const result: VaultUpdate = { + [vaultId1]: mockDonation.amount + mockDonationToSameVault.amount, + [vaultId2]: mockDonationToDiffVault.amount, + } + expect(mockDonation.targetVaultId).toEqual(mockDonationToSameVault.targetVaultId) + expect(service.prepareVaultUpdateObjectFromDonation(mockPayment.donations)).toEqual(result) + }) + it('prepareSQLValuesString should return string of VALUES to be updated by SQL statement', () => { + const [mockDonation, mockDonationToDiffVault, mockDonationToSameVault] = mockPayment.donations + const vaultId1 = mockDonation.targetVaultId + const vaultId2 = mockDonationToDiffVault.targetVaultId + + const vaultUpdateObj: VaultUpdate = { + [vaultId1]: mockDonation.amount + mockDonationToSameVault.amount, + [vaultId2]: mockDonationToDiffVault.amount, + } + + const result = `('${vaultId1}'::uuid, ${vaultUpdateObj[vaultId1]}),('${vaultId2}'::uuid, ${vaultUpdateObj[vaultId2]})` + + expect(service.prepareSQLValuesString(vaultUpdateObj)).toEqual(result) + }) }) diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 5389e17e4..9ed4b7c31 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -6,22 +6,13 @@ import { UnauthorizedException, BadRequestException, } from '@nestjs/common' -import { Prisma, Vault } from '@prisma/client' +import { Donation, Prisma, Vault } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { PersonService } from '../person/person.service' import { PrismaService } from '../prisma/prisma.service' import { CreateVaultDto } from './dto/create-vault.dto' import { UpdateVaultDto } from './dto/update-vault.dto' - -type VaultWithWithdrawalSum = Prisma.VaultGetPayload<{ - include: { campaign: { select: { id: true; title: true } } } -}> & { - withdrawnAmount: number -} - -type VaultUpdate = { - [id: string]: number -} +import { VaultUpdate, VaultWithWithdrawalSum } from './types/vault' @Injectable() export class VaultService { @@ -176,7 +167,6 @@ export class VaultService { tx: Prisma.TransactionClient, operationType: 'increment' | 'decrement', ) { - console.log(vaultId, amount, operationType) if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } @@ -194,22 +184,73 @@ export class VaultService { return vault } - public async updateManyVaultsAmount( + prepareVaultUpdateObjectFromDonation(donations: Donation[]): VaultUpdate { + const result = donations.reduce((acc, curr) => { + return { + ...acc, + [curr.targetVaultId]: acc[curr.targetVaultId] + ? acc[curr.targetVaultId] + curr.amount + : curr.amount, + } + }, {}) + return result + } + + async decrementManyVaults(listOfVaults: VaultUpdate, tx: Prisma.TransactionClient) { + const vaults = await tx.vault.findMany({ + where: { id: { in: Object.keys(listOfVaults) } }, + }) + const failedVaults = vaults.reduce((vaultAcc: string[], vault: Vault) => { + if (vault.amount - listOfVaults[vault.id] > 0) return vaultAcc + vaultAcc.push(vault.id) + return vaultAcc + }, []) + + 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(',')}`, + ) + } + return await this.updateManyVaults(listOfVaults, tx, 'decrement') + } + + async IncrementManyVaults(vaults: VaultUpdate, tx: Prisma.TransactionClient) { + return await this.updateManyVaults(vaults, tx, 'increment') + } + + prepareSQLValuesString(vaults: VaultUpdate): string { + return Object.entries(vaults) + .map(([vaultId, amount]) => `('${vaultId}'::uuid, ${amount})`) + .join(',') + } + + /** + * Update many vaults within a single query + * @param {VaultUpdate} vaults Object containing ids of vaults as key, and total amount to be incremented/decremented as a value + * @param tx - Prisma instance within transaction + * @param operationType Whether to increment or decrement vault + * @returns + */ + async updateManyVaults( vaults: VaultUpdate, tx: Prisma.TransactionClient, operationType: 'increment' | 'decrement', ) { - const sqlValues = Object.entries(vaults) - .map(([vaultId, amount]) => `('${vaultId}'::uuid, ${amount})`) - .join(',') + const sqlValues = this.prepareSQLValuesString(vaults) - return await tx.$queryRawUnsafe(` + return await tx.$executeRawUnsafe(` UPDATE vaults - SET amount = amount + new_amount + SET amount = CASE + WHEN ${operationType === 'increment'} THEN amount + new_amount + WHEN ${operationType === 'decrement'} THEN amount - new_amount + ELSE amount + END FROM ( VALUES ${sqlValues} ) AS updated_vault(id, new_amount) - WHERE vaults.id::text = updated_vault.id::text; + WHERE vaults.id::text = updated_vault.id::text AND vaults.amount::INTEGER - updated_vault.new_amount::INTEGER > 0 RETURNING *; `) } }