Skip to content

Commit

Permalink
refactor(stripe): Use PaymentIntent and subscriptions instead of Chec…
Browse files Browse the repository at this point in the history
…kout sessions (#627)

* feat: Add StripeModule to encapsulate stripe related logic

* src/stripe: Add DTO for subscription payments

* feat(stripe): Add support for recurring payment
- Added endpoint to create stripe subscriptions
- Add services to:
   Create stripe products
   Create stripe customers
   Create stripe subscriptions

* refactor: Move everything Stripe related to Stripe module

* refactor: Move updateDonationPayment fn to DonationService
This is where it should belong

* refactor: Adjust tests to latest changes

* refactor(paypal.service.spec): Remove duplicate CacheModule import

* feat(stripe): Don't initiate intent/subscription if campaign is completed

* refactor(stripe): Create subscription from setupIntent object

* chore(donations): Use card country when calculating netAmount for subscription

* chore(donations): Use idempotency key when creating stripe resources

* tests: Adjust tests to lates changes

* src/donations: Add endpoint to get first donation by paymentIntentId
Needed for creating wishes after payment has been made

* stripe: Add endpoint for setupintent cancellation

* chore: Address review changes

* fix: App not starting

---------

Co-authored-by: dimitar.nizamov <[email protected]>
  • Loading branch information
sashko9807 and dimitur2204 authored Oct 9, 2024
1 parent c5eb0a7 commit f96d503
Show file tree
Hide file tree
Showing 36 changed files with 1,585 additions and 768 deletions.
10 changes: 8 additions & 2 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,19 @@ import { MarketingNotificationsModule } from '../notifications/notifications.mod

import { StatisticsModule } from '../statistics/statistics.module'
import { AffiliateModule } from '../affiliate/affiliate.module'
import { StripeModule } from '../stripe/stripe.module'

import { LoggerModule } from '../logger/logger.module'
import { PrismaModule } from '../prisma/prisma.module'
import { CampaignApplicationModule } from '../campaign-application/campaign-application.module'

@Module({
imports: [
ConfigModule.forRoot({ validationSchema, isGlobal: true, load: [configuration] }),
ConfigModule.forRoot({
validationSchema,
isGlobal: true,
load: [configuration],
}),
/* External modules */
SentryModule.forRootAsync({
imports: [ConfigModule],
Expand Down Expand Up @@ -117,7 +122,7 @@ import { CampaignApplicationModule } from '../campaign-application/campaign-appl
BankTransactionsModule,
StatisticsModule,
CacheModule.registerAsync({
imports: [ConfigModule],
imports: [ConfigModule, AppModule],
useFactory: async (config: ConfigService) => ({
ttl: Number(config.get<number>('CACHE_TTL', 30 * 1000 /* ms */)),
}),
Expand All @@ -129,6 +134,7 @@ import { CampaignApplicationModule } from '../campaign-application/campaign-appl
MarketingNotificationsModule,
LoggerModule,
CampaignApplicationModule,
StripeModule,
],
controllers: [AppController],
providers: [
Expand Down
10 changes: 2 additions & 8 deletions apps/api/src/campaign/campaign.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,10 @@ import { CampaignNewsModule } from '../campaign-news/campaign-news.module'
import { MarketingNotificationsModule } from '../notifications/notifications.module'
import { PrismaModule } from '../prisma/prisma.module'
@Module({
imports: [
forwardRef(() => VaultModule),
MarketingNotificationsModule,
NotificationModule,
CampaignNewsModule,
PrismaModule,
],
imports: [MarketingNotificationsModule, NotificationModule, CampaignNewsModule],

controllers: [CampaignController, CampaignTypeController],
providers: [CampaignService, VaultService, PersonService, ConfigService],
providers: [CampaignService, PrismaService, PersonService, ConfigService],

exports: [CampaignService],
})
Expand Down
221 changes: 0 additions & 221 deletions apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ import type { PaymentWithDonation } from '../donations/types/donation'
export class CampaignService {
constructor(
private prisma: PrismaService,
private notificationService: NotificationService,
@Inject(forwardRef(() => VaultService)) private vaultService: VaultService,
@Inject(forwardRef(() => PersonService)) private personService: PersonService,
@Inject(forwardRef(() => MarketingNotificationsService))
private marketingNotificationsService: MarketingNotificationsService,
Expand Down Expand Up @@ -578,225 +576,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<string | undefined> {
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 existingPayment = await this.findExistingDonation(tx, paymentData)

//if missing create the donation with the incoming status
if (!existingPayment) {
const newDonation = await this.createIncomingDonation(
tx,
paymentData,
newDonationStatus,
campaign,
)
donationId = newDonation.donations[0].id
}
//donation exists, so check if it is safe to update it
else {
const updatedDonation = await this.updateDonationIfAllowed(
tx,
existingPayment,
newDonationStatus,
paymentData,
)
donationId = updatedDonation?.donations[0].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({
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/config/validation.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const globalValidationPipe = new ValidationPipe({
exposeDefaultValues: true,
},
stopAtFirstError: false,
forbidUnknownValues: true,
forbidUnknownValues: false,
disableErrorMessages: false,
exceptionFactory: (errors) => new BadRequestException(errors),
validationError: { target: false, value: false },
Expand Down
Loading

0 comments on commit f96d503

Please sign in to comment.