Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(stripe): Use PaymentIntent and subscriptions instead of Checkout sessions #627

Merged
merged 22 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fec3436
feat: Add StripeModule to encapsulate stripe related logic
dimitur2204 Apr 10, 2024
ca27b1a
src/stripe: Add DTO for subscription payments
dimitur2204 Apr 10, 2024
f785bee
feat(stripe): Add support for recurring payment
sashko9807 Apr 11, 2024
6166434
refactor: Move everything Stripe related to Stripe module
sashko9807 Apr 11, 2024
8a94a9a
refactor: Move updateDonationPayment fn to DonationService
sashko9807 Apr 11, 2024
bfa4d40
refactor: Adjust tests to latest changes
sashko9807 Apr 11, 2024
7367091
refactor(paypal.service.spec): Remove duplicate CacheModule import
sashko9807 Apr 11, 2024
2c9fee9
feat(stripe): Don't initiate intent/subscription if campaign is compl…
sashko9807 Apr 11, 2024
b01d458
refactor(stripe): Create subscription from setupIntent object
sashko9807 Apr 13, 2024
417054e
chore(donations): Use card country when calculating netAmount for sub…
sashko9807 Apr 14, 2024
37988df
chore(donations): Use idempotency key when creating stripe resources
sashko9807 Apr 14, 2024
d145139
tests: Adjust tests to lates changes
sashko9807 Apr 18, 2024
82ee4bc
src/donations: Add endpoint to get first donation by paymentIntentId
sashko9807 Apr 18, 2024
c2d293f
Merge branch 'master' of https://github.com/sashko9807/podkrepibg-api…
sashko9807 Apr 29, 2024
f596144
stripe: Add endpoint for setupintent cancellation
sashko9807 Apr 29, 2024
5f33a1f
Merge branch 'master' into stripe-donation-flow
sashko9807 May 22, 2024
cdc2825
Merge branch 'master' into stripe-donation-flow
sashko9807 Jun 19, 2024
2f58dd8
Merge branch 'master' of github.com:sashko9807/podkrepibg-api into st…
sashko9807 Sep 2, 2024
6c4f9ec
chore: Address review changes
sashko9807 Sep 2, 2024
0e24672
Merge branch 'master' into stripe-donation-flow
sashko9807 Sep 2, 2024
5f7439d
Merge branch 'master' into stripe-donation-flow
sashko9807 Sep 13, 2024
ac6e407
fix: App not starting
sashko9807 Sep 13, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -11,7 +11,7 @@
import { AppController } from './app.controller'
import { CustomAuthGuard } from './custom-auth.guard'
import configuration from '../config/configuration'
import { PrismaService } from '../prisma/prisma.service'

Check warning on line 14 in apps/api/src/app/app.module.ts

View workflow job for this annotation

GitHub Actions / Run API tests

'PrismaService' is defined but never used
import { AccountModule } from '../account/account.module'
import { HealthModule } from '../health/health.module'
import { SupportModule } from '../support/support.module'
Expand Down Expand Up @@ -59,14 +59,19 @@

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 @@
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 @@
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
Loading