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/affiliate.controller.ts b/apps/api/src/affiliate/affiliate.controller.ts index 22f906fa9..0db8cc1b9 100644 --- a/apps/api/src/affiliate/affiliate.controller.ts +++ b/apps/api/src/affiliate/affiliate.controller.ts @@ -23,7 +23,7 @@ import { CreateAffiliateDonationDto } from './dto/create-affiliate-donation.dto' import { DonationsService } from '../donations/donations.service' import { shouldAllowStatusChange } from '../donations/helpers/donation-status-updates' import { affiliateCodeGenerator } from './utils/affiliateCodeGenerator' -import { DonationStatus } from '@prisma/client' +import { PaymentStatus } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' @@ -134,7 +134,7 @@ export class AffiliateController { @Public() async getAffiliateDonations( @Param('affiliateCode') affiliateCode: string, - @Query('status') status: DonationStatus | undefined, + @Query('status') status: PaymentStatus | undefined, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit') limit: number | undefined, ) { @@ -151,7 +151,7 @@ export class AffiliateController { async findAffiliateDonationByCustomerId( @Param('affiliateCode') affiliateCode: string, @Param('customerId') customerId: string, - @Query('status') status: DonationStatus | undefined, + @Query('status') status: PaymentStatus | undefined, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit') limit: number | undefined, ) { diff --git a/apps/api/src/affiliate/affiliate.service.ts b/apps/api/src/affiliate/affiliate.service.ts index 3ea32aa51..4b72209ba 100644 --- a/apps/api/src/affiliate/affiliate.service.ts +++ b/apps/api/src/affiliate/affiliate.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service' -import { AffiliateStatus, DonationStatus } from '@prisma/client' +import { AffiliateStatus, PaymentStatus } from '@prisma/client' @Injectable() export class AffiliateService { @@ -19,20 +19,24 @@ export class AffiliateService { async findDonationsByCustomerId( affiliateCode: string, extCustomerId: string, - status: DonationStatus | undefined, + status: PaymentStatus | undefined, currentPage: number, limit: number | undefined, ) { return await this.prismaService.affiliate.findFirst({ where: { affiliateCode, - donations: { some: { extCustomerId: { equals: extCustomerId }, status } }, + payments: { some: { extCustomerId, status } }, }, select: { - donations: { - take: limit ? Number(limit) : undefined, - skip: Number((currentPage - 1) * (limit ?? 0)), - include: { metadata: true }, + payments: { + select: { + donations: { + take: limit ? Number(limit) : undefined, + skip: Number((currentPage - 1) * (limit ?? 0)), + include: { metadata: true }, + }, + }, }, }, }) @@ -67,13 +71,22 @@ export class AffiliateService { async getAffiliateDataByKeycloakId(keycloakId: string) { return await this.prismaService.affiliate.findFirst({ where: { company: { person: { keycloakId } } }, - include: { - donations: { - where: { status: DonationStatus.guaranteed }, + select: { + status: true, + affiliateCode: true, + company: { select: { companyName: true } }, + payments: { + where: { status: PaymentStatus.guaranteed }, include: { - targetVault: { select: { campaign: { select: { title: true, slug: true } } } }, - affiliate: { select: { company: { select: { companyName: true } } } }, - metadata: { select: { name: true } }, + donations: { + select: { + id: true, + paymentId: true, + targetVault: { select: { campaign: { select: { title: true, slug: true } } } }, + metadata: { select: { name: true } }, + amount: true, + }, + }, }, }, }, @@ -82,19 +95,23 @@ export class AffiliateService { async findAffiliateDonationsWithPagination( affiliateCode: string, - status: DonationStatus | undefined, + status: PaymentStatus | undefined, currentPage: number, limit: number | undefined, ) { return await this.prismaService.affiliate.findUnique({ where: { affiliateCode }, select: { - donations: { - orderBy: { createdAt: 'desc' }, - where: { status }, - take: limit ? Number(limit) : undefined, - skip: Number((currentPage - 1) * (limit ?? 0)), - include: { metadata: true }, + payments: { + select: { + donations: { + orderBy: { createdAt: 'desc' }, + where: { payment: { status } }, + take: limit ? Number(limit) : undefined, + skip: Number((currentPage - 1) * (limit ?? 0)), + include: { metadata: true }, + }, + }, }, }, }) @@ -104,9 +121,13 @@ export class AffiliateService { return await this.prismaService.affiliate.findUnique({ where: { affiliateCode }, include: { - donations: { - orderBy: { createdAt: 'desc' }, - take: 10, + payments: { + include: { + donations: { + orderBy: { createdAt: 'desc' }, + take: 10, + }, + }, }, company: { select: { companyName: true, companyNumber: true, legalPersonName: true } }, }, diff --git a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts index 838182a95..43ff59061 100644 --- a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts +++ b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts @@ -1,5 +1,12 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider, Prisma } from '@prisma/client' +import { + Currency, + DonationType, + PaymentProvider, + PaymentStatus, + PaymentType, + Prisma, +} from '@prisma/client' import { Expose, Type } from 'class-transformer' import { Equals, @@ -81,10 +88,10 @@ export class CreateAffiliateDonationDto { @ValidateNested({ each: true }) metadata: DonationMetadataDto | undefined - public toEntity(targetVaultId: string): Prisma.DonationCreateInput { + public toEntity(targetVaultId: string): Prisma.PaymentCreateInput { return { - type: DonationType.corporate, - status: DonationStatus.guaranteed, + type: PaymentType.single, + status: PaymentStatus.guaranteed, provider: PaymentProvider.bank, currency: this.currency, amount: this.amount, @@ -93,16 +100,24 @@ export class CreateAffiliateDonationDto { extPaymentMethodId: this.extPaymentMethodId ?? '', billingEmail: this.billingEmail, billingName: this.billingName, - targetVault: { - connect: { - id: targetVaultId, + donations: { + create: { + type: DonationType.corporate, + amount: this.amount, + + person: + this.isAnonymous === false && this.billingEmail + ? { connect: { email: this.billingEmail } } + : {}, + + targetVault: { + connect: { + id: targetVaultId, + }, + }, }, }, affiliate: this.affiliateId ? { connect: { id: this.affiliateId } } : {}, - person: - this.isAnonymous === false && this.billingEmail - ? { connect: { email: this.billingEmail } } - : {}, } } } 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-file/bank-transactions-file.controller.ts b/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts index bf778da64..0c6fe35ba 100644 --- a/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts +++ b/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts @@ -25,7 +25,7 @@ import { VaultService } from '../vault/vault.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' import { parseBankTransactionsFile } from './helpers/parser' -import { DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { DonationType, PaymentProvider, PaymentStatus, PaymentType } from '@prisma/client' import { ApiTags } from '@nestjs/swagger' import { BankImportResult, @@ -108,15 +108,32 @@ export class BankTransactionsFileController { const vault = await this.vaultService.findByCampaignId(campaign.id) movement.payment.extPaymentMethodId = 'imported bank payment' - movement.payment.targetVaultId = vault[0].id - movement.payment.type = DonationType.donation - movement.payment.status = DonationStatus.succeeded + movement.payment.donations[0].targetVaultId = vault[0].id + movement.payment.type = PaymentType.single + movement.payment.status = PaymentStatus.succeeded movement.payment.provider = PaymentProvider.bank + const paymentObj: CreateBankPaymentDto = { + provider: PaymentProvider.bank, + status: PaymentStatus.succeeded, + type: PaymentType.single, + extPaymentIntentId: movement.payment.extPaymentIntentId, + extPaymentMethodId: 'imported bank payment', + amount: movement.payment.amount, + currency: movement.payment.currency, + createdAt: movement.payment.createdAt, + extCustomerId: movement.payment.extCustomerId, + donations: { + create: { + type: DonationType.donation, + amount: movement.payment.amount, + targetVault: { connect: { id: vault[0].id } }, + }, + }, + } + try { - bankImportResult.status = await this.donationsService.createUpdateBankPayment( - movement.payment, - ) + bankImportResult.status = await this.donationsService.createUpdateBankPayment(paymentObj) } catch (e) { const errorMsg = `Error during database import ${movement.paymentRef} : ${e}` bankImportResult.status = ImportStatus.FAILED diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts b/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts index 1fe099d13..eceb138a5 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts @@ -6,10 +6,12 @@ import { BankTransactionsController } from './bank-transactions.controller' import { BankTransactionsService } from './bank-transactions.service' import { BankDonationStatus, + BankTransaction, BankTransactionType, Campaign, CampaignState, Currency, + Prisma, Vault, } from '@prisma/client' import { DonationsService } from '../donations/donations.service' @@ -27,7 +29,9 @@ import { TemplateService } from '../email/template.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' import { AffiliateService } from '../affiliate/affiliate.service' -const bankTransactionsMock = [ +type CampaignWithVault = Prisma.CampaignGetPayload<{ include: { vaults: true } }> + +const bankTransactionsMock: BankTransaction[] = [ { id: '1679851630581', ibanNumber: 'BG27STSA93001111111111', @@ -43,6 +47,8 @@ const bankTransactionsMock = [ description: 'Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.imported, + matchedRef: '123', + notified: true, }, { id: '1679851630581', @@ -59,6 +65,8 @@ const bankTransactionsMock = [ description: 'Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.reImported, + matchedRef: '123', + notified: true, }, { id: '1679851630582', @@ -75,6 +83,8 @@ const bankTransactionsMock = [ description: 'WRONG_Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.unrecognized, + matchedRef: '123', + notified: true, }, { id: '1679851630583', @@ -91,16 +101,32 @@ const bankTransactionsMock = [ description: 'WRONG_Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.importFailed, + matchedRef: '123', + notified: true, }, ] -const mockCampaign: Campaign & { vaults: Vault[] } = { +const mockCampaign: CampaignWithVault = { id: 'testId', + slug: 'test', + beneficiaryId: '123', + campaignTypeId: '123', + title: 'campaign-mock', + essence: 'short-description', state: CampaignState.approved, createdAt: new Date('2022-04-08T06:36:33.661Z'), updatedAt: new Date('2022-04-08T06:36:33.662Z'), deletedAt: null, approvedById: null, + startDate: new Date('2022-04-08T06:36:33.661Z'), + endDate: new Date('2022-04-08T06:36:33.661Z'), + targetAmount: 5000, + allowDonationOnComplete: false, + description: 'h', + coordinatorId: '1234', + companyId: '123', + currency: 'BGN', + organizerId: '123', paymentReference: 'payment-ref', vaults: [], } diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 75db2fed5..3355d8e7e 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -169,7 +169,7 @@ export class BankTransactionsController { const isDev = appEnv === 'development' || appEnv === 'staging' if (!isDev) throw new ForbiddenException('Endpoint available only for testing enviroments') - if (!isAdmin(user)) throw new ForbiddenException('Must be either an admin or active affiliate') + if (!isAdmin(user)) throw new ForbiddenException('Must be an admin') return await this.bankTransactionsService.simulateIrisTask( irisDto.irisIbanAccountInfo, diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index cc61ec955..3ee0f237d 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -4,10 +4,11 @@ import { BankTransaction, BankTransactionType, Currency, - DonationStatus, + PaymentStatus, DonationType, PaymentProvider, Vault, + PaymentType, } from '@prisma/client' import { ExportService } from '../export/export.service' import { getTemplateByTable } from '../export/helpers/exportableData' @@ -141,11 +142,17 @@ export class BankTransactionsService { createdAt: new Date(bankTransaction?.transactionDate), billingName: bankTransaction?.senderName || '', extPaymentMethodId: 'Manual Re-import', - targetVaultId: vault?.id, - type: DonationType.donation, - status: DonationStatus.succeeded, + type: PaymentType.single, + status: PaymentStatus.succeeded, provider: PaymentProvider.bank, - personId: null, + donations: { + create: { + amount: bankTransaction?.amount || 0, + personId: null, + targetVaultId: vault?.id, + type: DonationType.donation, + }, + }, } // Execute as atomic transaction - fail/succeed as a whole diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index a8bdb2427..2fb786fad 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 as string, 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 as string, consent: true, } @@ -458,7 +442,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email as string, // Valid consent consent: true, } @@ -497,7 +481,7 @@ describe('CampaignController', () => { jest.spyOn(campaignService, 'createCampaignNotificationList') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email as string, // Valid consent consent: true, } @@ -538,7 +522,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email as string, // 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 as string, // Valid consent consent: true, } diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 2acd46684..11ce5598b 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -3,8 +3,7 @@ import { Campaign, CampaignState, CampaignType, - Donation, - DonationStatus, + PaymentStatus, DonationType, Vault, CampaignFileRole, @@ -12,6 +11,8 @@ import { NotificationList, EmailType, CampaignTypeCategory, + PaymentType, + Payment, } from '@prisma/client' import { BadRequestException, @@ -49,6 +50,7 @@ import { ConfigService } from '@nestjs/config' import { DateTime } from 'luxon' import { CampaignSubscribeDto } from './dto/campaign-subscribe.dto' import { MarketingNotificationsService } from '../notifications/notifications.service' +import type { PaymentWithDonation } from '../donations/types/donation' @Injectable() export class CampaignService { @@ -99,29 +101,27 @@ export class CampaignService { async getCampaignSums(campaignIds?: string[]): Promise { let campaignSums: CampaignSummaryDto[] = [] - const result = await this.prisma.$queryRaw`SELECT + const result = await this.prisma.$queryRaw` + SELECT SUM(d.reached)::INTEGER as "reachedAmount", - SUM(g.guaranteed)::INTEGER as "guaranteedAmount", + SUM(d.guaranteed)::INTEGER as "guaranteedAmount", (SUM(v.amount) - SUM(v."blockedAmount"))::INTEGER as "currentAmount", SUM(v."blockedAmount")::INTEGER as "blockedAmount", SUM(w."withdrawnAmount")::INTEGER as "withdrawnAmount", - SUM(COALESCE(g.donors, 0) + COALESCE(d.donors, 0))::INTEGER as donors, + SUM(COALESCE(d.donors, 0))::INTEGER as donors, v.campaign_id as id FROM api.vaults v LEFT JOIN ( - SELECT target_vault_id, sum(amount) as reached, count(id) as donors + SELECT + target_vault_id, + COUNT(d.id) FILTER (WHERE d.payment_id = p.id AND p.status::text = 'succeeded' OR p.status::text = 'guaranteed') AS donors, + sum(d.amount) FILTER (WHERE d.payment_id = p.id AND p.status::text = 'succeeded') as reached, + sum(d.amount) FILTER (WHERE d.payment_id = p.id AND p.status::text = 'guaranteed') as guaranteed FROM api.donations d - WHERE status = 'succeeded' + INNER JOIN payments as p ON p.id = d.payment_id GROUP BY target_vault_id ) as d - ON d.target_vault_id = v.id - LEFT JOIN ( - SELECT target_vault_id, sum(amount) as guaranteed, count(id) as donors - FROM api.donations d - WHERE status = 'guaranteed' - GROUP BY target_vault_id - ) as g - ON g.target_vault_id = v.id + ON d.target_vault_id = v.id LEFT JOIN ( SELECT source_vault_id, sum(amount) as "withdrawnAmount" FROM api.withdrawals w @@ -494,8 +494,21 @@ export class CampaignService { pageSize?: number, ): Promise< Omit< - Donation, + Prisma.DonationGetPayload<{ + include: { + payment: { + select: { + affiliateId: true + provider: true + amount: true + currency: true + chargedAmount: true + } + } + } + }>, | 'personId' + | 'paymentId' | 'targetVaultId' | 'extCustomerId' | 'extPaymentIntentId' @@ -531,16 +544,20 @@ export class CampaignService { select: { id: true, type: true, - status: true, - affiliateId: true, - provider: true, createdAt: true, updatedAt: true, amount: true, - chargedAmount: true, - currency: true, person: { select: { firstName: true, lastName: true } }, targetVault: { select: { name: true } }, + payment: { + select: { + affiliateId: true, + provider: true, + amount: true, + currency: true, + chargedAmount: true, + }, + }, }, skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, take: pageSize ? pageSize : undefined, @@ -549,8 +566,8 @@ export class CampaignService { return donations } - async getDonationByIntentId(paymentIntentId: string): Promise { - return this.prisma.donation.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) + async getPaymentByIntentId(paymentIntentId: string): Promise { + return this.prisma.payment.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) } /** @@ -564,7 +581,7 @@ export class CampaignService { async updateDonationPayment( campaign: Campaign, paymentData: PaymentData, - newDonationStatus: DonationStatus, + newDonationStatus: PaymentStatus, ): Promise { const campaignId = campaign.id Logger.debug('Update donation to status: ' + newDonationStatus, { @@ -607,15 +624,15 @@ export class CampaignService { private async updateDonationIfAllowed( tx: Prisma.TransactionClient, - donation: Donation, - newDonationStatus: DonationStatus, + payment: PaymentWithDonation, + newDonationStatus: PaymentStatus, paymentData: PaymentData, ) { - if (shouldAllowStatusChange(donation.status, newDonationStatus)) { + if (shouldAllowStatusChange(payment.status, newDonationStatus)) { try { - const updatedDonation = await tx.donation.update({ + const updatedDonation = await tx.payment.update({ where: { - id: donation.id, + id: payment.id, }, data: { status: newDonationStatus, @@ -631,30 +648,30 @@ export class CampaignService { //if donation is switching to successful, increment the vault amount and send notification if ( - donation.status != DonationStatus.succeeded && - newDonationStatus === DonationStatus.succeeded + payment.status != PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - donation.targetVaultId, + payment.donations[0].targetVaultId, paymentData.netAmount, tx, ) this.notificationService.sendNotification('successfulDonation', { ...updatedDonation, - person: updatedDonation.person, + person: updatedDonation.donations[0].person, }) } else if ( - donation.status === DonationStatus.succeeded && - newDonationStatus === DonationStatus.refund + payment.status === PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.refund ) { await this.vaultService.decrementVaultAmount( - donation.targetVaultId, + payment.donations[0].targetVaultId, paymentData.netAmount, tx, ) this.notificationService.sendNotification('successfulRefund', { ...updatedDonation, - person: updatedDonation.person, + person: updatedDonation.donations[0].person, }) } return updatedDonation @@ -669,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}`, ) } } @@ -677,7 +694,7 @@ export class CampaignService { private async createIncomingDonation( tx: Prisma.TransactionClient, paymentData: PaymentData, - newDonationStatus: DonationStatus, + newDonationStatus: PaymentStatus, campaign: Campaign, ) { Logger.debug( @@ -691,27 +708,37 @@ export class CampaignService { const targetVaultData = { connect: { id: vault.id } } try { - const donation = await tx.donation.create({ + const donation = await tx.payment.create({ data: { amount: paymentData.netAmount, chargedAmount: paymentData.chargedAmount, currency: campaign.currency, - targetVault: targetVaultData, provider: paymentData.paymentProvider, - type: paymentData.type as DonationType, + type: PaymentType.single, status: newDonationStatus, extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: paymentData.paymentMethodId ?? '', billingName: paymentData.billingName, billingEmail: paymentData.billingEmail, - person: paymentData.personId ? { connect: { id: paymentData.personId } } : {}, + donations: { + create: { + amount: paymentData.netAmount, + type: paymentData.type as DonationType, + person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, + targetVault: targetVaultData, + }, + }, }, select: donationNotificationSelect, }) - if (newDonationStatus === DonationStatus.succeeded) { - await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount, tx) + if (newDonationStatus === PaymentStatus.succeeded) { + await this.vaultService.incrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) this.notificationService.sendNotification('successfulDonation', donation) } @@ -726,8 +753,9 @@ export class CampaignService { private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { //first try to find by paymentIntentId - let donation = await tx.donation.findUnique({ + 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 @@ -736,13 +764,18 @@ export class CampaignService { 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({ + donation = await tx.payment.findFirst({ where: { - status: DonationStatus.initial, - personId: paymentData.personId, + status: PaymentStatus.initial, chargedAmount: paymentData.chargedAmount, extPaymentMethodId: 'subscription', + donations: { + some: { + personId: paymentData.personId, + }, + }, }, + include: { donations: true }, }) Logger.debug('Donation found by subscription: ', donation) } diff --git a/apps/api/src/common/dto/donation-query-dto.ts b/apps/api/src/common/dto/donation-query-dto.ts index 579d93560..362bd1d59 100644 --- a/apps/api/src/common/dto/donation-query-dto.ts +++ b/apps/api/src/common/dto/donation-query-dto.ts @@ -1,4 +1,4 @@ -import { DonationStatus, PaymentProvider } from '@prisma/client' +import { PaymentStatus, PaymentProvider } from '@prisma/client' import { Expose, Transform } from 'class-transformer' import { IsOptional } from 'class-validator' @@ -16,7 +16,12 @@ export class DonationQueryDto { @Expose() @IsOptional() @Transform(({ value }) => falsyToUndefined(value)) - status?: DonationStatus + status?: PaymentStatus + + @Expose() + @IsOptional() + @Transform(({ value }) => falsyToUndefined(value)) + paymentId?: string @Expose() @IsOptional() diff --git a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts index 3672e5d40..b2fc23620 100644 --- a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts +++ b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts @@ -1,6 +1,6 @@ import { AffiliateStatus } from '@prisma/client' import { Company } from '../../company/entities/company.entity' -import { Donation } from '../../donation/entities/donation.entity' +import { Payment } from '../../payment/entities/payment.entity' export class Affiliate { id: string @@ -10,5 +10,5 @@ export class Affiliate { createdAt: Date updatedAt: Date | null company?: Company - donations?: Donation[] + payments?: Payment[] } diff --git a/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts b/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts index a5015856d..a318d4f53 100644 --- a/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts +++ b/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts @@ -1,4 +1,3 @@ export class ConnectDonationDto { - id?: string - extPaymentIntentId?: string + id: string } diff --git a/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts b/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts index e447208dc..290d9bee1 100644 --- a/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts +++ b/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts @@ -4,9 +4,4 @@ import { ApiProperty } from '@nestjs/swagger' export class CreateDonationDto { @ApiProperty({ enum: DonationType }) type: DonationType - extCustomerId: string - extPaymentIntentId: string - extPaymentMethodId: string - billingEmail?: string - billingName?: string } diff --git a/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts b/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts index c88603166..050aef5ea 100644 --- a/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts +++ b/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts @@ -4,9 +4,4 @@ import { ApiProperty } from '@nestjs/swagger' export class UpdateDonationDto { @ApiProperty({ enum: DonationType }) type?: DonationType - extCustomerId?: string - extPaymentIntentId?: string - extPaymentMethodId?: string - billingEmail?: string - billingName?: string } diff --git a/apps/api/src/domain/generated/donation/entities/donation.entity.ts b/apps/api/src/domain/generated/donation/entities/donation.entity.ts index 57fc0ce8b..6eb35a1a9 100644 --- a/apps/api/src/domain/generated/donation/entities/donation.entity.ts +++ b/apps/api/src/domain/generated/donation/entities/donation.entity.ts @@ -1,31 +1,22 @@ -import { DonationType, DonationStatus, PaymentProvider, Currency } from '@prisma/client' +import { DonationType } from '@prisma/client' import { Person } from '../../person/entities/person.entity' import { Vault } from '../../vault/entities/vault.entity' -import { Affiliate } from '../../affiliate/entities/affiliate.entity' import { DonationWish } from '../../donationWish/entities/donationWish.entity' import { DonationMetadata } from '../../donationMetadata/entities/donationMetadata.entity' +import { Payment } from '../../payment/entities/payment.entity' export class Donation { id: string + paymentId: string type: DonationType - status: DonationStatus - provider: PaymentProvider targetVaultId: string - extCustomerId: string - extPaymentIntentId: string - extPaymentMethodId: string - createdAt: Date - updatedAt: Date | null amount: number - currency: Currency - affiliateId: string | null personId: string | null - billingEmail: string | null - billingName: string | null - chargedAmount: number + createdAt: Date + updatedAt: Date | null person?: Person | null targetVault?: Vault - affiliate?: Affiliate | null DonationWish?: DonationWish | null metadata?: DonationMetadata | null + payment?: Payment } diff --git a/apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts b/apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts new file mode 100644 index 000000000..a4b7fc988 --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts @@ -0,0 +1,4 @@ +export class ConnectPaymentDto { + id?: string + extPaymentIntentId?: string +} diff --git a/apps/api/src/domain/generated/payment/dto/create-payment.dto.ts b/apps/api/src/domain/generated/payment/dto/create-payment.dto.ts new file mode 100644 index 000000000..e7345fdf3 --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/create-payment.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class CreatePaymentDto { + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + @ApiProperty({ enum: PaymentType }) + type: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payment/dto/index.ts b/apps/api/src/domain/generated/payment/dto/index.ts new file mode 100644 index 000000000..2b04d6e87 --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-payment.dto' +export * from './create-payment.dto' +export * from './update-payment.dto' diff --git a/apps/api/src/domain/generated/payment/dto/update-payment.dto.ts b/apps/api/src/domain/generated/payment/dto/update-payment.dto.ts new file mode 100644 index 000000000..4070875cc --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/update-payment.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class UpdatePaymentDto { + extCustomerId?: string + extPaymentIntentId?: string + extPaymentMethodId?: string + @ApiProperty({ enum: PaymentType }) + type?: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payment/entities/index.ts b/apps/api/src/domain/generated/payment/entities/index.ts new file mode 100644 index 000000000..5319a1832 --- /dev/null +++ b/apps/api/src/domain/generated/payment/entities/index.ts @@ -0,0 +1 @@ +export * from './payment.entity' diff --git a/apps/api/src/domain/generated/payment/entities/payment.entity.ts b/apps/api/src/domain/generated/payment/entities/payment.entity.ts new file mode 100644 index 000000000..82b35a137 --- /dev/null +++ b/apps/api/src/domain/generated/payment/entities/payment.entity.ts @@ -0,0 +1,23 @@ +import { PaymentType, Currency, PaymentStatus, PaymentProvider } from '@prisma/client' +import { Affiliate } from '../../affiliate/entities/affiliate.entity' +import { Donation } from '../../donation/entities/donation.entity' + +export class Payment { + id: string + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + type: PaymentType + currency: Currency + status: PaymentStatus + provider: PaymentProvider + affiliateId: string | null + createdAt: Date + updatedAt: Date | null + chargedAmount: number + amount: number + billingEmail: string | null + billingName: string | null + affiliate?: Affiliate | null + donations?: Donation[] +} diff --git a/apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts b/apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts new file mode 100644 index 000000000..3ab987276 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts @@ -0,0 +1,4 @@ +export class ConnectPaymentsDto { + id?: string + extPaymentIntentId?: string +} diff --git a/apps/api/src/domain/generated/payments/dto/create-payments.dto.ts b/apps/api/src/domain/generated/payments/dto/create-payments.dto.ts new file mode 100644 index 000000000..370655488 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/create-payments.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class CreatePaymentsDto { + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + @ApiProperty({ enum: PaymentType }) + type: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payments/dto/index.ts b/apps/api/src/domain/generated/payments/dto/index.ts new file mode 100644 index 000000000..f5b1ef3e8 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-payments.dto' +export * from './create-payments.dto' +export * from './update-payments.dto' diff --git a/apps/api/src/domain/generated/payments/dto/update-payments.dto.ts b/apps/api/src/domain/generated/payments/dto/update-payments.dto.ts new file mode 100644 index 000000000..fabc19788 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/update-payments.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class UpdatePaymentsDto { + extCustomerId?: string + extPaymentIntentId?: string + extPaymentMethodId?: string + @ApiProperty({ enum: PaymentType }) + type?: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payments/entities/index.ts b/apps/api/src/domain/generated/payments/entities/index.ts new file mode 100644 index 000000000..e2eff48b0 --- /dev/null +++ b/apps/api/src/domain/generated/payments/entities/index.ts @@ -0,0 +1 @@ +export * from './payments.entity' diff --git a/apps/api/src/domain/generated/payments/entities/payments.entity.ts b/apps/api/src/domain/generated/payments/entities/payments.entity.ts new file mode 100644 index 000000000..6e050ba29 --- /dev/null +++ b/apps/api/src/domain/generated/payments/entities/payments.entity.ts @@ -0,0 +1,23 @@ +import { PaymentType, Currency, PaymentStatus, PaymentProvider } from '@prisma/client' +import { Affiliate } from '../../affiliate/entities/affiliate.entity' +import { Donation } from '../../donation/entities/donation.entity' + +export class Payments { + id: string + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + type: PaymentType + currency: Currency + status: PaymentStatus + provider: PaymentProvider + affiliateId: string | null + createdAt: Date + updatedAt: Date | null + chargedAmount: number + amount: number + billingEmail: string | null + billingName: string | null + affiliate?: Affiliate | null + donations?: Donation[] +} diff --git a/apps/api/src/donation-wish/donation-wish.service.ts b/apps/api/src/donation-wish/donation-wish.service.ts index f9eb148ff..bc7824fce 100644 --- a/apps/api/src/donation-wish/donation-wish.service.ts +++ b/apps/api/src/donation-wish/donation-wish.service.ts @@ -76,7 +76,6 @@ export class DonationWishService { donation: { select: { amount: true, - currency: true, type: true, metadata: { select: { name: true } }, }, 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.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts index 909625621..a1e894e35 100644 --- a/apps/api/src/donations/donations.controller.spec.ts +++ b/apps/api/src/donations/donations.controller.spec.ts @@ -6,11 +6,12 @@ import { Campaign, CampaignState, Currency, - DonationStatus, + PaymentStatus, DonationType, PaymentProvider, Person, Vault, + Payment, } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { ExportService } from '../export/export.service' @@ -24,6 +25,7 @@ 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' +import type { PaymentWithDonation } from './types/donation' describe('DonationsController', () => { let controller: DonationsController @@ -50,11 +52,26 @@ describe('DonationsController', () => { } as CreateSessionDto const mockDonation = { + id: '1234', + paymentId: '123', + type: DonationType.donation, + amount: 10, + targetVaultId: '1000', + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-02'), + personId: '1', + person: { + id: '1', + keycloakId: '00000000-0000-0000-0000-000000000015', + }, + } + + const mockPayment: PaymentWithDonation = { id: '123', provider: PaymentProvider.bank, currency: Currency.BGN, - type: DonationType.donation, - status: DonationStatus.succeeded, + type: 'single', + status: PaymentStatus.succeeded, amount: 10, affiliateId: null, extCustomerId: 'gosho', @@ -62,15 +79,10 @@ describe('DonationsController', () => { extPaymentMethodId: 'bank', billingEmail: 'gosho1@abv.bg', billingName: 'gosho1', - targetVaultId: '1000', chargedAmount: 10.5, createdAt: new Date('2022-01-01'), updatedAt: new Date('2022-01-02'), - personId: '1', - person: { - id: '1', - keycloakId: '00000000-0000-0000-0000-000000000015', - }, + donations: [mockDonation], } beforeEach(async () => { @@ -177,12 +189,11 @@ describe('DonationsController', () => { it('should update a donations donor, when it is changed', async () => { const updatePaymentDto = { - type: DonationType.donation, amount: 10, targetPersonId: '2', } - const existingDonation = { ...mockDonation } + const existingPayment = { ...mockPayment } const existingTargetPerson: Person = { id: '2', firstName: 'string', @@ -208,19 +219,26 @@ describe('DonationsController', () => { .spyOn(vaultService, 'incrementVaultAmount') .mockImplementation() - prismaMock.donation.findFirst.mockResolvedValueOnce(existingDonation) + prismaMock.payment.findFirst.mockResolvedValueOnce(existingPayment) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) // act await controller.update('123', updatePaymentDto) // assert - expect(prismaMock.donation.update).toHaveBeenCalledWith({ + expect(prismaMock.payment.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { - status: existingDonation.status, - personId: '2', - updatedAt: existingDonation.updatedAt, + status: existingPayment.status, + updatedAt: existingPayment.updatedAt, + donations: { + updateMany: { + where: { paymentId: existingPayment.id }, + data: { + personId: '2', + }, + }, + }, }, }) expect(mockedIncrementVaultAmount).toHaveBeenCalledTimes(0) @@ -228,18 +246,18 @@ describe('DonationsController', () => { it('should update a donation status, when it is changed', async () => { const updatePaymentDto: UpdatePaymentDto = { - type: DonationType.donation, + type: 'single', amount: 10, - status: DonationStatus.succeeded, + status: PaymentStatus.succeeded, targetPersonId: mockDonation.personId, - billingEmail: mockDonation.billingEmail, + billingEmail: mockPayment.billingEmail as string, } const existingTargetPerson: Person = { id: mockDonation.personId, firstName: 'string', lastName: 'string', - email: mockDonation.billingEmail, + email: mockPayment.billingEmail, phone: 'string', companyId: 'string', createdAt: new Date('2022-01-01'), @@ -255,34 +273,41 @@ describe('DonationsController', () => { profileEnabled: true, } - const existingDonation = { ...mockDonation, status: DonationStatus.initial } - const expectedUpdatedDonation = { ...existingDonation, status: DonationStatus.succeeded } + const existingPayment = { ...mockPayment, status: PaymentStatus.initial } + const expectedUpdatedPayment = { ...existingPayment, status: PaymentStatus.succeeded } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - prismaMock.donation.findFirst.mockResolvedValueOnce(existingDonation) + prismaMock.payment.findFirst.mockResolvedValueOnce(existingPayment) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) - prismaMock.donation.update.mockResolvedValueOnce(expectedUpdatedDonation) + prismaMock.payment.update.mockResolvedValueOnce(expectedUpdatedPayment) prismaMock.vault.update.mockResolvedValueOnce({ id: '1000', campaignId: '111' } as Vault) // act await controller.update('123', updatePaymentDto) // assert - expect(prismaMock.donation.update).toHaveBeenCalledWith({ + expect(prismaMock.payment.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { - status: DonationStatus.succeeded, - personId: updatePaymentDto.targetPersonId, + status: PaymentStatus.succeeded, billingEmail: updatePaymentDto.billingEmail, - updatedAt: expectedUpdatedDonation.updatedAt, + updatedAt: expectedUpdatedPayment.updatedAt, + donations: { + updateMany: { + where: { paymentId: existingPayment.id }, + data: { + personId: existingPayment.donations[0].personId, + }, + }, + }, }, }) expect(prismaMock.vault.update).toHaveBeenCalledWith({ - where: { id: existingDonation.targetVaultId }, + where: { id: existingPayment.donations[0].targetVaultId }, data: { amount: { - increment: existingDonation.amount, + increment: existingPayment.donations[0].amount, }, }, }) @@ -299,24 +324,24 @@ describe('DonationsController', () => { }) it('should invalidate a donation and update the vault if needed', async () => { - const existingDonation = { ...mockDonation, status: DonationStatus.succeeded } + const existingPayment = { ...mockPayment, status: PaymentStatus.succeeded } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - prismaMock.donation.findFirstOrThrow.mockResolvedValueOnce(existingDonation) + prismaMock.payment.findFirstOrThrow.mockResolvedValueOnce(existingPayment) await controller.invalidate('123') - expect(prismaMock.donation.update).toHaveBeenCalledWith({ + expect(prismaMock.payment.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { - status: DonationStatus.invalid, + status: PaymentStatus.invalid, }, }) expect(prismaMock.vault.update).toHaveBeenCalledWith({ - where: { id: existingDonation.targetVaultId }, + where: { id: existingPayment.donations[0].targetVaultId }, data: { amount: { - decrement: existingDonation.amount, + decrement: existingPayment.donations[0].amount, }, }, }) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index e7bf46fe2..4c099531b 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -14,9 +14,13 @@ import { forwardRef, } from '@nestjs/common' import { ApiQuery, ApiTags } from '@nestjs/swagger' -import { DonationStatus } from '@prisma/client' +import { PaymentStatus } from '@prisma/client' import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' -import { RealmViewSupporters, ViewSupporters, EditFinancialsRequests } from '@podkrepi-bg/podkrepi-types' +import { + RealmViewSupporters, + ViewSupporters, + EditFinancialsRequests, +} from '@podkrepi-bg/podkrepi-types' import { isAdmin, KeycloakTokenParsed } from '../auth/keycloak' import { DonationsService } from './donations.service' @@ -130,17 +134,12 @@ export class DonationsController { @CacheTTL(2 * 1000) @Public() @ApiQuery({ name: 'campaignId', required: false, type: String }) - @ApiQuery({ name: 'status', required: false, enum: DonationStatus }) @ApiQuery({ name: 'pageindex', required: false, type: Number }) @ApiQuery({ name: 'pagesize', required: false, type: Number }) - findAllPublic( - @Query('campaignId') campaignId?: string, - @Query('status') status?: DonationStatus, - @Query() query?: DonationQueryDto, - ) { + findAllPublic(@Query('campaignId') campaignId?: string, @Query() query?: DonationQueryDto) { return this.donationsService.listDonationsPublic( campaignId, - status, + query?.status, query?.pageindex, query?.pagesize, ) @@ -154,6 +153,33 @@ export class DonationsController { @DonationsApiQuery() findAll(@Query() query: DonationQueryDto) { return this.donationsService.listDonations( + query?.paymentId, + query?.campaignId, + query?.status, + query?.provider, + query?.minAmount, + query?.maxAmount, + query?.from, + query?.to, + query?.search, + query?.sortBy, + query?.sortOrder, + query?.pageindex, + query?.pagesize, + ) + } + + @Get('payments') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async paymentsList( + @AuthenticatedUser() user: KeycloakTokenParsed, + @Query() query: DonationQueryDto, + ) { + return await this.donationsService.listPayments( + query?.paymentId, query?.campaignId, query?.status, query?.provider, diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index cf8e3eaee..44085b09b 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -4,11 +4,13 @@ import { InjectStripeClient } from '@golevelup/nestjs-stripe' import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' import { Campaign, - Donation, - DonationStatus, + PaymentStatus, DonationType, PaymentProvider, Prisma, + PaymentType, + Payment, + Donation, } from '@prisma/client' import { Response } from 'express' import { getTemplateByTable } from '../export/helpers/exportableData' @@ -21,13 +23,16 @@ 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' -import { Person } from '../person/entities/person.entity' + import { DonationBaseDto, ListDonationsDto } from './dto/list-donations.dto' -import { donationWithPerson, DonationWithPerson } from './queries/donation.validator' +import { donationWithPerson } from './queries/donation.validator' 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' +import { VaultUpdate } from '../vault/types/vault' +import { PaymentWithDonation } from './types/donation' +import type { DonationWithPersonAndVault, PaymentWithDonationCount } from './types/donation' @Injectable() export class DonationsService { @@ -91,7 +96,7 @@ export class DonationsService { campaign: Campaign, stripePaymentDto: CreateStripePaymentDto, paymentIntent: Stripe.PaymentIntent, - ): Promise { + ): Promise { Logger.debug('[ CreateInitialDonationFromIntent]', { campaignId: campaign.id, amount: paymentIntent.amount, @@ -104,32 +109,36 @@ export class DonationsService { /** * Create or update initial donation object */ - const donation = await this.prisma.donation.upsert({ + const donation = await this.prisma.payment.upsert({ where: { extPaymentIntentId: paymentIntent.id }, create: { amount: 0, chargedAmount: paymentIntent.amount, currency: campaign.currency, + type: PaymentType.single, provider: PaymentProvider.stripe, - type: DonationType.donation, - status: DonationStatus.initial, + status: PaymentStatus.initial, extCustomerId: stripePaymentDto.personEmail, extPaymentIntentId: paymentIntent.id, extPaymentMethodId: 'card', billingEmail: stripePaymentDto.personEmail, - targetVault: targetVaultData, + donations: { + create: { + type: DonationType.donation, + targetVault: targetVaultData, + }, + }, }, update: { amount: 0, //this will be updated on successful payment event chargedAmount: paymentIntent.amount, currency: campaign.currency, provider: PaymentProvider.stripe, - type: DonationType.donation, - status: DonationStatus.waiting, + type: PaymentType.single, + status: PaymentStatus.waiting, extCustomerId: stripePaymentDto.personEmail, extPaymentMethodId: 'card', billingEmail: stripePaymentDto.personEmail, - targetVault: targetVaultData, }, }) @@ -269,39 +278,35 @@ export class DonationsService { async listDonationsPublic( campaignId?: string, - status?: DonationStatus, + status?: PaymentStatus, pageIndex?: number, pageSize?: number, - ): Promise> { + ) { const [data, count] = await this.prisma.$transaction([ this.prisma.donation.findMany({ where: { - OR: [{ status: status }, { status: DonationStatus.guaranteed }], - targetVault: { campaign: { id: campaignId } }, + OR: [{ payment: { status: status } }, { payment: { status: PaymentStatus.guaranteed } }], + targetVault: { campaignId }, }, orderBy: [{ updatedAt: 'desc' }], select: { id: true, type: true, - status: true, - provider: true, createdAt: true, updatedAt: true, amount: true, - chargedAmount: true, - currency: true, person: { select: { firstName: true, lastName: true, company: { select: { companyName: true } } }, }, - metadata: { select: { name: true } }, }, + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, take: pageSize ? pageSize : undefined, }), this.prisma.donation.count({ where: { - OR: [{ status: status }, { status: DonationStatus.guaranteed }], - targetVault: { campaign: { id: campaignId } }, + OR: [{ payment: { status: status } }, { payment: { status: PaymentStatus.guaranteed } }], + targetVault: { campaignId }, }, }), ]) @@ -319,13 +324,14 @@ export class DonationsService { if (!vault || vault.length === 0) throw new NotFoundException('Campaign or vault not found') - const donation = await this.prisma.donation.create({ + const payment = await this.prisma.payment.create({ data: donationDto.toEntity(vault[0].id), + include: { donations: true }, }) if (donationDto.metadata) { await this.prisma.donationMetadata.create({ data: { - donationId: donation.id, + donationId: payment.donations[0].id, ...donationDto.metadata, }, }) @@ -335,12 +341,12 @@ export class DonationsService { data: { campaignId: donationDto.campaignId, message: donationDto.message, - donationId: donation.id, + donationId: payment.donations[0].id, personId: donationDto.personId, }, }) } - return donation + return payment } /** @@ -355,9 +361,10 @@ export class DonationsService { * @param pageSize (Optional) */ async listDonations( + paymentId?: string, campaignId?: string, - status?: DonationStatus, - provider?: PaymentProvider, + paymentStatus?: PaymentStatus, + paymentProvider?: PaymentProvider, minAmount?: number, maxAmount?: number, from?: Date, @@ -367,10 +374,8 @@ export class DonationsService { sortOrder?: string, pageIndex?: number, pageSize?: number, - ): Promise> { - const whereClause = { - status, - provider, + ): Promise> { + const whereClause = Prisma.validator()({ amount: { gte: minAmount, lte: maxAmount, @@ -379,38 +384,90 @@ export class DonationsService { gte: from, lte: to, }, + paymentId: paymentId, + OR: [ + { payment: { status: paymentStatus } }, + { payment: { provider: paymentProvider } }, + { payment: { billingEmail: { contains: search } } }, + { payment: { billingName: { contains: search } } }, + ], + targetVault: { campaign: { id: campaignId } }, + }) + + const [data, count] = await this.prisma.$transaction([ + this.prisma.donation.findMany({ + where: whereClause, + orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, + take: pageSize ? pageSize : undefined, + ...donationWithPerson, + }), + this.prisma.donation.count({ + where: whereClause, + }), + ]) + + const result = { + items: data, + total: count, + } + + return result + } + + async listPayments( + paymentId?: string, + campaignId?: string, + paymentStatus?: PaymentStatus, + paymentProvider?: PaymentProvider, + minAmount?: number, + maxAmount?: number, + from?: Date, + to?: Date, + search?: string, + sortBy?: string, + sortOrder?: string, + pageIndex?: number, + pageSize?: number, + ): Promise> { + const whereClause = Prisma.validator()({ + // id: paymentId, + amount: { + gte: minAmount, + lte: maxAmount, + }, + createdAt: { + gte: from, + lte: to, + }, + status: paymentStatus, + provider: paymentProvider, ...(search && { OR: [ - { billingName: { contains: search } }, - { billingEmail: { contains: search } }, { - person: { - OR: [ - { - firstName: { contains: search }, - }, - { - lastName: { contains: search }, - }, - ], - }, + billingEmail: { contains: search }, }, + { billingName: { contains: search } }, ], }), - targetVault: { campaign: { id: campaignId } }, - } - - const data = await this.prisma.donation.findMany({ - where: whereClause, - orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], - skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, - take: pageSize ? pageSize : undefined, - ...donationWithPerson, - }) - - const count = await this.prisma.donation.count({ - where: whereClause, + donations: { some: { targetVault: { campaignId } } }, }) + const [data, count] = await this.prisma.$transaction([ + this.prisma.payment.findMany({ + where: whereClause, + orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, + take: pageSize ? pageSize : undefined, + include: { + _count: { + select: { + donations: true, + }, + }, + }, + }), + this.prisma.payment.count({ where: whereClause }), + ]) const result = { items: data, @@ -426,10 +483,11 @@ 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.donation.findFirstOrThrow({ + const donation = await this.prisma.payment.findFirstOrThrow({ where: { id }, + include: { donations: true }, }) return donation } catch (err) { @@ -441,7 +499,7 @@ export class DonationsService { async getAffiliateDonationById(donationId: string, affiliateCode: string) { try { - const donation = await this.prisma.donation.findFirstOrThrow({ + const donation = await this.prisma.payment.findFirstOrThrow({ where: { id: donationId, affiliate: { affiliateCode: affiliateCode } }, }) return donation @@ -465,9 +523,10 @@ export class DonationsService { return await this.prisma.donation.findFirst({ where: { id, - status: DonationStatus.succeeded, - OR: [{ billingEmail: email }, { person: { keycloakId } }], + payment: { status: PaymentStatus.succeeded }, + OR: [{ payment: { billingEmail: email } }, { person: { keycloakId } }], }, + include: { targetVault: { select: { @@ -502,7 +561,7 @@ export class DonationsService { * @param inputDto Payment intent create params * @returns {Promise>} */ - async createStripePayment(inputDto: CreateStripePaymentDto): 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') @@ -563,25 +622,27 @@ export class DonationsService { 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 - const existingDonation = await tx.donation.findUnique({ + const existingDonation = await tx.payment.findUnique({ where: { extPaymentIntentId: donationDto.extPaymentIntentId }, }) if (!existingDonation) { - await tx.donation.create({ + const payment = await tx.payment.create({ data: donationDto, + include: { + donations: true, + }, }) await this.vaultService.incrementVaultAmount( - donationDto.targetVaultId, - donationDto.amount, + payment.donations[0].targetVaultId, + payment.donations[0].amount, tx, ) return ImportStatus.SUCCESS } - //Donation exists, so updating with incoming donation without increasing vault amounts - await this.prisma.donation.update({ + await this.prisma.payment.update({ where: { extPaymentIntentId: donationDto.extPaymentIntentId }, data: { ...donationDto, updatedAt: existingDonation.updatedAt }, }) @@ -589,20 +650,20 @@ export class DonationsService { }) } - async updateAffiliateBankPayment(donationDto: Donation) { + async updateAffiliateBankPayment(paymentsIds: string[], listOfVaults: VaultUpdate) { return await this.prisma.$transaction(async (tx) => { await Promise.all([ - this.vaultService.incrementVaultAmount(donationDto.targetVaultId, donationDto.amount, tx), - tx.donation.update({ - where: { id: donationDto.id }, - data: { status: DonationStatus.succeeded, updatedAt: donationDto.updatedAt }, + this.vaultService.IncrementManyVaults(listOfVaults, tx), + tx.payment.updateMany({ + where: { id: { in: paymentsIds } }, + data: { status: PaymentStatus.succeeded }, }), ]) }) } - async updateAffiliateDonations(donationId: string, affiliateId: string, status: DonationStatus) { - const donation = await this.prisma.donation.update({ + async updateAffiliateDonations(donationId: string, affiliateId: string, status: PaymentStatus) { + const donation = await this.prisma.payment.update({ where: { id: donationId, affiliateId: affiliateId, @@ -619,31 +680,36 @@ export class DonationsService { * @param updatePaymentDto * @returns */ - async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { + async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { try { // execute the below in prisma transaction return await this.prisma.$transaction(async (tx) => { - const currentDonation = await tx.donation.findFirst({ + const currentPayment = await tx.payment.findFirst({ where: { id }, + include: { + donations: { + select: { personId: true, targetVaultId: true }, + }, + }, }) - if (!currentDonation) { + if (!currentPayment) { throw new NotFoundException(`Update failed. No donation found with ID: ${id}`) } if ( - currentDonation.status === DonationStatus.succeeded && + currentPayment.status === PaymentStatus.succeeded && updatePaymentDto.status && - updatePaymentDto.status !== DonationStatus.succeeded + updatePaymentDto.status !== PaymentStatus.succeeded ) { throw new BadRequestException('Succeeded donations cannot be updated.') } - const status = updatePaymentDto.status || currentDonation.status - let donorId = currentDonation.personId + const status = updatePaymentDto.status || currentPayment.status + let donorId = currentPayment.donations[0].personId let billingEmail: string | null = '' if ( (updatePaymentDto.targetPersonId && - currentDonation.personId !== updatePaymentDto.targetPersonId) || + currentPayment.donations[0].personId !== updatePaymentDto.targetPersonId) || updatePaymentDto.billingEmail ) { const targetDonor = await tx.person.findFirst({ @@ -663,28 +729,35 @@ export class DonationsService { billingEmail = targetDonor.email } - const donation = await tx.donation.update({ + const donation = await tx.payment.update({ where: { id }, data: { status: status, - personId: updatePaymentDto.targetPersonId ? donorId : undefined, + donations: { + updateMany: { + where: { paymentId: id }, + data: { + 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 + ? currentPayment.updatedAt : undefined, }, }) if ( - currentDonation.status !== DonationStatus.succeeded && - updatePaymentDto.status === DonationStatus.succeeded && - donation.status === DonationStatus.succeeded + currentPayment.status !== PaymentStatus.succeeded && + updatePaymentDto.status === PaymentStatus.succeeded && + donation.status === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - currentDonation.targetVaultId, - currentDonation.amount, + currentPayment.donations[0].targetVaultId, + currentPayment.amount, tx, ) } @@ -698,10 +771,10 @@ export class DonationsService { async softDelete(ids: string[]): Promise { try { - return await this.prisma.donation.updateMany({ + return await this.prisma.payment.updateMany({ where: { id: { in: ids } }, data: { - status: DonationStatus.deleted, + status: PaymentStatus.deleted, }, }) } catch (err) { @@ -717,14 +790,18 @@ export class DonationsService { await this.prisma.$transaction(async (tx) => { const donation = await this.getDonationById(id) - if (donation.status === DonationStatus.succeeded) { - await this.vaultService.decrementVaultAmount(donation.targetVaultId, donation.amount, tx) + if (donation.status === PaymentStatus.succeeded) { + await this.vaultService.decrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) } - await this.prisma.donation.update({ + await tx.payment.update({ where: { id }, data: { - status: DonationStatus.invalid, + status: PaymentStatus.invalid, }, }) }) @@ -740,18 +817,31 @@ export class DonationsService { async getDonationsByUser(keycloakId: string, email?: string) { const donations = await this.prisma.donation.findMany({ where: { - OR: [{ billingEmail: email }, { person: { keycloakId } }], + OR: [{ person: { keycloakId } }, { payment: { billingEmail: email } }], }, - orderBy: [{ createdAt: 'desc' }], include: { + payment: { + select: { + status: true, + provider: true, + }, + }, targetVault: { - include: { campaign: { select: { title: true, slug: true } } }, + select: { + campaign: { + select: { + title: true, + slug: true, + }, + }, + }, }, }, + orderBy: [{ createdAt: 'desc' }], }) const total = donations.reduce((acc, current) => { - if (current.status === DonationStatus.succeeded) { + if (current.payment.status === PaymentStatus.succeeded) { acc += current.amount } return acc @@ -773,11 +863,11 @@ export class DonationsService { } async getTotalDonatedMoney() { - const totalMoney = await this.prisma.donation.aggregate({ + const totalMoney = await this.prisma.payment.aggregate({ _sum: { amount: true, }, - where: { status: DonationStatus.succeeded }, + where: { status: PaymentStatus.succeeded }, }) return { total: totalMoney._sum.amount } @@ -786,7 +876,7 @@ export class DonationsService { async getDonorsCount() { const donorsCount = await this.prisma.$queryRaw<{ count: number - }>`SELECT COUNT (*)::INTEGER FROM (SELECT DISTINCT billing_name FROM donations WHERE status::text=${DonationStatus.succeeded}) AS unique_donors` + }>`SELECT COUNT (*)::INTEGER FROM (SELECT DISTINCT billing_name FROM payments WHERE status::text=${PaymentStatus.succeeded}) AS unique_donors` return { count: donorsCount[0].count } } @@ -797,6 +887,7 @@ export class DonationsService { async exportToExcel(query: DonationQueryDto, response: Response) { //get donations from db based on the filter parameters const { items } = await this.listDonations( + query?.paymentId, query?.campaignId, query?.status, query?.provider, diff --git a/apps/api/src/donations/dto/create-bank-payment.dto.ts b/apps/api/src/donations/dto/create-bank-payment.dto.ts index ece249f95..d5ddb447d 100644 --- a/apps/api/src/donations/dto/create-bank-payment.dto.ts +++ b/apps/api/src/donations/dto/create-bank-payment.dto.ts @@ -1,17 +1,17 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { Currency, PaymentStatus, PaymentProvider, PaymentType, Prisma } from '@prisma/client' import { Expose } from 'class-transformer' import { IsDate, IsNumber, IsOptional, IsPositive, IsString, IsUUID } from 'class-validator' @Expose() export class CreateBankPaymentDto { @Expose() - @ApiProperty({ enum: DonationType }) - type: DonationType + @ApiProperty({ enum: PaymentType }) + type: PaymentType @Expose() - @ApiProperty({ enum: DonationStatus }) - status: DonationStatus + @ApiProperty({ enum: PaymentStatus }) + status: PaymentStatus @Expose() @ApiProperty({ enum: PaymentProvider }) @@ -49,15 +49,7 @@ export class CreateBankPaymentDto { @Expose() @ApiProperty() - @IsString() - @IsUUID() - targetVaultId: string - - @Expose() - @ApiProperty() - @IsString() - @IsOptional() - personId: string | null + donations: Prisma.DonationCreateNestedManyWithoutPaymentInput billingName?: string billingEmail?: string diff --git a/apps/api/src/donations/dto/create-payment.dto.ts b/apps/api/src/donations/dto/create-payment.dto.ts index f7af3d4bf..4279fb28c 100644 --- a/apps/api/src/donations/dto/create-payment.dto.ts +++ b/apps/api/src/donations/dto/create-payment.dto.ts @@ -1,17 +1,24 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider, Prisma } from '@prisma/client' +import { + Currency, + PaymentStatus, + DonationType, + PaymentProvider, + Prisma, + PaymentType, +} from '@prisma/client' import { Expose } from 'class-transformer' import { IsNumber, IsString, IsUUID } from 'class-validator' @Expose() export class CreatePaymentDto { @Expose() - @ApiProperty({ enum: DonationType }) - type: DonationType + @ApiProperty({ enum: PaymentType }) + type: PaymentType @Expose() - @ApiProperty({ enum: DonationStatus }) - status: DonationStatus + @ApiProperty({ enum: PaymentStatus }) + status: PaymentStatus @Expose() @ApiProperty({ enum: PaymentProvider }) @@ -47,7 +54,7 @@ export class CreatePaymentDto { @IsUUID() targetVaultId: string - public toEntity(user): Prisma.DonationCreateInput { + public toEntity(user): Prisma.PaymentCreateInput { return { type: this.type, status: this.status, @@ -57,20 +64,25 @@ export class CreatePaymentDto { extCustomerId: this.extCustomerId, extPaymentIntentId: this.extPaymentIntentId, extPaymentMethodId: this.extPaymentMethodId, - targetVault: { - connect: { - id: this.targetVaultId, - }, - }, - person: { - connectOrCreate: { - where: { - email: user.email, + donations: { + create: { + type: DonationType.donation, + targetVault: { + connect: { + id: this.targetVaultId, + }, }, - create: { - firstName: user.given_name, - lastName: user.family_name, - email: user.email, + person: { + connectOrCreate: { + where: { + email: user.email, + }, + create: { + firstName: user.given_name, + lastName: user.family_name, + email: user.email, + }, + }, }, }, }, diff --git a/apps/api/src/donations/dto/list-donations.dto.ts b/apps/api/src/donations/dto/list-donations.dto.ts index 4337e9a9c..d567dc692 100644 --- a/apps/api/src/donations/dto/list-donations.dto.ts +++ b/apps/api/src/donations/dto/list-donations.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { Currency, PaymentStatus, PaymentProvider, PaymentType, Donation } from '@prisma/client' import { Expose } from 'class-transformer' export class DonationBaseDto { @@ -9,11 +9,11 @@ export class DonationBaseDto { @ApiProperty() @Expose() - type: DonationType + type: PaymentType @ApiProperty() @Expose() - status: DonationStatus + status: PaymentStatus @ApiProperty() @Expose() @@ -37,7 +37,7 @@ export class DonationBaseDto { @ApiProperty() @Expose() - person: { firstName: string; lastName: string } | null + donations: Donation[] } export class ListDonationsDto { 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 21437da3d..499f82bc6 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -14,8 +14,10 @@ import { StripeModule, StripeModuleConfig, StripePayloadService } from '@golevel import { Campaign, CampaignState, - Donation, DonationType, + PaymentType, + Payment, + Prisma, RecurringDonationStatus, Vault, } from '@prisma/client' @@ -39,7 +41,7 @@ import { mockPaymentEventFailed, mockChargeRefundEventSucceeded, } from './stripe-payment.testdata' -import { DonationStatus } from '@prisma/client' +import { PaymentStatus } from '@prisma/client' import { RecurringDonationService } from '../../recurring-donation/recurring-donation.service' import { HttpService } from '@nestjs/axios' import { mockDeep } from 'jest-mock-extended' @@ -49,6 +51,7 @@ 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' const defaultStripeWebhookEndpoint = '/stripe/webhook' const stripeSecret = 'wh_123' @@ -75,6 +78,36 @@ describe('StripePaymentService', () => { }), } + const mockPayment: PaymentWithDonation = { + id: 'test-donation-id', + type: PaymentType.single, + status: PaymentStatus.waiting, + provider: 'stripe', + affiliateId: null, + extCustomerId: 'test123', + extPaymentIntentId: 'test1234', + extPaymentMethodId: 'card', + amount: 0, //amount is 0 on donation created from payment-intent + chargedAmount: 0, + currency: 'BGN', + createdAt: new Date(), + updatedAt: new Date(), + billingName: 'Test test', + billingEmail: 'test@podkrepi.bg', + donations: [ + { + personId: '123', + targetVaultId: '123', + id: '123', + paymentId: 'test-donation-id', + type: DonationType.donation, + amount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + } + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -165,7 +198,7 @@ describe('StripePaymentService', () => { expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, - DonationStatus.waiting, + PaymentStatus.waiting, ) }) }) @@ -203,7 +236,7 @@ describe('StripePaymentService', () => { expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, - DonationStatus.cancelled, + PaymentStatus.cancelled, ) }) }) @@ -239,7 +272,7 @@ describe('StripePaymentService', () => { expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, - DonationStatus.declined, + PaymentStatus.declined, ) }) }) @@ -271,33 +304,13 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.donation.findUnique.mockResolvedValue({ - id: 'test-donation-id', - type: DonationType.donation, - status: DonationStatus.waiting, - provider: 'stripe', - affiliateId: null, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: 'card', - targetVaultId: 'test-vault-id', - amount: 0, //amount is 0 on donation created from payment-intent - chargedAmount: 0, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - billingName: paymentData.billingName ?? '', - billingEmail: paymentData.billingEmail ?? '', - personId: 'donation-person', - }) + prismaMock.payment.findUnique.mockResolvedValue(mockPayment) - prismaMock.donation.update.mockResolvedValue({ - id: 'test-donation-id', - targetVaultId: 'test-vault-id', + prismaMock.payment.update.mockResolvedValue({ + ...mockPayment, amount: paymentData.netAmount, - status: 'succeeded', - person: { firstName: 'Full', lastName: 'Name' }, - } as Donation & { person: unknown }) + status: PaymentStatus.succeeded, + }) prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) @@ -329,10 +342,10 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.findUnique).toHaveBeenCalled() - expect(prismaMock.donation.create).not.toHaveBeenCalled() + expect(prismaMock.payment.findUnique).toHaveBeenCalled() + expect(prismaMock.payment.create).not.toHaveBeenCalled() expect(mockedIncrementVaultAmount).toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalledTimes(1) + expect(prismaMock.payment.update).toHaveBeenCalledTimes(1) expect(mockedUpdateCampaignStatusIfTargetReached).toHaveBeenCalled() expect(prismaMock.campaign.update).toHaveBeenCalledWith({ where: { @@ -373,33 +386,13 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.donation.findUnique.mockResolvedValue({ - id: 'test-donation-id', - type: DonationType.donation, - status: DonationStatus.waiting, - provider: 'stripe', - affiliateId: '', - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: 'card', - targetVaultId: 'test-vault-id', - amount: 0, //amount is 0 on donation created from payment-intent - chargedAmount: 0, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - billingName: paymentData.billingName ?? '', - billingEmail: paymentData.billingEmail ?? '', - personId: 'donation-person', - }) + prismaMock.payment.findUnique.mockResolvedValue(mockPayment) - prismaMock.donation.update.mockResolvedValue({ - id: 'test-donation-id', - targetVaultId: 'test-vault-id', + prismaMock.payment.update.mockResolvedValue({ + ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, status: 'succeeded', - person: { firstName: 'Full', lastName: 'Name' }, - } as Donation & { person: unknown }) + }) prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) @@ -419,9 +412,9 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.findUnique).toHaveBeenCalled() - expect(prismaMock.donation.create).not.toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalledOnce() //for the donation to succeeded + expect(prismaMock.payment.findUnique).toHaveBeenCalled() + expect(prismaMock.payment.create).not.toHaveBeenCalled() + expect(prismaMock.payment.update).toHaveBeenCalledOnce() //for the donation to succeeded expect(mockedIncrementVaultAmount).toHaveBeenCalled() expect(mockedcreateDonationWish).toHaveBeenCalled() }) @@ -446,33 +439,13 @@ describe('StripePaymentService', () => { mockChargeEventSucceeded.data.object as Stripe.Charge, ) - prismaMock.donation.findUnique.mockResolvedValue({ - id: 'test-donation-id', - type: DonationType.donation, - status: DonationStatus.succeeded, - provider: 'stripe', - affiliateId: null, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: 'card', - targetVaultId: 'test-vault-id', - amount: 1000, //amount is 0 on donation created from payment-intent - chargedAmount: 800, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - billingName: paymentData.billingName ?? '', - billingEmail: paymentData.billingEmail ?? '', - personId: 'donation-person', - }) + const succeededPayment: Payment = { ...mockPayment, status: PaymentStatus.succeeded } + prismaMock.payment.findUnique.mockResolvedValue(succeededPayment) - prismaMock.donation.update.mockResolvedValue({ - id: 'test-donation-id', - targetVaultId: 'test-vault-id', - amount: paymentData.netAmount, - status: DonationStatus.refund, - person: { firstName: 'Full', lastName: 'Name' }, - } as Donation & { person: unknown }) + prismaMock.payment.update.mockResolvedValue({ + ...mockPayment, + status: PaymentStatus.refund, + }) prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) @@ -499,10 +472,10 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.findUnique).toHaveBeenCalled() - expect(prismaMock.donation.create).not.toHaveBeenCalled() + expect(prismaMock.payment.findUnique).toHaveBeenCalled() + expect(prismaMock.payment.create).not.toHaveBeenCalled() expect(mockDecremementVaultAmount).toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalled() + expect(prismaMock.payment.update).toHaveBeenCalled() }) }) @@ -601,18 +574,17 @@ describe('StripePaymentService', () => { .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') - prismaMock.donation.findFirst.mockResolvedValue({ - targetVaultId: '1', + prismaMock.payment.findFirst.mockResolvedValue({ + ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, - status: 'initial', - } as Donation) + status: PaymentStatus.initial, + }) - prismaMock.donation.update.mockResolvedValue({ - targetVaultId: '1', + prismaMock.payment.update.mockResolvedValue({ + ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, - status: 'initial', - person: {}, - } as Donation & { person: unknown }) + status: PaymentStatus.initial, + }) const mockedIncrementVaultAmount = jest .spyOn(vaultService, 'incrementVaultAmount') diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 3d7d13160..d15f33795 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -15,7 +15,7 @@ import { getPaymentDataFromCharge, PaymentData, } from '../helpers/payment-intent-helpers' -import { DonationStatus, CampaignState } from '@prisma/client' +import { PaymentStatus, CampaignState } from '@prisma/client' import { EmailService } from '../../email/email.service' import { RefundDonationEmailDto } from '../../email/template.interface' import { PrismaService } from '../../prisma/prisma.service' @@ -63,7 +63,7 @@ export class StripePaymentService { /* * Handle the create event */ - await this.campaignService.updateDonationPayment(campaign, paymentData, DonationStatus.waiting) + await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) } @StripeWebhookHandler('payment_intent.canceled') @@ -77,7 +77,7 @@ export class StripePaymentService { const billingData = getPaymentData(paymentIntent) - this.updatePaymentDonationStatus(paymentIntent, billingData, DonationStatus.cancelled) + this.updatePaymentPaymentStatus(paymentIntent, billingData, PaymentStatus.cancelled) } @StripeWebhookHandler('payment_intent.payment_failed') @@ -91,13 +91,13 @@ export class StripePaymentService { const billingData = getPaymentData(paymentIntent) - await this.updatePaymentDonationStatus(paymentIntent, billingData, DonationStatus.declined) + await this.updatePaymentPaymentStatus(paymentIntent, billingData, PaymentStatus.declined) } - async updatePaymentDonationStatus( + async updatePaymentPaymentStatus( paymentIntent: Stripe.PaymentIntent, billingData: PaymentData, - donationStatus: DonationStatus, + PaymentStatus: PaymentStatus, ) { const metadata: DonationMetadata = paymentIntent.metadata as DonationMetadata if (!metadata.campaignId) { @@ -109,7 +109,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, donationStatus) + await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus) } @StripeWebhookHandler('charge.succeeded') @@ -139,7 +139,7 @@ export class StripePaymentService { const donationId = await this.campaignService.updateDonationPayment( campaign, billingData, - DonationStatus.succeeded, + PaymentStatus.succeeded, ) //updateDonationPayment will mark the campaign as completed if amount is reached @@ -171,7 +171,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, DonationStatus.refund) + await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } @@ -364,11 +364,7 @@ export class StripePaymentService { const paymentData = getInvoiceData(invoice) - await this.campaignService.updateDonationPayment( - campaign, - paymentData, - DonationStatus.succeeded, - ) + await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.succeeded) //updateDonationPayment will mark the campaign as completed if amount is reached await this.cancelSubscriptionsIfCompletedCampaign(metadata.campaignId) diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 532c14974..6a799d325 100644 --- a/apps/api/src/donations/helpers/donation-status-updates.ts +++ b/apps/api/src/donations/helpers/donation-status-updates.ts @@ -1,35 +1,35 @@ -import { DonationStatus } from '@prisma/client' - -const initial: DonationStatus[] = [DonationStatus.initial] -const changeable: DonationStatus[] = [ - DonationStatus.incomplete, - DonationStatus.paymentRequested, - DonationStatus.waiting, - DonationStatus.declined, - DonationStatus.guaranteed, +import { PaymentStatus } from '@prisma/client' + +const initial: PaymentStatus[] = [PaymentStatus.initial] +const changeable: PaymentStatus[] = [ + PaymentStatus.incomplete, + PaymentStatus.paymentRequested, + PaymentStatus.waiting, + PaymentStatus.declined, + PaymentStatus.guaranteed, ] -const final: DonationStatus[] = [ - DonationStatus.succeeded, - DonationStatus.cancelled, - DonationStatus.deleted, - DonationStatus.invalid, - DonationStatus.refund, +const final: PaymentStatus[] = [ + PaymentStatus.succeeded, + PaymentStatus.cancelled, + PaymentStatus.deleted, + PaymentStatus.invalid, + PaymentStatus.refund, ] -function isInitial(status: DonationStatus) { +function isInitial(status: PaymentStatus) { return initial.includes(status) } -function isChangeable(status: DonationStatus) { +function isChangeable(status: PaymentStatus) { return changeable.includes(status) } -function isFinal(status: DonationStatus) { +function isFinal(status: PaymentStatus) { return final.includes(status) } -function isRefundable(oldStatus: DonationStatus, newStatus: DonationStatus) { - return oldStatus === DonationStatus.succeeded && newStatus === DonationStatus.refund +function isRefundable(oldStatus: PaymentStatus, newStatus: PaymentStatus) { + return oldStatus === PaymentStatus.succeeded && newStatus === PaymentStatus.refund } /** @@ -38,8 +38,8 @@ function isRefundable(oldStatus: DonationStatus, newStatus: DonationStatus) { * @returns allowed previous status that can be changed by the event */ export function shouldAllowStatusChange( - oldStatus: DonationStatus, - newStatus: DonationStatus, + oldStatus: PaymentStatus, + newStatus: PaymentStatus, ): boolean { if (oldStatus === newStatus || isRefundable(oldStatus, newStatus)) { return true diff --git a/apps/api/src/donations/queries/donation.validator.ts b/apps/api/src/donations/queries/donation.validator.ts index ac3f6b0be..292984047 100644 --- a/apps/api/src/donations/queries/donation.validator.ts +++ b/apps/api/src/donations/queries/donation.validator.ts @@ -22,5 +22,3 @@ export const donationWithPerson = Prisma.validator( metadata: true, }, }) - -export type DonationWithPerson = Prisma.DonationGetPayload diff --git a/apps/api/src/donations/types/donation.ts b/apps/api/src/donations/types/donation.ts new file mode 100644 index 000000000..84b632dc1 --- /dev/null +++ b/apps/api/src/donations/types/donation.ts @@ -0,0 +1,15 @@ +import { Prisma } from '@prisma/client' +import { donationWithPerson } from '../queries/donation.validator' + +export type PaymentWithDonation = Prisma.PaymentGetPayload<{ include: { donations: true } }> +export type DonationWithPerson = Prisma.DonationGetPayload<{ include: { person: true } }> +export type DonationWithPersonAndVault = Prisma.DonationGetPayload +export type PaymentWithDonationCount = Prisma.PaymentGetPayload<{ + include: { + _count: { + select: { + donations: 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/paypal/paypal.service.ts b/apps/api/src/paypal/paypal.service.ts index 3eb13182a..dc5c303de 100644 --- a/apps/api/src/paypal/paypal.service.ts +++ b/apps/api/src/paypal/paypal.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { CampaignService } from '../campaign/campaign.service' import { HttpService } from '@nestjs/axios' -import { DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { PaymentStatus, DonationType, PaymentProvider, PaymentType } from '@prisma/client' @Injectable() export class PaypalService { @@ -29,7 +29,7 @@ export class PaypalService { await this.campaignService.updateDonationPayment( campaign, billingDetails, - DonationStatus.waiting, + PaymentStatus.waiting, ) Logger.debug('Donation created!') @@ -52,7 +52,7 @@ export class PaypalService { await this.campaignService.updateDonationPayment( campaign, billingDetails, - DonationStatus.succeeded, + PaymentStatus.succeeded, ) Logger.debug('Donation completed!') @@ -166,7 +166,7 @@ export class PaypalService { //note we store the money in db as cents so we multiply incoming amounts by 100 return { //TODO: Find a way to attach type to metadata - type: DonationType.donation, + type: PaymentType.single, paymentProvider: PaymentProvider.paypal, campaignId: paypalOrder.resource.purchase_units[0].custom_id, paymentIntentId: paypalOrder.resource.purchase_units[0].payments.captures[0].id, @@ -197,7 +197,7 @@ export class PaypalService { paymentProvider: PaymentProvider.paypal, campaignId: paypalCapture.resource.custom_id, //TODO: Find a way to attach type to metadata - type: DonationType.donation, + type: PaymentType.single, paymentIntentId: paypalCapture.resource.id, netAmount: 100 * Number(paypalCapture.resource.seller_receivable_breakdown.net_amount.value), chargedAmount: @@ -218,5 +218,5 @@ type PaymentData = { billingEmail?: string paymentMethodId?: string stripeCustomerId?: string - type: DonationType + type: PaymentType } 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/sockets/notifications/notification.service.ts b/apps/api/src/sockets/notifications/notification.service.ts index 1eaea26ef..52feb357c 100644 --- a/apps/api/src/sockets/notifications/notification.service.ts +++ b/apps/api/src/sockets/notifications/notification.service.ts @@ -1,22 +1,28 @@ import { Injectable } from '@nestjs/common' import { NotificationGateway } from './gateway' +import { Prisma } from '@prisma/client' -export const donationNotificationSelect = { +export const donationNotificationSelect = Prisma.validator()({ id: true, status: true, currency: true, amount: true, - createdAt: true, extPaymentMethodId: true, - targetVaultId: true, - person: { + createdAt: true, + + donations: { select: { - firstName: true, - lastName: true, - picture: true, + targetVaultId: true, + person: { + select: { + firstName: true, + lastName: true, + picture: true, + }, + }, }, }, -} +}) @Injectable() export class NotificationService { diff --git a/apps/api/src/statistics/statistics.service.ts b/apps/api/src/statistics/statistics.service.ts index 3bc6149a6..000f70144 100644 --- a/apps/api/src/statistics/statistics.service.ts +++ b/apps/api/src/statistics/statistics.service.ts @@ -19,21 +19,21 @@ export class StatisticsService { ): Promise { const date = groupBy === GroupBy.MONTH - ? Prisma.sql`DATE_TRUNC('MONTH', created_at) date` + ? Prisma.sql`DATE_TRUNC('MONTH', d.created_at) date` : groupBy === GroupBy.WEEK - ? Prisma.sql`DATE_TRUNC('WEEK', created_at) date` - : Prisma.sql`DATE_TRUNC('DAY', created_at) date` + ? Prisma.sql`DATE_TRUNC('WEEK', d.created_at) date` + : Prisma.sql`DATE_TRUNC('DAY', d.created_at) date` const group = groupBy === GroupBy.MONTH - ? Prisma.sql`GROUP BY DATE_TRUNC('MONTH', created_at)` + ? Prisma.sql`GROUP BY DATE_TRUNC('MONTH', d.created_at)` : groupBy === GroupBy.WEEK - ? Prisma.sql`GROUP BY DATE_TRUNC('WEEK', created_at)` - : Prisma.sql`GROUP BY DATE_TRUNC('DAY', created_at)` + ? Prisma.sql`GROUP BY DATE_TRUNC('WEEK', d.created_at)` + : Prisma.sql`GROUP BY DATE_TRUNC('DAY', d.created_at)` return this.prisma.$queryRaw` - SELECT SUM(amount)::INTEGER, COUNT(id)::INTEGER, ${date} - FROM api.donations WHERE status = 'succeeded' + SELECT SUM(d.amount)::INTEGER, COUNT(d.id)::INTEGER, ${date} + FROM api.donations d, payments p WHERE p.status::text = 'succeeded' ${Prisma.sql`AND target_vault_id IN ( SELECT id from api.vaults WHERE campaign_id = ${campaignId}::uuid)`} ${group} ORDER BY date ASC ` @@ -41,17 +41,17 @@ export class StatisticsService { async listUniqueDonations(campaignId: string): Promise { return this.prisma.$queryRaw` - SELECT amount::INTEGER, COUNT(id)::INTEGER AS count - FROM api.donations WHERE status = 'succeeded' + SELECT d.amount::INTEGER, COUNT(d.id)::INTEGER AS count + FROM api.donations d, payments p WHERE p.status::text = 'succeeded' ${Prisma.sql`AND target_vault_id IN ( SELECT id from api.vaults WHERE campaign_id = ${campaignId}::uuid)`} - GROUP BY amount + GROUP BY d.amount ORDER BY amount ASC` } async listHourlyDonations(campaignId: string): Promise { return this.prisma.$queryRaw` - SELECT EXTRACT(HOUR from created_at)::INTEGER AS hour, COUNT(id)::INTEGER AS count - FROM api.donations where status = 'succeeded' + SELECT EXTRACT(HOUR from d.created_at)::INTEGER AS hour, COUNT(d.id)::INTEGER AS count + FROM api.donations d, payments p WHERE p.status::text = 'succeeded' ${Prisma.sql`AND target_vault_id IN ( SELECT id from api.vaults WHERE campaign_id = ${campaignId}::uuid)`} GROUP BY hour ORDER BY hour ASC` diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts index 92ba78380..39be21441 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts @@ -18,7 +18,8 @@ import { BankDonationStatus, BankTransaction, Campaign, - DonationStatus, + DonationType, + PaymentStatus, Prisma, Vault, } from '@prisma/client' @@ -32,7 +33,7 @@ import { MarketingNotificationsService } from '../../notifications/notifications const IBAN = 'BG77UNCR92900016740920' type AffiliateWithPayload = Prisma.AffiliateGetPayload<{ - include: { donations: true } + include: { payments: { include: { donations: true } } } }> class MockIrisTasks extends IrisTasks { @@ -68,25 +69,37 @@ describe('ImportTransactionsTask', () => { companyId: '1234572', affiliateCode: 'af_12345', status: 'active', - donations: [ + createdAt: new Date(), + updatedAt: new Date(), + payments: [ { - id: 'donation-id', - type: 'donation', + id: 'payment-id', + type: 'single', status: 'guaranteed', amount: 5000, affiliateId: 'affiliate-id', - personId: null, extCustomerId: '', extPaymentIntentId: '123456', extPaymentMethodId: '1234', billingEmail: 'test@podkrepi.bg', billingName: 'John doe', - targetVaultId: 'vault-id', chargedAmount: 0, currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date('2023-03-14T00:00:00.000Z'), + updatedAt: new Date('2023-03-14T00:00:00.000Z'), provider: 'bank', + donations: [ + { + type: DonationType.donation, + id: '123', + amount: 50, + targetVaultId: '1', + paymentId: 'payment-id', + createdAt: new Date('2023-03-14T00:00:00.000Z'), + updatedAt: new Date('2023-03-14T00:00:00.000Z'), + personId: null, + }, + ], }, ], } @@ -390,14 +403,14 @@ describe('ImportTransactionsTask', () => { }, }, include: { - donations: { + payments: { where: { - status: DonationStatus.guaranteed, + status: PaymentStatus.guaranteed, }, orderBy: { createdAt: 'asc', }, - include: { targetVault: true }, + include: { donations: true }, }, }, }), @@ -412,10 +425,21 @@ describe('ImportTransactionsTask', () => { id: mockDonatedCampaigns[0].vaults[0].id, }), ) + expect(donationSpy).toHaveBeenCalledWith( expect.objectContaining({ - extPaymentIntentId: mockIrisTransactions[0].transactionId, - targetVaultId: mockDonatedCampaigns[0].vaults[0].id, + amount: 5000, + billingName: 'JOHN DOE', + createdAt: new Date('2023-03-14T00:00:00.000Z'), + currency: 'BGN', + donations: { create: { personId: null, targetVaultId: 'vault-id', type: 'donation' } }, + extCustomerId: 'BG77UNCR92900016740920', + extPaymentIntentId: + 'Booked_5954782144_70123543493054963FTRO23073A58G01C2023345440_20230314', + extPaymentMethodId: 'IRIS bank import', + provider: 'bank', + status: 'succeeded', + type: 'single', }), ) 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 27a6bf805..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,9 +5,10 @@ import { SchedulerRegistry } from '@nestjs/schedule' import { BankDonationStatus, Currency, - DonationStatus, DonationType, PaymentProvider, + PaymentStatus, + PaymentType, Prisma, Vault, } from '@prisma/client' @@ -34,10 +35,11 @@ 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: { donations: true } + include: { payments: { include: { donations: true } } } }> @Injectable() @@ -393,14 +395,17 @@ export class IrisTasks { }, }, include: { - donations: { + payments: { where: { - status: DonationStatus.guaranteed, + status: PaymentStatus.guaranteed, }, + orderBy: { createdAt: 'asc', }, - include: { targetVault: true }, + include: { + donations: true, + }, }, }, }) @@ -458,25 +463,35 @@ export class IrisTasks { private async processAffiliateDonations(affiliate: AffiliatePayload, trx: filteredTransaction) { let totalDonated = 0 - let updatedDonations = 0 + let updatedPayments = 0 if (!trx.amount) { trx.bankDonationStatus = BankDonationStatus.importFailed return ImportStatus.UNPROCESSED } + + const paymentIdsToUpdate: string[] = [] + const vaultsToUpdate: VaultUpdate = {} + //If no guaranteed donations are found for the affiliate //mark the transaction as imported - if (affiliate.donations.length === 0) { + if (affiliate.payments.length === 0) { trx.bankDonationStatus = BankDonationStatus.imported return ImportStatus.SUCCESS } - for (const donation of affiliate.donations) { - if (trx.amount - totalDonated < donation.amount) continue - await this.donationsService.updateAffiliateBankPayment(donation) - totalDonated += donation.amount - updatedDonations++ + for (const payment of affiliate.payments) { + if (trx.amount - totalDonated < payment.amount) continue + paymentIdsToUpdate.push(payment.id) + for (const donation of payment.donations) { + vaultsToUpdate[donation.targetVaultId] = + (vaultsToUpdate[donation.targetVaultId] || 0) + donation.amount + } + totalDonated += payment.amount + updatedPayments++ } - if (trx.amount - totalDonated > 0 || updatedDonations < affiliate.donations.length) { + await this.donationsService.updateAffiliateBankPayment(paymentIdsToUpdate, vaultsToUpdate) + + if (trx.amount - totalDonated > 0 || updatedPayments < affiliate.payments.length) { trx.bankDonationStatus = BankDonationStatus.incomplete return ImportStatus.INCOMPLETE } @@ -497,11 +512,16 @@ export class IrisTasks { createdAt: new Date(bankTransaction.transactionDate), billingName: bankTransaction.senderName || '', extPaymentMethodId: this.paymentMethodId, - targetVaultId: vault.id, - type: DonationType.donation, - status: DonationStatus.succeeded, + type: PaymentType.single, + status: PaymentStatus.succeeded, provider: PaymentProvider.bank, - personId: null, + donations: { + create: { + personId: null, + targetVaultId: vault.id, + type: DonationType.donation, + }, + }, } return bankPayment 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 a9702baa2..9ed4b7c31 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -6,18 +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 -} +import { VaultUpdate, VaultWithWithdrawalSum } from './types/vault' @Injectable() export class VaultService { @@ -170,7 +165,7 @@ export class VaultService { vaultId: string, amount: number, tx: Prisma.TransactionClient, - operationType: string, + operationType: 'increment' | 'decrement', ) { if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') @@ -188,4 +183,74 @@ export class VaultService { const vault = await tx.vault.update(updateStatement) return vault } + + 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 = this.prepareSQLValuesString(vaults) + + return await tx.$executeRawUnsafe(` + UPDATE vaults + 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 AND vaults.amount::INTEGER - updated_vault.new_amount::INTEGER > 0 RETURNING *; + `) + } } diff --git a/migrations/20240212112752_new_donation_structure/migration.sql b/migrations/20240212112752_new_donation_structure/migration.sql new file mode 100644 index 000000000..90e592a79 --- /dev/null +++ b/migrations/20240212112752_new_donation_structure/migration.sql @@ -0,0 +1,133 @@ +BEGIN; + +CREATE TABLE "donations_temp" AS TABLE donations; + +--Rename donation_status to payment_status +CREATE TYPE "payment_status" AS ENUM ('initial', 'invalid', 'incomplete', 'declined', 'waiting', 'cancelled', 'guaranteed', 'succeeded', 'deleted', 'refund', 'paymentRequested'); +CREATE TYPE "payment_type" AS ENUM ('single', 'category', 'benevity'); + + +--Drop constraints as donation table will be truncated +--Constraints will be re-added before the transaction is commited +ALTER TABLE "donation_metadata" DROP CONSTRAINT "donation_metadata_donation_id_fkey"; +ALTER TABLE "donation_wishes" DROP CONSTRAINT "donation_wishes_donation_id_fkey"; + +--Delete all existing records of donation +TRUNCATE donations; + +-- Remove redundant fields, indexes and constraint from donations table. Add payment_id field +ALTER TABLE "donations" + DROP COLUMN "affiliate_id", + DROP COLUMN "billing_email", + DROP COLUMN "billing_name", + DROP COLUMN "chargedAmount", + DROP COLUMN "ext_customer_id", + DROP COLUMN "ext_payment_intent_id", + DROP COLUMN "ext_payment_method_id", + DROP COLUMN "currency", + DROP COLUMN "provider", + DROP COLUMN "status", + ADD COLUMN "payment_id" UUID NOT NULL; + + + +--Create Payments table +CREATE TABLE payments ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "ext_customer_id" VARCHAR(50) NOT NULL, + "ext_payment_intent_id" TEXT NOT NULL, + "ext_payment_method_id" TEXT NOT NULL, + "type" "payment_type" NOT NULL, + "currency" "currency" NOT NULL DEFAULT 'BGN', + "status" "payment_status" NOT NULL DEFAULT 'initial', + "provider" "payment_provider" NOT NULL DEFAULT 'none', + "affiliate_id" UUID, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + "charged_amount" INTEGER NOT NULL DEFAULT 0, + "amount" INTEGER NOT NULL DEFAULT 0, + "billing_email" VARCHAR, + "billing_name" VARCHAR, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +--Add donation<->payments relation +ALTER TABLE "donations" ADD CONSTRAINT "donations_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "payments_ext_payment_intent_id_key" ON "payments"("ext_payment_intent_id"); + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_affiliate_id_fkey" FOREIGN KEY ("affiliate_id") REFERENCES "affiliates"("id") ON DELETE SET NULL ON UPDATE CASCADE; +DO $$ +DECLARE + dbrow RECORD; + payment_result RECORD; + old_donation_count INTEGER; + payments_count INTEGER; + new_donation_count INTEGER; + l_context TEXT; + + BEGIN +RAISE DEBUG '==FILL TABLES=='; + FOR dbrow IN SELECT * FROM "donations_temp" + LOOP + RAISE DEBUG '%', dbrow; + WITH payment AS ( + INSERT INTO payments ("ext_customer_id", + "ext_payment_intent_id", + "ext_payment_method_id", + "type", + "currency", + "status", + "provider", + "affiliate_id", + "created_at", + "updated_at", + "charged_amount", + "amount", + "billing_email", + "billing_name") + VALUES ( + dbrow.ext_customer_id, + dbrow.ext_payment_intent_id, + dbrow.ext_payment_method_id, + 'single', + dbrow.currency, + dbrow.status::TEXT::payment_status, + dbrow.provider, + dbrow.affiliate_id, + dbrow.created_at, + dbrow.updated_at, + dbrow."chargedAmount", + dbrow.amount, + dbrow.billing_email, + dbrow.billing_name + ) RETURNING id + ) + + SELECT * INTO payment_result FROM payment; + INSERT INTO "donations" (id, payment_id, "type", target_vault_id, amount, person_id, created_at, updated_at) + VALUES(dbrow.id, payment_result.id, dbrow.type, dbrow.target_vault_id, dbrow.amount, dbrow.person_id, dbrow.created_at, dbrow.updated_at); + + END LOOP; +RAISE DEBUG '==END FILL TABLES=='; + +SELECT COUNT(*)::INTEGER INTO old_donation_count FROM donations_temp; +SELECT COUNT(*)::INTEGER INTO payments_count FROM payments; +SELECT COUNT (*)::INTEGER INTO new_donation_count FROM donations; + +ASSERT old_donation_count = payments_count, 'Mismatch of old and new versions'; +ASSERT old_donation_count = new_donation_count, 'Mismatch of old and new versions'; +ASSERT payments_count = new_donation_count, 'Payments and Donations have different length'; +END$$; + +-- Add constraints +ALTER TABLE "donation_metadata" ADD CONSTRAINT "donation_metadata_donation_id_fkey" FOREIGN KEY ("donation_id") REFERENCES "donations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "donation_wishes" ADD CONSTRAINT "donation_wishes_donation_id_fkey" FOREIGN KEY ("donation_id") REFERENCES "donations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +--DROP temp dable +DROP TABLE "donations_temp"; +DROP TYPE "donation_status"; +COMMIT; \ No newline at end of file diff --git a/podkrepi.dbml b/podkrepi.dbml index 7c6c8b54c..451d653cd 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -76,7 +76,7 @@ Table affiliates { createdAt DateTime [default: `now()`, not null] updatedAt DateTime company companies [not null] - donations donations [not null] + payments payments [not null] } Table organizers { @@ -369,29 +369,40 @@ Table vaults { withdraws withdrawals [not null] } -Table donations { +Table payments { id String [pk] - type DonationType [not null] - status DonationStatus [not null, default: 'initial'] - provider PaymentProvider [not null, default: 'none'] - targetVaultId String [not null, note: 'Vault where the funds are going'] - extCustomerId String [not null, note: 'Payment provider attributes'] + extCustomerId String [not null] extPaymentIntentId String [unique, not null] extPaymentMethodId String [not null] + type PaymentType [not null] + currency Currency [not null, default: 'BGN'] + status PaymentStatus [not null, default: 'initial'] + provider PaymentProvider [not null, default: 'none'] + affiliateId String createdAt DateTime [default: `now()`, not null] updatedAt DateTime + chargedAmount Int [not null, default: 0] amount Int [not null, default: 0] - currency Currency [not null, default: 'BGN'] - affiliateId String - personId String billingEmail String billingName String - chargedAmount Int [not null, default: 0] + affiliate affiliates + donations donations [not null] +} + +Table donations { + id String [pk] + paymentId String [not null] + type DonationType [not null] + targetVaultId String [not null, note: 'Vault where the funds are going'] + amount Int [not null, default: 0] + personId String + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime person people targetVault vaults [not null] - affiliate affiliates DonationWish donation_wishes metadata donation_metadata + payment payments [not null] } Table donation_metadata { @@ -660,7 +671,13 @@ Enum DonationType { corporate } -Enum DonationStatus { +Enum PaymentType { + single + category + benevity +} + +Enum PaymentStatus { initial invalid incomplete @@ -867,11 +884,13 @@ Ref: cities.countryId > countries.id Ref: vaults.campaignId > campaigns.id +Ref: payments.affiliateId > affiliates.id + Ref: donations.personId > people.id Ref: donations.targetVaultId > vaults.id -Ref: donations.affiliateId > affiliates.id +Ref: donations.paymentId > payments.id Ref: donation_metadata.donationId - donations.id diff --git a/schema.prisma b/schema.prisma index 1c3d805d6..73826c6d0 100644 --- a/schema.prisma +++ b/schema.prisma @@ -107,7 +107,7 @@ model Affiliate { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) company Company @relation(fields: [companyId], references: [id]) - donations Donation[] + payments Payment[] @@map("affiliates") } @@ -448,32 +448,44 @@ model Vault { @@map("vaults") } +model Payment { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + extCustomerId String @map("ext_customer_id") @db.VarChar(50) + extPaymentIntentId String @unique @map("ext_payment_intent_id") + extPaymentMethodId String @map("ext_payment_method_id") + type PaymentType + currency Currency @default(BGN) + status PaymentStatus @default(initial) + provider PaymentProvider @default(none) + affiliateId String? @map("affiliate_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + chargedAmount Int @default(0) @map("charged_amount") + amount Int @default(0) + billingEmail String? @map("billing_email") @db.VarChar + billingName String? @map("billing_name") @db.VarChar + affiliate Affiliate? @relation(fields: [affiliateId], references: [id]) + donations Donation[] + + @@map("payments") +} + model Donation { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - type DonationType - status DonationStatus @default(initial) - provider PaymentProvider @default(none) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + + paymentId String @map("payment_id") @db.Uuid + type DonationType /// Vault where the funds are going - targetVaultId String @map("target_vault_id") @db.Uuid - /// Payment provider attributes - extCustomerId String @map("ext_customer_id") @db.VarChar(50) - extPaymentIntentId String @unique @map("ext_payment_intent_id") - extPaymentMethodId String @map("ext_payment_method_id") - /// - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - amount Int @default(0) - currency Currency @default(BGN) - affiliateId String? @map("affiliate_id") @db.Uuid - personId String? @map("person_id") @db.Uuid - billingEmail String? @map("billing_email") @db.VarChar - billingName String? @map("billing_name") @db.VarChar - chargedAmount Int @default(0) - person Person? @relation(fields: [personId], references: [id]) - targetVault Vault @relation(fields: [targetVaultId], references: [id]) - affiliate Affiliate? @relation(fields: [affiliateId], references: [id]) - DonationWish DonationWish? - metadata DonationMetadata? + targetVaultId String @map("target_vault_id") @db.Uuid + amount Int @default(0) + personId String? @map("person_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + person Person? @relation(fields: [personId], references: [id]) + targetVault Vault @relation(fields: [targetVaultId], references: [id]) + DonationWish DonationWish? + metadata DonationMetadata? + payment Payment @relation(fields: [paymentId], references: [id]) @@map("donations") } @@ -801,7 +813,15 @@ enum DonationType { @@map("donation_type") } -enum DonationStatus { +enum PaymentType { + single + category + benevity + + @@map("payment_type") +} + +enum PaymentStatus { initial invalid incomplete @@ -814,7 +834,7 @@ enum DonationStatus { refund paymentRequested - @@map("donation_status") + @@map("payment_status") } enum RecurringDonationStatus {