From 7dde7aa28556b26e9b576ade0b29154a2e7211f4 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 25 Mar 2024 19:44:08 +0200 Subject: [PATCH 01/15] refactor: Move donation related fns from campaign to donation service --- apps/api/src/campaign/campaign.service.ts | 220 ---------------- apps/api/src/donations/donations.service.ts | 235 +++++++++++++++++- .../events/stripe-payment.service.ts | 13 +- 3 files changed, 241 insertions(+), 227 deletions(-) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 9c429955..88c8e8ae 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -570,226 +570,6 @@ export class CampaignService { return this.prisma.payment.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) } - /** - * Creates or Updates an incoming donation depending on the newDonationStatus attribute - * @param campaign - * @param paymentData - * @param newDonationStatus - * @param metadata - * @returns donation.id of the created/updated donation - */ - async updateDonationPayment( - campaign: Campaign, - paymentData: PaymentData, - newDonationStatus: PaymentStatus, - ): Promise { - const campaignId = campaign.id - Logger.debug('Update donation to status: ' + newDonationStatus, { - campaignId, - paymentIntentId: paymentData.paymentIntentId, - }) - - //Update existing donation or create new in a transaction that - //also increments the vault amount and marks campaign as completed - //if target amount is reached - return await this.prisma.$transaction(async (tx) => { - let donationId - // Find donation by extPaymentIntentId - const existingDonation = await this.findExistingDonation(tx, paymentData) - - //if missing create the donation with the incoming status - if (!existingDonation) { - const newDonation = await this.createIncomingDonation( - tx, - paymentData, - newDonationStatus, - campaign, - ) - donationId = newDonation.id - } - //donation exists, so check if it is safe to update it - else { - const updatedDonation = await this.updateDonationIfAllowed( - tx, - existingDonation, - newDonationStatus, - paymentData, - ) - donationId = updatedDonation?.id - } - - return donationId - }) //end of the transaction scope - } - - private async updateDonationIfAllowed( - tx: Prisma.TransactionClient, - payment: PaymentWithDonation, - newDonationStatus: PaymentStatus, - paymentData: PaymentData, - ) { - if (shouldAllowStatusChange(payment.status, newDonationStatus)) { - try { - const updatedDonation = await tx.payment.update({ - where: { - id: payment.id, - }, - data: { - status: newDonationStatus, - amount: paymentData.netAmount, - extCustomerId: paymentData.stripeCustomerId, - extPaymentMethodId: paymentData.paymentMethodId, - extPaymentIntentId: paymentData.paymentIntentId, - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - donations: { - updateMany: { - where: { paymentId: payment.id }, - data: { - amount: paymentData.netAmount, - }, - }, - }, - }, - select: donationNotificationSelect, - }) - - //if donation is switching to successful, increment the vault amount and send notification - if ( - payment.status != PaymentStatus.succeeded && - newDonationStatus === PaymentStatus.succeeded - ) { - await this.vaultService.incrementVaultAmount( - payment.donations[0].targetVaultId, - paymentData.netAmount, - tx, - ) - this.notificationService.sendNotification('successfulDonation', { - ...updatedDonation, - person: updatedDonation.donations[0].person, - }) - } else if ( - payment.status === PaymentStatus.succeeded && - newDonationStatus === PaymentStatus.refund - ) { - await this.vaultService.decrementVaultAmount( - payment.donations[0].targetVaultId, - paymentData.netAmount, - tx, - ) - this.notificationService.sendNotification('successfulRefund', { - ...updatedDonation, - person: updatedDonation.donations[0].person, - }) - } - return updatedDonation - } catch (error) { - Logger.error( - `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, - ) - throw new InternalServerErrorException(error) - } - } - //donation exists but we need to skip because previous status is from later event than the incoming - else { - Logger.warn( - `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} - and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, - ) - } - } - - private async createIncomingDonation( - tx: Prisma.TransactionClient, - paymentData: PaymentData, - newDonationStatus: PaymentStatus, - campaign: Campaign, - ) { - Logger.debug( - 'No donation exists with extPaymentIntentId: ' + - paymentData.paymentIntentId + - ' Creating new donation with status: ' + - newDonationStatus, - ) - - const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) - const targetVaultData = { connect: { id: vault.id } } - - try { - const donation = await tx.payment.create({ - data: { - amount: paymentData.netAmount, - chargedAmount: paymentData.chargedAmount, - currency: campaign.currency, - provider: paymentData.paymentProvider, - type: PaymentType.single, - status: newDonationStatus, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: paymentData.paymentMethodId ?? '', - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - donations: { - create: { - amount: paymentData.netAmount, - type: paymentData.type as DonationType, - person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, - targetVault: targetVaultData, - }, - }, - }, - select: donationNotificationSelect, - }) - - if (newDonationStatus === PaymentStatus.succeeded) { - await this.vaultService.incrementVaultAmount( - donation.donations[0].targetVaultId, - donation.amount, - tx, - ) - this.notificationService.sendNotification('successfulDonation', donation) - } - - return donation - } catch (error) { - Logger.error( - `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, - ) - throw new InternalServerErrorException(error) - } - } - - private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { - //first try to find by paymentIntentId - let donation = await tx.payment.findUnique({ - where: { extPaymentIntentId: paymentData.paymentIntentId }, - include: { donations: true }, - }) - - // if not found by paymentIntent, check for if this is payment on subscription - // check for UUID length of personId - // subscriptions always have a personId - if (!donation && paymentData.personId && paymentData.personId.length === 36) { - // search for a subscription donation - // for subscriptions, we don't have a paymentIntentId - donation = await tx.payment.findFirst({ - where: { - status: PaymentStatus.initial, - chargedAmount: paymentData.chargedAmount, - extPaymentMethodId: 'subscription', - donations: { - some: { - personId: paymentData.personId, - }, - }, - }, - include: { donations: true }, - }) - Logger.debug('Donation found by subscription: ', donation) - } - return donation - } - async createDonationWish(wish: string, donationId: string, campaignId: string) { const person = await this.prisma.donation.findUnique({ where: { id: donationId } }).person() await this.prisma.donationWish.upsert({ diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index d04a6cfe..9829af61 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -1,7 +1,13 @@ import Stripe from 'stripe' import { ConfigService } from '@nestjs/config' import { InjectStripeClient } from '@golevelup/nestjs-stripe' -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common' import { Campaign, PaymentStatus, @@ -33,6 +39,12 @@ import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-do import { VaultUpdate } from '../vault/types/vault' import { PaymentWithDonation } from './types/donation' import type { DonationWithPersonAndVault, PaymentWithDonationCount } from './types/donation' +import { PaymentData } from './helpers/payment-intent-helpers' +import { + NotificationService, + donationNotificationSelect, +} from '../sockets/notifications/notification.service' +import { shouldAllowStatusChange } from './helpers/donation-status-updates' @Injectable() export class DonationsService { @@ -43,6 +55,7 @@ export class DonationsService { private prisma: PrismaService, private vaultService: VaultService, private exportService: ExportService, + private notificationService: NotificationService, ) {} async listPrices(type?: Stripe.PriceListParams.Type, active?: boolean): Promise { const listResponse = await this.stripeClient.prices.list({ active, type, limit: 100 }).then( @@ -937,4 +950,224 @@ export class DonationsService { }) }) } + + /** + * Creates or Updates an incoming donation depending on the newDonationStatus attribute + * @param campaign + * @param paymentData + * @param newDonationStatus + * @param metadata + * @returns donation.id of the created/updated donation + */ + async updateDonationPayment( + campaign: Campaign, + paymentData: PaymentData, + newDonationStatus: PaymentStatus, + ): Promise { + const campaignId = campaign.id + Logger.debug('Update donation to status: ' + newDonationStatus, { + campaignId, + paymentIntentId: paymentData.paymentIntentId, + }) + + //Update existing donation or create new in a transaction that + //also increments the vault amount and marks campaign as completed + //if target amount is reached + return await this.prisma.$transaction(async (tx) => { + let donationId + // Find donation by extPaymentIntentId + const existingDonation = await this.findExistingDonation(tx, paymentData) + + //if missing create the donation with the incoming status + if (!existingDonation) { + const newDonation = await this.createIncomingDonation( + tx, + paymentData, + newDonationStatus, + campaign, + ) + donationId = newDonation.id + } + //donation exists, so check if it is safe to update it + else { + const updatedDonation = await this.updateDonationIfAllowed( + tx, + existingDonation, + newDonationStatus, + paymentData, + ) + donationId = updatedDonation?.id + } + + return donationId + }) //end of the transaction scope + } + + private async updateDonationIfAllowed( + tx: Prisma.TransactionClient, + payment: PaymentWithDonation, + newDonationStatus: PaymentStatus, + paymentData: PaymentData, + ) { + if (shouldAllowStatusChange(payment.status, newDonationStatus)) { + try { + const updatedDonation = await tx.payment.update({ + where: { + id: payment.id, + }, + data: { + status: newDonationStatus, + amount: paymentData.netAmount, + extCustomerId: paymentData.stripeCustomerId, + extPaymentMethodId: paymentData.paymentMethodId, + extPaymentIntentId: paymentData.paymentIntentId, + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + donations: { + updateMany: { + where: { paymentId: payment.id }, + data: { + amount: paymentData.netAmount, + }, + }, + }, + }, + select: donationNotificationSelect, + }) + + //if donation is switching to successful, increment the vault amount and send notification + if ( + payment.status != PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.succeeded + ) { + await this.vaultService.incrementVaultAmount( + payment.donations[0].targetVaultId, + paymentData.netAmount, + tx, + ) + this.notificationService.sendNotification('successfulDonation', { + ...updatedDonation, + person: updatedDonation.donations[0].person, + }) + } else if ( + payment.status === PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.refund + ) { + await this.vaultService.decrementVaultAmount( + payment.donations[0].targetVaultId, + paymentData.netAmount, + tx, + ) + this.notificationService.sendNotification('successfulRefund', { + ...updatedDonation, + person: updatedDonation.donations[0].person, + }) + } + return updatedDonation + } catch (error) { + Logger.error( + `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, + ) + throw new InternalServerErrorException(error) + } + } + //donation exists but we need to skip because previous status is from later event than the incoming + else { + Logger.warn( + `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} + and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, + ) + } + } + + private async createIncomingDonation( + tx: Prisma.TransactionClient, + paymentData: PaymentData, + newDonationStatus: PaymentStatus, + campaign: Campaign, + ) { + Logger.debug( + 'No donation exists with extPaymentIntentId: ' + + paymentData.paymentIntentId + + ' Creating new donation with status: ' + + newDonationStatus, + ) + + const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) + const targetVaultData = { connect: { id: vault.id } } + + try { + const donation = await tx.payment.create({ + data: { + amount: paymentData.netAmount, + chargedAmount: paymentData.chargedAmount, + currency: campaign.currency, + provider: paymentData.paymentProvider, + type: PaymentType.single, + status: newDonationStatus, + extCustomerId: paymentData.stripeCustomerId ?? '', + extPaymentIntentId: paymentData.paymentIntentId, + extPaymentMethodId: paymentData.paymentMethodId ?? '', + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + donations: { + create: { + amount: paymentData.netAmount, + type: paymentData.type as DonationType, + person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, + targetVault: targetVaultData, + }, + }, + }, + select: donationNotificationSelect, + }) + + if (newDonationStatus === PaymentStatus.succeeded) { + await this.vaultService.incrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) + this.notificationService.sendNotification('successfulDonation', donation) + } + + return donation + } catch (error) { + Logger.error( + `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, + ) + throw new InternalServerErrorException(error) + } + } + + private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { + //first try to find by paymentIntentId + let donation = await tx.payment.findUnique({ + where: { extPaymentIntentId: paymentData.paymentIntentId }, + include: { donations: true }, + }) + + // if not found by paymentIntent, check for if this is payment on subscription + // check for UUID length of personId + // subscriptions always have a personId + if (!donation && paymentData.personId && paymentData.personId.length === 36) { + // search for a subscription donation + // for subscriptions, we don't have a paymentIntentId + donation = await tx.payment.findFirst({ + where: { + status: PaymentStatus.initial, + chargedAmount: paymentData.chargedAmount, + extPaymentMethodId: 'subscription', + donations: { + some: { + personId: paymentData.personId, + }, + }, + }, + include: { donations: true }, + }) + Logger.debug('Donation found by subscription: ', donation) + } + return donation + } } diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index d15f3379..86692ef1 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -19,6 +19,7 @@ import { PaymentStatus, CampaignState } from '@prisma/client' import { EmailService } from '../../email/email.service' import { RefundDonationEmailDto } from '../../email/template.interface' import { PrismaService } from '../../prisma/prisma.service' +import { DonationsService } from '../donations.service' /** Testing Stripe on localhost is described here: * https://github.com/podkrepi-bg/api/blob/master/TESTING.md#testing-stripe @@ -26,10 +27,10 @@ import { PrismaService } from '../../prisma/prisma.service' @Injectable() export class StripePaymentService { constructor( + private donationService: DonationsService, private campaignService: CampaignService, private recurringDonationService: RecurringDonationService, private sendEmail: EmailService, - private prismaService: PrismaService, ) {} @StripeWebhookHandler('payment_intent.created') @@ -63,7 +64,7 @@ export class StripePaymentService { /* * Handle the create event */ - await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) + await this.donationService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) } @StripeWebhookHandler('payment_intent.canceled') @@ -109,7 +110,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus) + await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus) } @StripeWebhookHandler('charge.succeeded') @@ -136,7 +137,7 @@ export class StripePaymentService { const billingData = getPaymentDataFromCharge(charge) - const donationId = await this.campaignService.updateDonationPayment( + const donationId = await this.donationService.updateDonationPayment( campaign, billingData, PaymentStatus.succeeded, @@ -171,7 +172,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) + await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } @@ -364,7 +365,7 @@ export class StripePaymentService { const paymentData = getInvoiceData(invoice) - await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.succeeded) + await this.donationService.updateDonationPayment(campaign, paymentData, PaymentStatus.succeeded) //updateDonationPayment will mark the campaign as completed if amount is reached await this.cancelSubscriptionsIfCompletedCampaign(metadata.campaignId) From cd714f85ced032d2c109e2862337f60db1593464 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 25 Mar 2024 19:53:29 +0200 Subject: [PATCH 02/15] stripe.spec: Adjust stripe tests to module changes --- .../events/stripe-payment.service.spec.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) 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 499f82bc..a1308cb3 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -52,12 +52,15 @@ import { MarketingNotificationsService } from '../../notifications/notifications import { EmailService } from '../../email/email.service' import { TemplateService } from '../../email/template.service' import type { PaymentWithDonation } from '../types/donation' +import { DonationsService } from '../donations.service' const defaultStripeWebhookEndpoint = '/stripe/webhook' const stripeSecret = 'wh_123' describe('StripePaymentService', () => { let stripePaymentService: StripePaymentService + let campaignService: CampaignService + let donationService: DonationsService let app: INestApplication const stripe = new Stripe(stripeSecret, { apiVersion: '2022-11-15' }) @@ -129,6 +132,7 @@ describe('StripePaymentService', () => { ConfigService, StripePaymentService, CampaignService, + DonationsService, MockPrismaService, VaultService, PersonService, @@ -147,6 +151,8 @@ describe('StripePaymentService', () => { await app.init() stripePaymentService = app.get(StripePaymentService) + campaignService = app.get(CampaignService) + donationService = app.get(DonationsService) //this intercepts the request raw body and removes the exact signature check const stripePayloadService = app.get(StripePayloadService) @@ -170,7 +176,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -178,7 +183,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventCreated.data.object as Stripe.PaymentIntent) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -211,7 +216,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -221,7 +225,7 @@ describe('StripePaymentService', () => { ) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -249,7 +253,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -257,7 +260,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventFailed.data.object as Stripe.PaymentIntent) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -288,7 +291,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -323,7 +325,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') @@ -370,7 +372,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -398,7 +399,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') @@ -428,9 +429,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) - const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -458,7 +457,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockDecremementVaultAmount = jest.spyOn(vaultService, 'decrementVaultAmount') @@ -502,7 +501,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const recurring = app.get(RecurringDonationService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignVault') @@ -561,7 +559,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -571,7 +568,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockName('updateDonationPayment') prismaMock.payment.findFirst.mockResolvedValue({ @@ -614,7 +611,6 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) - const campaignService = app.get(CampaignService) const recurring = app.get(RecurringDonationService) const mockCancelSubscription = jest @@ -626,7 +622,7 @@ describe('StripePaymentService', () => { .mockImplementation(() => Promise.resolve(mockedCampaignCompeleted)) const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') + .spyOn(donationService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') From 5959d76d946e103b096f923b17ecfad237a316f3 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 2 Apr 2024 12:39:08 +0300 Subject: [PATCH 03/15] random-commits --- .../api/src/donations/donations.controller.ts | 33 ++++++++++++- apps/api/src/donations/donations.service.ts | 46 ++++++++++++++++++- ...pdate-payment-from-stripe-charge.dto.ts.ts | 11 +++++ .../helpers/donation-status-updates.ts | 14 ++++++ apps/api/src/paypal/paypal.module.ts | 3 +- apps/api/src/paypal/paypal.service.ts | 6 ++- 6 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index 63681360..d6600117 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -12,6 +12,7 @@ import { Res, Inject, forwardRef, + Put, } from '@nestjs/common' import { ApiQuery, ApiTags } from '@nestjs/swagger' import { PaymentStatus } from '@prisma/client' @@ -37,6 +38,8 @@ import { PersonService } from '../person/person.service' import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager' import { UseInterceptors } from '@nestjs/common' +import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts' + @ApiTags('donation') @Controller('donation') export class DonationsController { @@ -196,7 +199,10 @@ export class DonationsController { } @Get(':id') - @Public() + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) findOne(@Param('id') id: string) { return this.donationsService.getDonationById(id) } @@ -254,6 +260,15 @@ export class DonationsController { return this.donationsService.refundStripePayment(paymentIntentId) } + @Get('/payments/:id') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + getPaymentByPaymentIntentId(@Param('id') paymentIntentId: string) { + return this.donationsService.getDonationById(paymentIntentId) + } + @Post('create-bank-payment') @Roles({ roles: [RealmViewSupporters.role, ViewSupporters.role], @@ -292,6 +307,22 @@ export class DonationsController { return this.donationsService.update(id, updatePaymentDto) } + @Get('stripe/:id/') + @Public() + async findStripePayment(@Param('id') stripeId: string) { + return await this.donationsService.findDonationByStripeId(stripeId) + } + + @Put('create-update-stripe-payment') + // @Roles({ + // roles: [EditFinancialsRequests.role], + // mode: RoleMatchingMode.ANY, + // }) + @Public() + async syncWithPaymentWithStripe(@Body() stripeChargeDto: CreateUpdatePaymentFromStripeChargeDto) { + return await this.donationsService.syncPaymentWithStripe(stripeChargeDto) + } + @Patch(':id/sync-with-payment') @Roles({ roles: [EditFinancialsRequests.role], diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 9829af61..ed8256e5 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config' import { InjectStripeClient } from '@golevelup/nestjs-stripe' import { BadRequestException, + ForbiddenException, Injectable, InternalServerErrorException, Logger, @@ -39,12 +40,17 @@ import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-do import { VaultUpdate } from '../vault/types/vault' import { PaymentWithDonation } from './types/donation' import type { DonationWithPersonAndVault, PaymentWithDonationCount } from './types/donation' -import { PaymentData } from './helpers/payment-intent-helpers' +import { getCountryRegion, stripeFeeCalculator } from './helpers/stripe-fee-calculator' +import { PaymentData, getPaymentDataFromCharge } from './helpers/payment-intent-helpers' import { NotificationService, donationNotificationSelect, } from '../sockets/notifications/notification.service' -import { shouldAllowStatusChange } from './helpers/donation-status-updates' +import { + mapStripeStatusToInternal, + shouldAllowStatusChange, +} from './helpers/donation-status-updates' +import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts' @Injectable() export class DonationsService { @@ -1142,6 +1148,7 @@ export class DonationsService { private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { //first try to find by paymentIntentId + console.log(paymentData) let donation = await tx.payment.findUnique({ where: { extPaymentIntentId: paymentData.paymentIntentId }, include: { donations: true }, @@ -1170,4 +1177,39 @@ export class DonationsService { } return donation } + + async findDonationByStripeId(id: string) { + const charge = await this.stripeClient.charges.list({ payment_intent: id }) + if (!charge) throw new NotFoundException('Charge not found, by payment_intent') + const internalDonation = await this.prisma.payment.findFirst({ + where: { provider: 'stripe', extPaymentIntentId: id }, + }) + + const stripe = { + netAmount: Math.round( + charge.data[0].amount - + stripeFeeCalculator( + charge.data[0].amount, + getCountryRegion(charge.data[0].payment_method_details?.card?.country as string), + ), + ), + status: charge.data[0].status, + } + + return { + stripe: charge.data[0], + internal: internalDonation, + region: getCountryRegion(charge.data[0].payment_method_details?.card?.country as string), + } + } + + async syncPaymentWithStripe({ stripe }: CreateUpdatePaymentFromStripeChargeDto) { + const paymentData = getPaymentDataFromCharge(stripe) + + const campaignId = stripe.metadata?.campaignId + const campaign = await this.campaignService.getCampaignById(campaignId) + const newStatus = mapStripeStatusToInternal(stripe) + + this.updateDonationPayment(campaign, paymentData, newStatus) + } } diff --git a/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts b/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts new file mode 100644 index 00000000..83adb213 --- /dev/null +++ b/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Payment, PaymentStatus } from '@prisma/client' +import { Expose, Type } from 'class-transformer' +import { IsEnum, IsNumber, IsString } from 'class-validator' +import type Stripe from 'stripe' + +export class CreateUpdatePaymentFromStripeChargeDto { + @ApiProperty() + @Expose() + stripe: Stripe.Charge +} diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 6a799d32..5242858a 100644 --- a/apps/api/src/donations/helpers/donation-status-updates.ts +++ b/apps/api/src/donations/helpers/donation-status-updates.ts @@ -1,4 +1,5 @@ import { PaymentStatus } from '@prisma/client' +import Stripe from 'stripe' const initial: PaymentStatus[] = [PaymentStatus.initial] const changeable: PaymentStatus[] = [ @@ -58,3 +59,16 @@ export function shouldAllowStatusChange( throw new Error(`Unhandled donation status change from ${oldStatus} to ${newStatus}`) } + +/** + * Convert stripe status to one used internally + * @param charge Stripe.Charge object + * @returns + */ + +export function mapStripeStatusToInternal(charge: Stripe.Charge): PaymentStatus { + if (charge.refunded) return PaymentStatus.refund + if (charge.status === 'succeeded') return PaymentStatus.succeeded + if (charge.status === 'pending') return PaymentStatus.waiting + return PaymentStatus.declined +} diff --git a/apps/api/src/paypal/paypal.module.ts b/apps/api/src/paypal/paypal.module.ts index a48c04ad..59e07e5b 100644 --- a/apps/api/src/paypal/paypal.module.ts +++ b/apps/api/src/paypal/paypal.module.ts @@ -4,9 +4,10 @@ import { PaypalService } from './paypal.service' import { HttpModule } from '@nestjs/axios' import { CampaignModule } from '../campaign/campaign.module' import { ConfigService } from '@nestjs/config' +import { DonationsModule } from '../donations/donations.module' @Module({ - imports: [HttpModule, CampaignModule], + imports: [HttpModule, CampaignModule, DonationsModule], controllers: [PaypalController], providers: [PaypalService, ConfigService], exports: [PaypalService], diff --git a/apps/api/src/paypal/paypal.service.ts b/apps/api/src/paypal/paypal.service.ts index dc5c303d..18aefa37 100644 --- a/apps/api/src/paypal/paypal.service.ts +++ b/apps/api/src/paypal/paypal.service.ts @@ -3,11 +3,13 @@ import { ConfigService } from '@nestjs/config' import { CampaignService } from '../campaign/campaign.service' import { HttpService } from '@nestjs/axios' import { PaymentStatus, DonationType, PaymentProvider, PaymentType } from '@prisma/client' +import { DonationsService } from '../donations/donations.service' @Injectable() export class PaypalService { constructor( private campaignService: CampaignService, + private donationService: DonationsService, private config: ConfigService, private httpService: HttpService, ) {} @@ -26,7 +28,7 @@ export class PaypalService { // get campaign by id const campaign = await this.campaignService.getCampaignById(billingDetails.campaignId) - await this.campaignService.updateDonationPayment( + await this.donationService.updateDonationPayment( campaign, billingDetails, PaymentStatus.waiting, @@ -49,7 +51,7 @@ export class PaypalService { // get campaign by id const campaign = await this.campaignService.getCampaignById(billingDetails.campaignId) - await this.campaignService.updateDonationPayment( + await this.donationService.updateDonationPayment( campaign, billingDetails, PaymentStatus.succeeded, From 91863ab8e96d70449c854a2ac8f7deda21396f8f Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 9 Apr 2024 12:48:50 +0300 Subject: [PATCH 04/15] commit 4 --- .../bank-transactions.controller.ts | 11 ++- .../bank-transactions.service.ts | 12 +++ apps/api/src/campaign/campaign.service.ts | 8 +- .../src/campaign/dto/list-campaigns.dto.ts | 1 + apps/api/src/common/stringToUUID.ts | 5 ++ .../api/src/donations/donations.controller.ts | 11 +++ apps/api/src/donations/donations.service.ts | 74 +++++++++++++++++++ .../donations/dto/create-benevity-payment.ts | 70 ++++++++++++++++++ apps/api/src/vault/vault.service.ts | 3 +- package.json | 1 + yarn.lock | 8 ++ 11 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/common/stringToUUID.ts create mode 100644 apps/api/src/donations/dto/create-benevity-payment.ts diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 3355d8e7..e53f1a9f 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -16,7 +16,7 @@ import { } from '@nestjs/common' import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger' import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' -import { Roles, RoleMatchingMode, AuthenticatedUser } from 'nest-keycloak-connect' +import { Roles, RoleMatchingMode, AuthenticatedUser, Public } from 'nest-keycloak-connect' import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' import { BankTransactionsService } from './bank-transactions.service' import { @@ -68,6 +68,15 @@ export class BankTransactionsController { ) } + @Get(':id') + // @Roles({ + // roles: [RealmViewSupporters.role, ViewSupporters.role], + // mode: RoleMatchingMode.ANY, + // }) + @Public() + async findTransactionById(@Param('id') id: string) { + return await this.bankTransactionsService.findTransactionById(id) + } @Get('export-excel') @Roles({ roles: [RealmViewSupporters.role, ViewSupporters.role], diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index 3ee0f237..b41ba080 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -108,6 +108,18 @@ export class BankTransactionsService { return result } + /** + * Find bank transaction by id, whether it is internal, or external defined in the transaction's description. + * @param id Id of transaction + * @returns + */ + async findTransactionById(id: string): Promise { + return await this.prisma.bankTransaction.findFirst({ + where: { + OR: [{ id: id }, { description: { contains: id } }], + }, + }) + } /** * @param res - Response object to be used for the export to excel file */ diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 88c8e8ae..a76de82c 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -375,7 +375,7 @@ export class CampaignService { return campaign } - async getCampaignBySlug(slug: string): Promise { + async getCampaignBySlug(slug: string) { const includeFilter = { campaignType: { select: { name: true, slug: true, category: true }, @@ -401,6 +401,7 @@ export class CampaignService { person: { select: { id: true, firstName: true, lastName: true, email: true } }, }, }, + vaults: true, campaignFiles: true, } @@ -437,11 +438,6 @@ export class CampaignService { campaign['summary'] = this.getVaultAndDonationSummaries(campaign.id, campaignSums) campaign['campaignNews'] = await this.getCampaignNews(campaign.id) - const vault = await this.getCampaignVault(campaign.id) - if (vault) { - campaign['defaultVault'] = vault?.id - } - return campaign } diff --git a/apps/api/src/campaign/dto/list-campaigns.dto.ts b/apps/api/src/campaign/dto/list-campaigns.dto.ts index 1b6172a3..bfdaec62 100644 --- a/apps/api/src/campaign/dto/list-campaigns.dto.ts +++ b/apps/api/src/campaign/dto/list-campaigns.dto.ts @@ -15,6 +15,7 @@ export const AdminCampaignListItemSelect = Prisma.validator endDate: true, createdAt: true, updatedAt: true, + vaults: true, deletedAt: true, campaignType: { select: { diff --git a/apps/api/src/common/stringToUUID.ts b/apps/api/src/common/stringToUUID.ts new file mode 100644 index 00000000..3d096523 --- /dev/null +++ b/apps/api/src/common/stringToUUID.ts @@ -0,0 +1,5 @@ +import { v5 as uuidv5 } from 'uuid' +export function stringToUUID(str: string) { + const OIS_NAMESPACE = '6ba7b812-9dad-11d1-80b4-00c04fd430c8' + return uuidv5(str, OIS_NAMESPACE) +} diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index d6600117..c163d895 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -39,6 +39,7 @@ import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager' import { UseInterceptors } from '@nestjs/common' import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts' +import { CreateBenevityPaymentDto } from './dto/create-benevity-payment' @ApiTags('donation') @Controller('donation') @@ -323,6 +324,16 @@ export class DonationsController { return await this.donationsService.syncPaymentWithStripe(stripeChargeDto) } + @Post('import/benevity') + // @Roles({ + // roles: [EditFinancialsRequests.role], + // mode: RoleMatchingMode.ANY, + // }) + @Public() + async createDonationFromBenevity(@Body() benevityDto: CreateBenevityPaymentDto) { + return await this.donationsService.createFromBenevity(benevityDto) + } + @Patch(':id/sync-with-payment') @Roles({ roles: [EditFinancialsRequests.role], diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index ed8256e5..f30f172c 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config' import { InjectStripeClient } from '@golevelup/nestjs-stripe' import { BadRequestException, + ConflictException, ForbiddenException, Injectable, InternalServerErrorException, @@ -51,6 +52,8 @@ import { shouldAllowStatusChange, } from './helpers/donation-status-updates' import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts' +import { CreateBenevityPaymentDto } from './dto/create-benevity-payment' +import { stringToUUID } from '../common/stringToUUID' @Injectable() export class DonationsService { @@ -1212,4 +1215,75 @@ export class DonationsService { this.updateDonationPayment(campaign, paymentData, newStatus) } + + async createFromBenevity(benevityDto: CreateBenevityPaymentDto) { + const payment = await this.prisma.payment.findUnique({ + where: { extPaymentIntentId: benevityDto.extPaymentIntentId }, + }) + if (payment) + throw new ConflictException(`Payment with id ${payment.extPaymentIntentId} already exists`) + + const campaignSlug = benevityDto.benevityData.donations[0].projectRemoteId + const campaign = await this.campaignService.getCampaignBySlug(campaignSlug) + const personObj: Prisma.PersonCreateInput[] = [] + for (const donation of benevityDto.benevityData.donations) { + const donorString = `${donation.donorFirstName}-${donation.donorLastName}-${donation.email}` + const personId = stringToUUID(donorString) + personObj.push({ + id: personId, + firstName: donation.donorFirstName, + lastName: donation.donorLastName, + email: donation.email, + }) + } + if (!campaign) throw new NotFoundException(`Campaign with ${campaignSlug} not found`) + + const paymentData = Prisma.validator()({ + type: 'benevity', + extPaymentIntentId: benevityDto.extPaymentIntentId, + extPaymentMethodId: 'benevity', + billingName: 'UK ONLINE GIVING FOUNDATION', + extCustomerId: '', + amount: benevityDto.amount * 100, + status: PaymentStatus.succeeded, + donations: { + createMany: { + data: benevityDto.benevityData.donations.map((donation, index) => { + return { + type: DonationType.donation, + amount: donation.totalAmount * benevityDto.exchangeRate * 100, + targetVaultId: campaign.vaults[0].id, + personId: personObj[index].id, + } + }), + }, + }, + }) + + await this.prisma.$transaction( + personObj.map((person) => + this.prisma.person.upsert({ + where: { id: person.id }, + create: { + id: person.id, + firstName: person.firstName, + lastName: person.lastName, + email: person.email, + profileEnabled: false, + }, + update: {}, + }), + ), + ) + try { + await this.prisma.$transaction(async (tx) => { + await tx.payment.create({ data: paymentData }) + await this.vaultService.incrementVaultAmount(campaign.vaults[0].id, benevityDto.amount, tx) + }) + } catch (err) { + console.log(err) + } + // console.log(result) + // return result + } } diff --git a/apps/api/src/donations/dto/create-benevity-payment.ts b/apps/api/src/donations/dto/create-benevity-payment.ts new file mode 100644 index 00000000..f5af5f87 --- /dev/null +++ b/apps/api/src/donations/dto/create-benevity-payment.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose, Type } from 'class-transformer' +import { IsArray, IsNumber, IsObject, IsOptional, IsString, IsUUID } from 'class-validator' + +class BenevityDonationDto { + @ApiProperty() + @Expose() + @IsString() + projectRemoteId: string + + @ApiProperty() + @Expose() + @IsString() + @IsUUID() + transactionId: string + + @ApiProperty() + @Expose() + @IsNumber() + totalAmount: number + + @ApiProperty() + @Expose() + @IsOptional() + @IsString() + donorFirstName: string + + @ApiProperty() + @Expose() + @IsOptional() + @IsString() + donorLastName: string + + @ApiProperty() + @Expose() + @IsOptional() + @IsString() + email: string +} + +class BenevityDataDto { + @ApiProperty() + @Expose() + @IsArray() + @Type(() => BenevityDonationDto) + donations: BenevityDonationDto[] +} + +export class CreateBenevityPaymentDto { + @ApiProperty() + @Expose() + @IsNumber() + amount: number + + @ApiProperty() + @Expose() + @IsNumber() + exchangeRate: number + + @ApiProperty() + @Expose() + @IsString() + extPaymentIntentId: string + + @ApiProperty() + @Expose() + @IsObject() + @Type(() => BenevityDataDto) + benevityData: BenevityDataDto +} diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 9ed4b7c3..f301c91d 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -143,6 +143,7 @@ export class VaultService { amount: number, tx: Prisma.TransactionClient, ): Promise { + console.log(vaultId, amount) const vault = await this.updateVaultAmount(vaultId, amount, tx, 'increment') await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx) @@ -170,7 +171,7 @@ export class VaultService { if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } - + console.log(vaultId, amount, operationType) const updateStatement = { where: { id: vaultId }, data: { diff --git a/package.json b/package.json index a0067bbf..be9e56ce 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@types/mjml": "^4.7.1", "@types/multer": "1.4.7", "@types/node": "16.9.1", + "@types/uuid": "^9.0.8", "@types/xml2js": "^0", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", diff --git a/yarn.lock b/yarn.lock index 33177a03..ec691914 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5422,6 +5422,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.8": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: b8c60b7ba8250356b5088302583d1704a4e1a13558d143c549c408bf8920535602ffc12394ede77f8a8083511b023704bc66d1345792714002bfa261b17c5275 + languageName: node + linkType: hard + "@types/validator@npm:^13.7.10": version: 13.7.13 resolution: "@types/validator@npm:13.7.13" @@ -13626,6 +13633,7 @@ __metadata: "@types/mjml": ^4.7.1 "@types/multer": 1.4.7 "@types/node": 16.9.1 + "@types/uuid": ^9.0.8 "@types/xml2js": ^0 "@typescript-eslint/eslint-plugin": ^5.48.1 "@typescript-eslint/parser": ^5.48.1 From 8cd7c0ff2f88efc2fd5bfc2dd490be1ddd3d0d5a Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Thu, 20 Jun 2024 10:46:09 +0300 Subject: [PATCH 05/15] some changes i dont recall --- apps/api/src/donations/donations.service.ts | 24 +++++++------------ ...pdate-payment-from-stripe-charge.dto.ts.ts | 4 +--- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index f30f172c..7e749efa 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -1182,27 +1182,20 @@ export class DonationsService { } async findDonationByStripeId(id: string) { - const charge = await this.stripeClient.charges.list({ payment_intent: id }) + const charge = await this.stripeClient.charges.retrieve(id, { expand: ['payment_intent'] }) + console.log(charge) if (!charge) throw new NotFoundException('Charge not found, by payment_intent') const internalDonation = await this.prisma.payment.findFirst({ - where: { provider: 'stripe', extPaymentIntentId: id }, + where: { + provider: 'stripe', + extPaymentIntentId: (charge.payment_intent as Stripe.PaymentIntent).id, + }, }) - const stripe = { - netAmount: Math.round( - charge.data[0].amount - - stripeFeeCalculator( - charge.data[0].amount, - getCountryRegion(charge.data[0].payment_method_details?.card?.country as string), - ), - ), - status: charge.data[0].status, - } - return { - stripe: charge.data[0], + stripe: charge, internal: internalDonation, - region: getCountryRegion(charge.data[0].payment_method_details?.card?.country as string), + region: getCountryRegion(charge.payment_method_details?.card?.country as string), } } @@ -1269,7 +1262,6 @@ export class DonationsService { firstName: person.firstName, lastName: person.lastName, email: person.email, - profileEnabled: false, }, update: {}, }), diff --git a/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts b/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts index 83adb213..47f22201 100644 --- a/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts +++ b/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts @@ -1,7 +1,5 @@ import { ApiProperty } from '@nestjs/swagger' -import { Payment, PaymentStatus } from '@prisma/client' -import { Expose, Type } from 'class-transformer' -import { IsEnum, IsNumber, IsString } from 'class-validator' +import { Expose } from 'class-transformer' import type Stripe from 'stripe' export class CreateUpdatePaymentFromStripeChargeDto { From 09c80c58c14f65f389bb2f8002ca2311ea79fa54 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 22 Jun 2024 16:22:33 +0300 Subject: [PATCH 06/15] chore: Remove redundant controller fn --- apps/api/src/donations/donations.controller.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index f4417f49..20ae9621 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -261,15 +261,6 @@ export class DonationsController { return this.donationsService.refundStripePayment(paymentIntentId) } - @Get('/payments/:id') - @Roles({ - roles: [RealmViewSupporters.role, ViewSupporters.role], - mode: RoleMatchingMode.ANY, - }) - getPaymentByPaymentIntentId(@Param('id') paymentIntentId: string) { - return this.donationsService.getDonationById(paymentIntentId) - } - @Post('create-bank-payment') @Roles({ roles: [RealmViewSupporters.role, ViewSupporters.role], From c6cca4bc00c88064e215a3076042a7a744b48cfe Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 22 Jun 2024 16:23:23 +0300 Subject: [PATCH 07/15] chore: Change person insertions strategy The initial idea of upsert was to prevent error on conflicting user. createMany has skipDuplicate flags which does the same job in cleaner way --- apps/api/src/donations/donations.service.ts | 27 +++++++++------------ 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 32aaeb41..9dd0d63e 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -1213,6 +1213,10 @@ export class DonationsService { const campaignSlug = benevityDto.benevityData.donations[0].projectRemoteId const campaign = await this.campaignService.getCampaignBySlug(campaignSlug) + + if (!campaign) throw new NotFoundException(`Campaign with ${campaignSlug} not found`) + + // Prepare person records for bulk insertion const personObj: Prisma.PersonCreateInput[] = [] for (const donation of benevityDto.benevityData.donations) { const donorString = `${donation.donorFirstName}-${donation.donorLastName}-${donation.email}` @@ -1224,8 +1228,8 @@ export class DonationsService { email: donation.email, }) } - if (!campaign) throw new NotFoundException(`Campaign with ${campaignSlug} not found`) + //Prepare payment and donation records const paymentData = Prisma.validator()({ type: 'benevity', extPaymentIntentId: benevityDto.extPaymentIntentId, @@ -1248,24 +1252,15 @@ export class DonationsService { }, }) - await this.prisma.$transaction( - personObj.map((person) => - this.prisma.person.upsert({ - where: { id: person.id }, - create: { - id: person.id, - firstName: person.firstName, - lastName: person.lastName, - email: person.email, - }, - update: {}, - }), - ), - ) try { await this.prisma.$transaction(async (tx) => { + await tx.person.createMany({ data: personObj, skipDuplicates: true }) await tx.payment.create({ data: paymentData }) - await this.vaultService.incrementVaultAmount(campaign.vaults[0].id, benevityDto.amount, tx) + await this.vaultService.incrementVaultAmount( + campaign.vaults[0].id, + benevityDto.amount * 100, + tx, + ) }) } catch (err) { console.log(err) From b018f162df800631e9ec52bb4f8148b0139af94f Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 22 Jun 2024 16:26:30 +0300 Subject: [PATCH 08/15] chore: Remove redundant console logs --- apps/api/src/vault/vault.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index f301c91d..9ce20a58 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -143,7 +143,6 @@ export class VaultService { amount: number, tx: Prisma.TransactionClient, ): Promise { - console.log(vaultId, amount) const vault = await this.updateVaultAmount(vaultId, amount, tx, 'increment') await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx) @@ -171,7 +170,6 @@ export class VaultService { if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } - console.log(vaultId, amount, operationType) const updateStatement = { where: { id: vaultId }, data: { From 03bc5f0759e6932fd5b5fa423c65c2628b459bca Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 23 Jun 2024 18:32:59 +0300 Subject: [PATCH 09/15] chore: Logic improvements --- .../api/src/donations/donations.controller.ts | 3 ++- apps/api/src/donations/donations.service.ts | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index 20ae9621..61b8317f 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -40,6 +40,7 @@ import { UseInterceptors } from '@nestjs/common' import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts' import { CreateBenevityPaymentDto } from './dto/create-benevity-payment' +import Stripe from 'stripe' @ApiTags('donation') @Controller('donation') @@ -311,7 +312,7 @@ export class DonationsController { // mode: RoleMatchingMode.ANY, // }) @Public() - async syncWithPaymentWithStripe(@Body() stripeChargeDto: CreateUpdatePaymentFromStripeChargeDto) { + async syncWithPaymentWithStripe(@Body() stripeChargeDto: Stripe.Charge) { return await this.donationsService.syncPaymentWithStripe(stripeChargeDto) } diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 9dd0d63e..e4aacd6d 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -1177,13 +1177,12 @@ export class DonationsService { } async findDonationByStripeId(id: string) { - const charge = await this.stripeClient.charges.retrieve(id, { expand: ['payment_intent'] }) - console.log(charge) + const charge = await this.stripeClient.charges.retrieve(id) if (!charge) throw new NotFoundException('Charge not found, by payment_intent') const internalDonation = await this.prisma.payment.findFirst({ where: { provider: 'stripe', - extPaymentIntentId: (charge.payment_intent as Stripe.PaymentIntent).id, + extPaymentIntentId: charge.payment_intent as string, }, }) @@ -1194,13 +1193,12 @@ export class DonationsService { } } - async syncPaymentWithStripe({ stripe }: CreateUpdatePaymentFromStripeChargeDto) { - const paymentData = getPaymentDataFromCharge(stripe) + async syncPaymentWithStripe(stripeChargeDto: Stripe.Charge) { + const paymentData = getPaymentDataFromCharge(stripeChargeDto) - const campaignId = stripe.metadata?.campaignId + const campaignId = stripeChargeDto.metadata?.campaignId const campaign = await this.campaignService.getCampaignById(campaignId) - const newStatus = mapStripeStatusToInternal(stripe) - + const newStatus = mapStripeStatusToInternal(stripeChargeDto) this.updateDonationPayment(campaign, paymentData, newStatus) } @@ -1229,7 +1227,7 @@ export class DonationsService { }) } - //Prepare payment and donation records + //Prepare payment and donation records for bulk insertion const paymentData = Prisma.validator()({ type: 'benevity', extPaymentIntentId: benevityDto.extPaymentIntentId, @@ -1241,11 +1239,14 @@ export class DonationsService { donations: { createMany: { data: benevityDto.benevityData.donations.map((donation, index) => { + const isAnonymous = + personObj[index].firstName === 'Not shared by donor' || + personObj[index].lastName === 'Not shared by donor' return { type: DonationType.donation, amount: donation.totalAmount * benevityDto.exchangeRate * 100, targetVaultId: campaign.vaults[0].id, - personId: personObj[index].id, + personId: !isAnonymous ? personObj[index].id : null, } }), }, From e1d808b97da729146f7b8fab263f7a022ad55e0b Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 24 Jun 2024 17:09:39 +0300 Subject: [PATCH 10/15] chore: Add benevity as payment provider --- apps/api/src/donations/donations.service.ts | 1 + .../migration.sql | 2 ++ podkrepi.dbml | 3 +++ schema.prisma | 1 + 4 files changed, 7 insertions(+) create mode 100644 migrations/20240624140843_add_benevity_as_payment_provider/migration.sql diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index e4aacd6d..724d77d9 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -1232,6 +1232,7 @@ export class DonationsService { type: 'benevity', extPaymentIntentId: benevityDto.extPaymentIntentId, extPaymentMethodId: 'benevity', + provider: 'benevity', billingName: 'UK ONLINE GIVING FOUNDATION', extCustomerId: '', amount: benevityDto.amount * 100, diff --git a/migrations/20240624140843_add_benevity_as_payment_provider/migration.sql b/migrations/20240624140843_add_benevity_as_payment_provider/migration.sql new file mode 100644 index 00000000..99be9ad5 --- /dev/null +++ b/migrations/20240624140843_add_benevity_as_payment_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "payment_provider" ADD VALUE 'benevity'; diff --git a/podkrepi.dbml b/podkrepi.dbml index 30dc8acd..3159a540 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -600,6 +600,8 @@ Table campaign_applications { category CampaignTypeCategory [default: 'others'] ticketURL String archived Boolean [default: false] + + Note: 'CampaignApplication represents a request for a new campaign - it is not a Campaign yet and has to proove it needs to be' } Table campaign_application_files { @@ -695,6 +697,7 @@ Enum PaymentProvider { epay bank cash + benevity } Enum DocumentType { diff --git a/schema.prisma b/schema.prisma index 131271ca..d4bb4cf5 100644 --- a/schema.prisma +++ b/schema.prisma @@ -795,6 +795,7 @@ enum PaymentProvider { epay bank cash + benevity @@map("payment_provider") } From 5964977992871c1471bb694199d0fdde7c994d97 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 25 Jun 2024 18:07:20 +0300 Subject: [PATCH 11/15] chore: Add roles to the manual payment creation endpoints --- .../api/src/donations/donations.controller.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index 61b8317f..88be40d7 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -301,27 +301,28 @@ export class DonationsController { } @Get('stripe/:id/') - @Public() + @Roles({ + roles: [EditFinancialsRequests.role], + mode: RoleMatchingMode.ANY, + }) async findStripePayment(@Param('id') stripeId: string) { return await this.donationsService.findDonationByStripeId(stripeId) } @Put('create-update-stripe-payment') - // @Roles({ - // roles: [EditFinancialsRequests.role], - // mode: RoleMatchingMode.ANY, - // }) - @Public() + @Roles({ + roles: [EditFinancialsRequests.role], + mode: RoleMatchingMode.ANY, + }) async syncWithPaymentWithStripe(@Body() stripeChargeDto: Stripe.Charge) { return await this.donationsService.syncPaymentWithStripe(stripeChargeDto) } @Post('import/benevity') - // @Roles({ - // roles: [EditFinancialsRequests.role], - // mode: RoleMatchingMode.ANY, - // }) - @Public() + @Roles({ + roles: [EditFinancialsRequests.role], + mode: RoleMatchingMode.ANY, + }) async createDonationFromBenevity(@Body() benevityDto: CreateBenevityPaymentDto) { return await this.donationsService.createFromBenevity(benevityDto) } From e80f63f61758795753c0485e222f09fdd3d470e9 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 25 Jun 2024 18:39:43 +0300 Subject: [PATCH 12/15] Partially revert 7dde7aa This refactor will be handled by another PR --- apps/api/src/campaign/campaign.service.ts | 228 +++++++++++++++++- apps/api/src/donations/donations.service.ts | 190 --------------- .../events/stripe-payment.service.spec.ts | 2 + apps/api/src/paypal/paypal.controller.spec.ts | 1 + apps/api/src/paypal/paypal.module.ts | 3 +- apps/api/src/paypal/paypal.service.ts | 6 +- 6 files changed, 232 insertions(+), 198 deletions(-) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 2c1e0e32..d487e519 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -375,7 +375,7 @@ export class CampaignService { return campaign } - async getCampaignBySlug(slug: string) { + async getCampaignBySlug(slug: string): Promise { const includeFilter = { campaignType: { select: { name: true, slug: true, category: true }, @@ -409,7 +409,6 @@ export class CampaignService { }, }, }, - vaults: true, campaignFiles: true, } @@ -446,6 +445,11 @@ export class CampaignService { campaign['summary'] = this.getVaultAndDonationSummaries(campaign.id, campaignSums) campaign['campaignNews'] = await this.getCampaignNews(campaign.id) + const vault = await this.getCampaignVault(campaign.id) + if (vault) { + campaign['defaultVault'] = vault?.id + } + return campaign } @@ -574,6 +578,226 @@ export class CampaignService { return this.prisma.payment.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) } + /** + * Creates or Updates an incoming donation depending on the newDonationStatus attribute + * @param campaign + * @param paymentData + * @param newDonationStatus + * @param metadata + * @returns donation.id of the created/updated donation + */ + async updateDonationPayment( + campaign: Campaign, + paymentData: PaymentData, + newDonationStatus: PaymentStatus, + ): Promise { + const campaignId = campaign.id + Logger.debug('Update donation to status: ' + newDonationStatus, { + campaignId, + paymentIntentId: paymentData.paymentIntentId, + }) + + //Update existing donation or create new in a transaction that + //also increments the vault amount and marks campaign as completed + //if target amount is reached + return await this.prisma.$transaction(async (tx) => { + let donationId + // Find donation by extPaymentIntentId + const existingDonation = await this.findExistingDonation(tx, paymentData) + + //if missing create the donation with the incoming status + if (!existingDonation) { + const newDonation = await this.createIncomingDonation( + tx, + paymentData, + newDonationStatus, + campaign, + ) + donationId = newDonation.id + } + //donation exists, so check if it is safe to update it + else { + const updatedDonation = await this.updateDonationIfAllowed( + tx, + existingDonation, + newDonationStatus, + paymentData, + ) + donationId = updatedDonation?.id + } + + return donationId + }) //end of the transaction scope + } + + private async updateDonationIfAllowed( + tx: Prisma.TransactionClient, + payment: PaymentWithDonation, + newDonationStatus: PaymentStatus, + paymentData: PaymentData, + ) { + if (shouldAllowStatusChange(payment.status, newDonationStatus)) { + try { + const updatedDonation = await tx.payment.update({ + where: { + id: payment.id, + }, + data: { + status: newDonationStatus, + amount: paymentData.netAmount, + extCustomerId: paymentData.stripeCustomerId, + extPaymentMethodId: paymentData.paymentMethodId, + extPaymentIntentId: paymentData.paymentIntentId, + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + donations: { + updateMany: { + where: { paymentId: payment.id }, + data: { + amount: paymentData.netAmount, + }, + }, + }, + }, + select: donationNotificationSelect, + }) + + //if donation is switching to successful, increment the vault amount and send notification + if ( + payment.status != PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.succeeded + ) { + await this.vaultService.incrementVaultAmount( + payment.donations[0].targetVaultId, + paymentData.netAmount, + tx, + ) + this.notificationService.sendNotification('successfulDonation', { + ...updatedDonation, + person: updatedDonation.donations[0].person, + }) + } else if ( + payment.status === PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.refund + ) { + await this.vaultService.decrementVaultAmount( + payment.donations[0].targetVaultId, + paymentData.netAmount, + tx, + ) + this.notificationService.sendNotification('successfulRefund', { + ...updatedDonation, + person: updatedDonation.donations[0].person, + }) + } + return updatedDonation + } catch (error) { + Logger.error( + `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, + ) + throw new InternalServerErrorException(error) + } + } + //donation exists but we need to skip because previous status is from later event than the incoming + else { + Logger.warn( + `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} + and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, + ) + } + } + + private async createIncomingDonation( + tx: Prisma.TransactionClient, + paymentData: PaymentData, + newDonationStatus: PaymentStatus, + campaign: Campaign, + ) { + Logger.debug( + 'No donation exists with extPaymentIntentId: ' + + paymentData.paymentIntentId + + ' Creating new donation with status: ' + + newDonationStatus, + ) + + const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) + const targetVaultData = { connect: { id: vault.id } } + + try { + const donation = await tx.payment.create({ + data: { + amount: paymentData.netAmount, + chargedAmount: paymentData.chargedAmount, + currency: campaign.currency, + provider: paymentData.paymentProvider, + type: PaymentType.single, + status: newDonationStatus, + extCustomerId: paymentData.stripeCustomerId ?? '', + extPaymentIntentId: paymentData.paymentIntentId, + extPaymentMethodId: paymentData.paymentMethodId ?? '', + billingName: paymentData.billingName, + billingEmail: paymentData.billingEmail, + donations: { + create: { + amount: paymentData.netAmount, + type: paymentData.type as DonationType, + person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, + targetVault: targetVaultData, + }, + }, + }, + select: donationNotificationSelect, + }) + + if (newDonationStatus === PaymentStatus.succeeded) { + await this.vaultService.incrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) + this.notificationService.sendNotification('successfulDonation', donation) + } + + return donation + } catch (error) { + Logger.error( + `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, + ) + throw new InternalServerErrorException(error) + } + } + + private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { + //first try to find by paymentIntentId + let donation = await tx.payment.findUnique({ + where: { extPaymentIntentId: paymentData.paymentIntentId }, + include: { donations: true }, + }) + + // if not found by paymentIntent, check for if this is payment on subscription + // check for UUID length of personId + // subscriptions always have a personId + if (!donation && paymentData.personId && paymentData.personId.length === 36) { + // search for a subscription donation + // for subscriptions, we don't have a paymentIntentId + donation = await tx.payment.findFirst({ + where: { + status: PaymentStatus.initial, + chargedAmount: paymentData.chargedAmount, + extPaymentMethodId: 'subscription', + donations: { + some: { + personId: paymentData.personId, + }, + }, + }, + include: { donations: true }, + }) + Logger.debug('Donation found by subscription: ', donation) + } + return donation + } + async createDonationWish(wish: string, donationId: string, campaignId: string) { const person = await this.prisma.donation.findUnique({ where: { id: donationId } }).person() await this.prisma.donationWish.upsert({ diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 724d77d9..426d0f53 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -955,198 +955,8 @@ export class DonationsService { }) } - /** - * Creates or Updates an incoming donation depending on the newDonationStatus attribute - * @param campaign - * @param paymentData - * @param newDonationStatus - * @param metadata - * @returns donation.id of the created/updated donation - */ - async updateDonationPayment( - campaign: Campaign, - paymentData: PaymentData, - newDonationStatus: PaymentStatus, - ): Promise { - const campaignId = campaign.id - Logger.debug('Update donation to status: ' + newDonationStatus, { - campaignId, - paymentIntentId: paymentData.paymentIntentId, - }) - - //Update existing donation or create new in a transaction that - //also increments the vault amount and marks campaign as completed - //if target amount is reached - return await this.prisma.$transaction(async (tx) => { - let donationId - // Find donation by extPaymentIntentId - const existingDonation = await this.findExistingDonation(tx, paymentData) - - //if missing create the donation with the incoming status - if (!existingDonation) { - const newDonation = await this.createIncomingDonation( - tx, - paymentData, - newDonationStatus, - campaign, - ) - donationId = newDonation.id - } - //donation exists, so check if it is safe to update it - else { - const updatedDonation = await this.updateDonationIfAllowed( - tx, - existingDonation, - newDonationStatus, - paymentData, - ) - donationId = updatedDonation?.id - } - - return donationId - }) //end of the transaction scope - } - - private async updateDonationIfAllowed( - tx: Prisma.TransactionClient, - payment: PaymentWithDonation, - newDonationStatus: PaymentStatus, - paymentData: PaymentData, - ) { - if (shouldAllowStatusChange(payment.status, newDonationStatus)) { - try { - const updatedDonation = await tx.payment.update({ - where: { - id: payment.id, - }, - data: { - status: newDonationStatus, - amount: paymentData.netAmount, - extCustomerId: paymentData.stripeCustomerId, - extPaymentMethodId: paymentData.paymentMethodId, - extPaymentIntentId: paymentData.paymentIntentId, - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - donations: { - updateMany: { - where: { paymentId: payment.id }, - data: { - amount: paymentData.netAmount, - }, - }, - }, - }, - select: donationNotificationSelect, - }) - - //if donation is switching to successful, increment the vault amount and send notification - if ( - payment.status != PaymentStatus.succeeded && - newDonationStatus === PaymentStatus.succeeded - ) { - await this.vaultService.incrementVaultAmount( - payment.donations[0].targetVaultId, - paymentData.netAmount, - tx, - ) - this.notificationService.sendNotification('successfulDonation', { - ...updatedDonation, - person: updatedDonation.donations[0].person, - }) - } else if ( - payment.status === PaymentStatus.succeeded && - newDonationStatus === PaymentStatus.refund - ) { - await this.vaultService.decrementVaultAmount( - payment.donations[0].targetVaultId, - paymentData.netAmount, - tx, - ) - this.notificationService.sendNotification('successfulRefund', { - ...updatedDonation, - person: updatedDonation.donations[0].person, - }) - } - return updatedDonation - } catch (error) { - Logger.error( - `Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`, - ) - throw new InternalServerErrorException(error) - } - } - //donation exists but we need to skip because previous status is from later event than the incoming - else { - Logger.warn( - `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} - and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, - ) - } - } - - private async createIncomingDonation( - tx: Prisma.TransactionClient, - paymentData: PaymentData, - newDonationStatus: PaymentStatus, - campaign: Campaign, - ) { - Logger.debug( - 'No donation exists with extPaymentIntentId: ' + - paymentData.paymentIntentId + - ' Creating new donation with status: ' + - newDonationStatus, - ) - - const vault = await tx.vault.findFirstOrThrow({ where: { campaignId: campaign.id } }) - const targetVaultData = { connect: { id: vault.id } } - - try { - const donation = await tx.payment.create({ - data: { - amount: paymentData.netAmount, - chargedAmount: paymentData.chargedAmount, - currency: campaign.currency, - provider: paymentData.paymentProvider, - type: PaymentType.single, - status: newDonationStatus, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: paymentData.paymentMethodId ?? '', - billingName: paymentData.billingName, - billingEmail: paymentData.billingEmail, - donations: { - create: { - amount: paymentData.netAmount, - type: paymentData.type as DonationType, - person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, - targetVault: targetVaultData, - }, - }, - }, - select: donationNotificationSelect, - }) - - if (newDonationStatus === PaymentStatus.succeeded) { - await this.vaultService.incrementVaultAmount( - donation.donations[0].targetVaultId, - donation.amount, - tx, - ) - this.notificationService.sendNotification('successfulDonation', donation) - } - - return donation - } catch (error) { - Logger.error( - `Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`, - ) - throw new InternalServerErrorException(error) - } - } - private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { //first try to find by paymentIntentId - console.log(paymentData) let donation = await tx.payment.findUnique({ where: { extPaymentIntentId: paymentData.paymentIntentId }, include: { donations: true }, 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 a1308cb3..254a9116 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -53,6 +53,7 @@ import { EmailService } from '../../email/email.service' import { TemplateService } from '../../email/template.service' import type { PaymentWithDonation } from '../types/donation' import { DonationsService } from '../donations.service' +import { ExportService } from '../../export/export.service' const defaultStripeWebhookEndpoint = '/stripe/webhook' const stripeSecret = 'wh_123' @@ -137,6 +138,7 @@ describe('StripePaymentService', () => { VaultService, PersonService, RecurringDonationService, + ExportService, { provide: HttpService, useValue: mockDeep(), diff --git a/apps/api/src/paypal/paypal.controller.spec.ts b/apps/api/src/paypal/paypal.controller.spec.ts index cf46e3f4..56903f54 100644 --- a/apps/api/src/paypal/paypal.controller.spec.ts +++ b/apps/api/src/paypal/paypal.controller.spec.ts @@ -8,6 +8,7 @@ import { HttpModule } from '@nestjs/axios' import { NotificationModule } from '../sockets/notifications/notification.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { DonationsService } from '../donations/donations.service' describe('PaypalController', () => { let controller: PaypalController diff --git a/apps/api/src/paypal/paypal.module.ts b/apps/api/src/paypal/paypal.module.ts index 59e07e5b..a48c04ad 100644 --- a/apps/api/src/paypal/paypal.module.ts +++ b/apps/api/src/paypal/paypal.module.ts @@ -4,10 +4,9 @@ import { PaypalService } from './paypal.service' import { HttpModule } from '@nestjs/axios' import { CampaignModule } from '../campaign/campaign.module' import { ConfigService } from '@nestjs/config' -import { DonationsModule } from '../donations/donations.module' @Module({ - imports: [HttpModule, CampaignModule, DonationsModule], + imports: [HttpModule, CampaignModule], controllers: [PaypalController], providers: [PaypalService, ConfigService], exports: [PaypalService], diff --git a/apps/api/src/paypal/paypal.service.ts b/apps/api/src/paypal/paypal.service.ts index 18aefa37..dc5c303d 100644 --- a/apps/api/src/paypal/paypal.service.ts +++ b/apps/api/src/paypal/paypal.service.ts @@ -3,13 +3,11 @@ import { ConfigService } from '@nestjs/config' import { CampaignService } from '../campaign/campaign.service' import { HttpService } from '@nestjs/axios' import { PaymentStatus, DonationType, PaymentProvider, PaymentType } from '@prisma/client' -import { DonationsService } from '../donations/donations.service' @Injectable() export class PaypalService { constructor( private campaignService: CampaignService, - private donationService: DonationsService, private config: ConfigService, private httpService: HttpService, ) {} @@ -28,7 +26,7 @@ export class PaypalService { // get campaign by id const campaign = await this.campaignService.getCampaignById(billingDetails.campaignId) - await this.donationService.updateDonationPayment( + await this.campaignService.updateDonationPayment( campaign, billingDetails, PaymentStatus.waiting, @@ -51,7 +49,7 @@ export class PaypalService { // get campaign by id const campaign = await this.campaignService.getCampaignById(billingDetails.campaignId) - await this.donationService.updateDonationPayment( + await this.campaignService.updateDonationPayment( campaign, billingDetails, PaymentStatus.succeeded, From d581aade5109e8f80770ae28883d2ff008e966e0 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 25 Jun 2024 18:41:29 +0300 Subject: [PATCH 13/15] chore: Remove unused fn --- apps/api/src/donations/donations.service.ts | 31 --------------------- 1 file changed, 31 deletions(-) diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 426d0f53..d2d92b9a 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -955,37 +955,6 @@ export class DonationsService { }) } - private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { - //first try to find by paymentIntentId - let donation = await tx.payment.findUnique({ - where: { extPaymentIntentId: paymentData.paymentIntentId }, - include: { donations: true }, - }) - - // if not found by paymentIntent, check for if this is payment on subscription - // check for UUID length of personId - // subscriptions always have a personId - if (!donation && paymentData.personId && paymentData.personId.length === 36) { - // search for a subscription donation - // for subscriptions, we don't have a paymentIntentId - donation = await tx.payment.findFirst({ - where: { - status: PaymentStatus.initial, - chargedAmount: paymentData.chargedAmount, - extPaymentMethodId: 'subscription', - donations: { - some: { - personId: paymentData.personId, - }, - }, - }, - include: { donations: true }, - }) - Logger.debug('Donation found by subscription: ', donation) - } - return donation - } - async findDonationByStripeId(id: string) { const charge = await this.stripeClient.charges.retrieve(id) if (!charge) throw new NotFoundException('Charge not found, by payment_intent') From b00abf65a3c8d1c93e57af27ed40b77115acd237 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 25 Jun 2024 18:51:55 +0300 Subject: [PATCH 14/15] fix: tests and errors after e80f63f --- apps/api/src/campaign/campaign.service.ts | 3 +- apps/api/src/donations/donations.service.ts | 2 +- .../events/stripe-payment.service.spec.ts | 34 ++++++++++--------- .../events/stripe-payment.service.ts | 11 +++--- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index d487e519..874826d2 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -375,7 +375,7 @@ export class CampaignService { return campaign } - async getCampaignBySlug(slug: string): Promise { + async getCampaignBySlug(slug: string) { const includeFilter = { campaignType: { select: { name: true, slug: true, category: true }, @@ -409,6 +409,7 @@ export class CampaignService { }, }, }, + vaults: true, campaignFiles: true, } diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index d2d92b9a..22d7ee1d 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -978,7 +978,7 @@ export class DonationsService { const campaignId = stripeChargeDto.metadata?.campaignId const campaign = await this.campaignService.getCampaignById(campaignId) const newStatus = mapStripeStatusToInternal(stripeChargeDto) - this.updateDonationPayment(campaign, paymentData, newStatus) + this.campaignService.updateDonationPayment(campaign, paymentData, newStatus) } async createFromBenevity(benevityDto: CreateBenevityPaymentDto) { 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 254a9116..499f82bc 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -52,16 +52,12 @@ import { MarketingNotificationsService } from '../../notifications/notifications import { EmailService } from '../../email/email.service' import { TemplateService } from '../../email/template.service' import type { PaymentWithDonation } from '../types/donation' -import { DonationsService } from '../donations.service' -import { ExportService } from '../../export/export.service' const defaultStripeWebhookEndpoint = '/stripe/webhook' const stripeSecret = 'wh_123' describe('StripePaymentService', () => { let stripePaymentService: StripePaymentService - let campaignService: CampaignService - let donationService: DonationsService let app: INestApplication const stripe = new Stripe(stripeSecret, { apiVersion: '2022-11-15' }) @@ -133,12 +129,10 @@ describe('StripePaymentService', () => { ConfigService, StripePaymentService, CampaignService, - DonationsService, MockPrismaService, VaultService, PersonService, RecurringDonationService, - ExportService, { provide: HttpService, useValue: mockDeep(), @@ -153,8 +147,6 @@ describe('StripePaymentService', () => { await app.init() stripePaymentService = app.get(StripePaymentService) - campaignService = app.get(CampaignService) - donationService = app.get(DonationsService) //this intercepts the request raw body and removes the exact signature check const stripePayloadService = app.get(StripePayloadService) @@ -178,6 +170,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -185,7 +178,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventCreated.data.object as Stripe.PaymentIntent) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -218,6 +211,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -227,7 +221,7 @@ describe('StripePaymentService', () => { ) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -255,6 +249,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -262,7 +257,7 @@ describe('StripePaymentService', () => { const paymentData = getPaymentData(mockPaymentEventFailed.data.object as Stripe.PaymentIntent) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') @@ -293,6 +288,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -327,7 +323,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') @@ -374,6 +370,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -401,7 +398,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') @@ -431,7 +428,9 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) + const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignById') .mockImplementation(() => Promise.resolve(mockedCampaign)) @@ -459,7 +458,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') const mockDecremementVaultAmount = jest.spyOn(vaultService, 'decrementVaultAmount') @@ -503,6 +502,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const recurring = app.get(RecurringDonationService) const mockedCampaignById = jest .spyOn(campaignService, 'getCampaignVault') @@ -561,6 +561,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const vaultService = app.get(VaultService) const mockedCampaignById = jest @@ -570,7 +571,7 @@ describe('StripePaymentService', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') prismaMock.payment.findFirst.mockResolvedValue({ @@ -613,6 +614,7 @@ describe('StripePaymentService', () => { secret: stripeSecret, }) + const campaignService = app.get(CampaignService) const recurring = app.get(RecurringDonationService) const mockCancelSubscription = jest @@ -624,7 +626,7 @@ describe('StripePaymentService', () => { .mockImplementation(() => Promise.resolve(mockedCampaignCompeleted)) const mockedUpdateDonationPayment = jest - .spyOn(donationService, 'updateDonationPayment') + .spyOn(campaignService, 'updateDonationPayment') .mockImplementation(() => Promise.resolve('')) .mockName('updateDonationPayment') diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 86692ef1..e7e4eef8 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -27,7 +27,6 @@ import { DonationsService } from '../donations.service' @Injectable() export class StripePaymentService { constructor( - private donationService: DonationsService, private campaignService: CampaignService, private recurringDonationService: RecurringDonationService, private sendEmail: EmailService, @@ -64,7 +63,7 @@ export class StripePaymentService { /* * Handle the create event */ - await this.donationService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) + await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) } @StripeWebhookHandler('payment_intent.canceled') @@ -110,7 +109,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus) + await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus) } @StripeWebhookHandler('charge.succeeded') @@ -137,7 +136,7 @@ export class StripePaymentService { const billingData = getPaymentDataFromCharge(charge) - const donationId = await this.donationService.updateDonationPayment( + const donationId = await this.campaignService.updateDonationPayment( campaign, billingData, PaymentStatus.succeeded, @@ -172,7 +171,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.donationService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) + await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } @@ -365,7 +364,7 @@ export class StripePaymentService { const paymentData = getInvoiceData(invoice) - await this.donationService.updateDonationPayment(campaign, paymentData, PaymentStatus.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) From 4a580aeef278a23e79ef9dd176186d2867b1686b Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Tue, 25 Jun 2024 18:52:58 +0300 Subject: [PATCH 15/15] chore: Protect bank-transaction GET endpoint --- .../bank-transactions/bank-transactions.controller.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index e53f1a9f..8e6d4998 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -69,11 +69,10 @@ export class BankTransactionsController { } @Get(':id') - // @Roles({ - // roles: [RealmViewSupporters.role, ViewSupporters.role], - // mode: RoleMatchingMode.ANY, - // }) - @Public() + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) async findTransactionById(@Param('id') id: string) { return await this.bankTransactionsService.findTransactionById(id) }