Skip to content

Commit

Permalink
fix campaign completion transaction (#536)
Browse files Browse the repository at this point in the history
* deleted manual donation create method as it is not allowed

* added db transaction when updatingDonation that encapsulates the completion of campaign when targetAmount is reached

* fixed existing tests

* fixed tests with the expected update vault

* linter cleanup

* removed logging of the full CreateCheckoutSession

* simplified updateDonationPayment function by breaking it into smaller ones, so now it is more readable

* Update apps/api/src/donations/events/stripe-payment.service.spec.ts

Co-authored-by: Slavcho Ivanov <[email protected]>

* added find vault  in the donation transaction scope

* fixed tests failing due to rename

---------

Co-authored-by: igoychev <[email protected]>
Co-authored-by: Slavcho Ivanov <[email protected]>
  • Loading branch information
3 people authored Aug 30, 2023
1 parent 06ea11e commit 0118cba
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 262 deletions.
1 change: 1 addition & 0 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ProviderDto } from './dto/provider.dto'
import { EmailService } from '../email/email.service'
import { JwtService } from '@nestjs/jwt'
import { TemplateService } from '../email/template.service'

import { SendGridNotificationsProvider } from '../notifications/providers/notifications.sendgrid.provider'
import { NotificationsProviderInterface } from '../notifications/providers/notifications.interface.providers'
import { MarketingNotificationsModule } from '../notifications/notifications.module'
Expand Down
254 changes: 142 additions & 112 deletions apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,95 +549,71 @@ export class CampaignService {
paymentData: PaymentData,
newDonationStatus: DonationStatus,
metadata?: DonationMetadata,
): Promise<string> {
): Promise<string | undefined> {
const campaignId = campaign.id
Logger.debug('Update donation to status: ' + newDonationStatus, {
campaignId,
paymentIntentId: paymentData.paymentIntentId,
})

/**
* Create or connect campaign vault
*/
const vault = await this.prisma.vault.findFirst({ where: { campaignId } })
const targetVaultData = vault
? // Connect the existing vault to this donation
{ connect: { id: vault.id } }
: // Create new vault for the campaign
{ create: { campaignId, currency: campaign.currency, name: campaign.title } }

// Find donation by extPaymentIntentId and update if status allows

let donation = await this.prisma.donation.findUnique({
where: { extPaymentIntentId: paymentData.paymentIntentId },
select: donationNotificationSelect,
})

// check for UUID length of personId
// subscriptions always have a personId
if (!donation && paymentData.personId && paymentData.personId.length === 36) {
// search for a subscription donation
// for subscriptions, we don't have a paymentIntentId
donation = await this.prisma.donation.findFirst({
where: {
status: DonationStatus.initial,
personId: paymentData.personId,
chargedAmount: paymentData.chargedAmount,
extPaymentMethodId: 'subscription',
},
select: donationNotificationSelect,
})

if (donation && newDonationStatus == DonationStatus.succeeded) {
donation.status = newDonationStatus
this.notificationService.sendNotification('successfulDonation', donation)
//Update existing donation or create new in a transaction that
//also increments the vault amount and marks campaign as completed
//if target amount is reached
return await this.prisma.$transaction(async (tx) => {
let donationId
// Find donation by extPaymentIntentId
const existingDonation = await this.findExistingDonation(tx, paymentData)

//if missing create the donation with the incoming status
if (!existingDonation) {
const newDonation = await this.createIncomingDonation(
tx,
paymentData,
newDonationStatus,
campaign,
)
donationId = newDonation.id
}

Logger.debug('Donation found by subscription: ', donation)
}

//if missing create the donation with the incoming status
if (!donation) {
Logger.debug(
'No donation exists with extPaymentIntentId: ' +
paymentData.paymentIntentId +
' Creating new donation with status: ' +
//donation exists, so check if it is safe to update it
else {
const updatedDonation = await this.updateDonationIfAllowed(
tx,
existingDonation,
newDonationStatus,
)
paymentData,
)
donationId = updatedDonation?.id
}

try {
donation = await this.prisma.donation.create({
data: {
amount: paymentData.netAmount,
chargedAmount: paymentData.chargedAmount,
currency: campaign.currency,
targetVault: targetVaultData,
provider: paymentData.paymentProvider,
type: DonationType.donation,
status: newDonationStatus,
extCustomerId: paymentData.stripeCustomerId ?? '',
extPaymentIntentId: paymentData.paymentIntentId,
extPaymentMethodId: paymentData.paymentMethodId ?? '',
billingName: paymentData.billingName,
billingEmail: paymentData.billingEmail,
person: paymentData.personId ? { connect: { id: paymentData.personId } } : {},
},
select: donationNotificationSelect,
})
if (newDonationStatus === DonationStatus.succeeded) {
this.notificationService.sendNotification('successfulDonation', donation)
//For successful donations we will also need to link them to user if not marked as anonymous
if (donationId && newDonationStatus === DonationStatus.succeeded) {
if (metadata?.isAnonymous !== 'true') {
await tx.donation.update({
where: { id: donationId },
data: {
person: {
connect: {
email: paymentData.billingEmail,
},
},
},
})
}
} catch (error) {
Logger.error(
`Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`,
)
throw new InternalServerErrorException(error)
}
}
//donation exists, so check if it is safe to update it
else if (shouldAllowStatusChange(donation.status, newDonationStatus)) {

return donationId
}) //end of the transaction scope
}

private async updateDonationIfAllowed(
tx: Prisma.TransactionClient,
donation: Donation,
newDonationStatus: DonationStatus,
paymentData: PaymentData,
) {
if (shouldAllowStatusChange(donation.status, newDonationStatus)) {
try {
const updatedDonation = await this.prisma.donation.update({
const updatedDonation = await tx.donation.update({
where: {
id: donation.id,
},
Expand All @@ -652,12 +628,17 @@ export class CampaignService {
},
select: donationNotificationSelect,
})

//if donation is switching to successful, increment the vault amount and send notification
if (newDonationStatus === DonationStatus.succeeded) {
await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount, tx)
this.notificationService.sendNotification('successfulDonation', {
...updatedDonation,
person: donation.person,
person: updatedDonation.person,
})
}

return updatedDonation
} catch (error) {
Logger.error(
`Error wile updating donation with paymentIntentId: ${paymentData.paymentIntentId} in database. Error is: ${error}`,
Expand All @@ -672,26 +653,88 @@ export class CampaignService {
and status: ${newDonationStatus} because the event comes after existing donation with status: ${donation.status}`,
)
}
}

//For successful donations we will also need to link them to user and add donation wish:
if (newDonationStatus === DonationStatus.succeeded) {
Logger.debug('metadata?.isAnonymous = ' + metadata?.isAnonymous)
private async createIncomingDonation(
tx: Prisma.TransactionClient,
paymentData: PaymentData,
newDonationStatus: DonationStatus,
campaign: Campaign,
) {
Logger.debug(
'No donation exists with extPaymentIntentId: ' +
paymentData.paymentIntentId +
' Creating new donation with status: ' +
newDonationStatus,
)

if (metadata?.isAnonymous != 'true') {
await this.prisma.donation.update({
where: { id: donation.id },
data: {
person: {
connect: {
email: paymentData.billingEmail,
},
},
},
})
/**
* Create or connect campaign vault
*/
const vault = await tx.vault.findFirst({ where: { campaignId: campaign.id } })
const targetVaultData = vault
? // Connect the existing vault to this donation
{ connect: { id: vault.id } }
: // Create new vault for the campaign
{ create: { campaignId: campaign.id, currency: campaign.currency, name: campaign.title } }

try {
const donation = await tx.donation.create({
data: {
amount: paymentData.netAmount,
chargedAmount: paymentData.chargedAmount,
currency: campaign.currency,
targetVault: targetVaultData,
provider: paymentData.paymentProvider,
type: DonationType.donation,
status: newDonationStatus,
extCustomerId: paymentData.stripeCustomerId ?? '',
extPaymentIntentId: paymentData.paymentIntentId,
extPaymentMethodId: paymentData.paymentMethodId ?? '',
billingName: paymentData.billingName,
billingEmail: paymentData.billingEmail,
person: paymentData.personId ? { connect: { id: paymentData.personId } } : {},
},
select: donationNotificationSelect,
})

if (newDonationStatus === DonationStatus.succeeded) {
await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount, tx)
this.notificationService.sendNotification('successfulDonation', donation)
}

return donation
} catch (error) {
Logger.error(
`Error while creating donation with paymentIntentId: ${paymentData.paymentIntentId} and status: ${newDonationStatus} . Error is: ${error}`,
)
throw new InternalServerErrorException(error)
}
}

private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) {
//first try to find by paymentIntentId
let donation = await tx.donation.findUnique({
where: { extPaymentIntentId: paymentData.paymentIntentId },
})

return donation.id
// if not found by paymentIntent, check for if this is payment on subscription
// check for UUID length of personId
// subscriptions always have a personId
if (!donation && paymentData.personId && paymentData.personId.length === 36) {
// search for a subscription donation
// for subscriptions, we don't have a paymentIntentId
donation = await tx.donation.findFirst({
where: {
status: DonationStatus.initial,
personId: paymentData.personId,
chargedAmount: paymentData.chargedAmount,
extPaymentMethodId: 'subscription',
},
})
Logger.debug('Donation found by subscription: ', donation)
}
return donation
}

async createDonationWish(wish: string, donationId: string, campaignId: string) {
Expand All @@ -706,22 +749,6 @@ export class CampaignService {
})
}

async donateToCampaign(campaign: Campaign, paymentData: PaymentData) {
Logger.debug('Update amounts with successful donation', {
campaignId: campaign.id,
paymentIntentId: paymentData.paymentIntentId,
netAmount: paymentData.netAmount,
chargedAmount: paymentData.chargedAmount,
})

const vault = await this.getCampaignVault(campaign.id)
if (vault) {
await this.vaultService.incrementVaultAmount(vault.id, paymentData.netAmount)
} else {
//vault is already checked and created if not existing in updateDonationPayment() above
}
}

async validateCampaignId(campaignId: string): Promise<Campaign> {
const campaign = await this.getCampaignById(campaignId)
return this.validateCampaign(campaign)
Expand Down Expand Up @@ -754,8 +781,11 @@ export class CampaignService {
* Call after executing a successful donation and adding the amount to a vault.
* This will set the campaign state to 'complete' if the campaign's target amount has been reached
*/
public async updateCampaignStatusIfTargetReached(campaignId: string) {
const campaign = await this.prisma.campaign.findFirst({
public async updateCampaignStatusIfTargetReached(
campaignId: string,
tx: Prisma.TransactionClient,
) {
const campaign = await tx.campaign.findFirst({
where: {
id: campaignId,
},
Expand All @@ -775,7 +805,7 @@ export class CampaignService {
if (campaign && campaign.state !== CampaignState.complete && campaign.targetAmount) {
const actualAmount = campaign.vaults.map((vault) => vault.amount).reduce((a, b) => a + b, 0)
if (actualAmount >= campaign.targetAmount) {
await this.prisma.campaign.update({
await tx.campaign.update({
where: {
id: campaign.id,
},
Expand Down
Loading

0 comments on commit 0118cba

Please sign in to comment.