Skip to content

Commit

Permalink
[Stripe] Implement Refund event listener in Stripe webhook (podkrepi-…
Browse files Browse the repository at this point in the history
…bg#556)

* Add refund optionallity

* Improve email sent to the user

* Remove not needed debug

* Add test for charge.refunded

* Refactor vault service

* Fix vault-service

* Remove breakline from email

* Add refund endpoint

* Update refund comment

* Udate emailVariable func name

* Fix text in the e-mail

---------

Co-authored-by: i553818 <[email protected]>
Co-authored-by: igoychev <[email protected]>
  • Loading branch information
3 people authored Oct 29, 2023
1 parent cdda5b3 commit 4a6e572
Show file tree
Hide file tree
Showing 13 changed files with 418 additions and 6 deletions.
3 changes: 3 additions & 0 deletions apps/api/src/assets/templates/refund-donation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"subject": "Заявка за възстановяване на пари от дарение в Подкрепи.бг"
}
68 changes: 68 additions & 0 deletions apps/api/src/assets/templates/refund-donation.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<mjml>
<mj-body background-color="#ffffff" font-size="13px">
<mj-section
background-color="#009FE3"
vertical-align="top"
padding-bottom="0px"
padding-top="0">
<mj-column vertical-align="top" width="100%">
<mj-text
align="left"
color="#ffffff"
font-size="45px"
font-weight="bold"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px"
padding-bottom="30px"
padding-top="50px">
Върнати пари от дарение в Подкрепи.бг
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#009fe3" padding-bottom="20px" padding-top="20px">
<mj-column vertical-align="middle" width="100%">
<mj-text
align="left"
color="#ffffff"
font-size="22px"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px">
<span style="color: #feeb35"> Здравейте, </span>
<br /><br />
</mj-text>
<mj-text
align="left"
color="#ffffff"
font-size="15px"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px">
Това е автоматичен e-mail, който потвърждава, че ще получите обратно парите от вашето
дарение към кампанията {{campaignName}}. <br />
За съжаление, поради ограничения на Stripe, не можем да ви възстановим таксите, които са
удържани от тях. <br /><br />

Допълнителни подробности:<br />
Сумата която ще възстановим е на стойност {{netAmount}} {{currency}}.<br />
Таксата удържана от Stripe е на стойност {{taxAmount}} {{currency}}<br />
Транзакцията ще отнеме от 5 до 10 дни и парите ще ви бъдат върнати към същата карта, с
която сте превели дарението към Страйп. <br /><br />

Благодарим ви за разбирането!
</mj-text>
<mj-text
align="left"
color="#ffffff"
font-size="15px"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px">
Поздрави, <br />
Екипът на Подкрепи.бг</mj-text
>
</mj-column>
</mj-section>
</mj-body>
</mjml>
14 changes: 13 additions & 1 deletion apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,8 +651,20 @@ export class CampaignService {
...updatedDonation,
person: updatedDonation.person,
})
} else if (
donation.status === DonationStatus.succeeded &&
newDonationStatus === DonationStatus.refund
) {
await this.vaultService.decrementVaultAmount(
donation.targetVaultId,
paymentData.netAmount,
tx,
)
this.notificationService.sendNotification('successfulRefund', {
...updatedDonation,
person: updatedDonation.person,
})
}

return updatedDonation
} catch (error) {
Logger.error(
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/donations/donations.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ describe('DonationsController', () => {

const stripeMock = {
checkout: { sessions: { create: jest.fn() } },
paymentIntents: { retrieve: jest.fn() },
refunds: { create: jest.fn() },
}
stripeMock.checkout.sessions.create.mockResolvedValue({ payment_intent: 'unique-intent' })
stripeMock.paymentIntents.retrieve.mockResolvedValue({
payment_intent: 'unique-intent',
metadata: { campaignId: 'unique-campaign' },
})

const mockSession = {
mode: 'payment',
Expand Down Expand Up @@ -277,4 +283,14 @@ describe('DonationsController', () => {
},
})
})

it('should request refund for donation', async () => {
await controller.refundStripePaymet('unique-intent')

expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('unique-intent')
expect(stripeMock.refunds.create).toHaveBeenCalledWith({
payment_intent: 'unique-intent',
reason: 'requested_by_customer',
})
})
})
9 changes: 9 additions & 0 deletions apps/api/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,15 @@ export class DonationsController {
return this.donationsService.createStripePayment(stripePaymentDto)
}

@Post('/refund-stripe-payment/:id')
@Roles({
roles: [RealmViewSupporters.role, ViewSupporters.role],
mode: RoleMatchingMode.ANY,
})
refundStripePaymet(@Param('id') paymentIntentId: string) {
return this.donationsService.refundStripePayment(paymentIntentId)
}

@Post('create-bank-payment')
@Roles({
roles: [RealmViewSupporters.role, ViewSupporters.role],
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/donations/donations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { HttpModule } from '@nestjs/axios'
import { ExportModule } from './../export/export.module'
import { NotificationModule } from '../sockets/notifications/notification.module'
import { MarketingNotificationsModule } from '../notifications/notifications.module'
import { EmailService } from '../email/email.service'

@Module({
imports: [
Expand All @@ -43,6 +44,7 @@ import { MarketingNotificationsModule } from '../notifications/notifications.mod
VaultService,
PersonService,
ExportService,
EmailService,
],
exports: [DonationsService],
})
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,28 @@ export class DonationsService {
return this.createInitialDonationFromIntent(campaign, inputDto, intent)
}

/**
* Refund a stipe payment donation
* https://stripe.com/docs/api/refunds/create
* @param inputDto Refund-stripe params
* @returns {Promise<Stripe.Response<Stripe.Refund>>}
*/
async refundStripePayment(paymentIntentId: string): Promise<Stripe.Response<Stripe.Refund>> {
const intent = await this.stripeClient.paymentIntents.retrieve(paymentIntentId)
if (!intent) {
throw new BadRequestException('Payment Intent is missing from stripe')
}

if (!intent.metadata.campaignId) {
throw new BadRequestException('Campaign id is missing from payment intent metadata')
}

return await this.stripeClient.refunds.create({
payment_intent: paymentIntentId,
reason: 'requested_by_customer',
})
}

/**
* Update a payment intent for a donation
* https://stripe.com/docs/api/payment_intents/update
Expand Down
89 changes: 88 additions & 1 deletion apps/api/src/donations/events/stripe-payment.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
mockedVault,
mockChargeEventSucceeded,
mockPaymentEventFailed,
mockChargeRefundEventSucceeded,
} from './stripe-payment.testdata'
import { DonationStatus } from '@prisma/client'
import { RecurringDonationService } from '../../recurring-donation/recurring-donation.service'
Expand Down Expand Up @@ -68,6 +69,11 @@ describe('StripePaymentService', () => {
},
},
}
const emailServiceMock = {
sendFromTemplate: jest.fn(() => {
return true
}),
}

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -99,7 +105,10 @@ describe('StripePaymentService', () => {
useValue: mockDeep<HttpService>(),
},
],
}).compile()
})
.overrideProvider(EmailService)
.useValue(emailServiceMock)
.compile()

app = module.createNestApplication()
await app.init()
Expand Down Expand Up @@ -416,6 +425,84 @@ describe('StripePaymentService', () => {
})
})

it('should handle charge.refunded', () => {
const payloadString = JSON.stringify(mockChargeRefundEventSucceeded, null, 2)

const header = stripe.webhooks.generateTestHeaderString({
payload: payloadString,
secret: stripeSecret,
})

const campaignService = app.get<CampaignService>(CampaignService)
const vaultService = app.get<VaultService>(VaultService)

const mockedCampaignById = jest
.spyOn(campaignService, 'getCampaignById')
.mockImplementation(() => Promise.resolve(mockedCampaign))

const paymentData = getPaymentDataFromCharge(
mockChargeEventSucceeded.data.object as Stripe.Charge,
)

prismaMock.donation.findUnique.mockResolvedValue({
id: 'test-donation-id',
type: DonationType.donation,
status: DonationStatus.succeeded,
provider: 'stripe',
extCustomerId: paymentData.stripeCustomerId ?? '',
extPaymentIntentId: paymentData.paymentIntentId,
extPaymentMethodId: 'card',
targetVaultId: 'test-vault-id',
amount: 1000, //amount is 0 on donation created from payment-intent
chargedAmount: 800,
currency: 'BGN',
createdAt: new Date(),
updatedAt: new Date(),
billingName: paymentData.billingName ?? '',
billingEmail: paymentData.billingEmail ?? '',
personId: 'donation-person',
})

prismaMock.donation.update.mockResolvedValue({
id: 'test-donation-id',
targetVaultId: 'test-vault-id',
amount: paymentData.netAmount,
status: DonationStatus.refund,
person: { firstName: 'Full', lastName: 'Name' },
} as Donation & { person: unknown })

prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault)

prismaMock.campaign.findFirst.mockResolvedValue({
id: 'test-campaign',
state: CampaignState.active,
targetAmount: paymentData.netAmount,
vaults: [{ amount: paymentData.netAmount }],
} as unknown as Campaign)

jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock))
const mockedUpdateDonationPayment = jest
.spyOn(campaignService, 'updateDonationPayment')
.mockName('updateDonationPayment')

const mockDecremementVaultAmount = jest.spyOn(vaultService, 'decrementVaultAmount')

return request(app.getHttpServer())
.post(defaultStripeWebhookEndpoint)
.set('stripe-signature', header)
.type('json')
.send(payloadString)
.expect(201)
.then(() => {
expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event
expect(mockedUpdateDonationPayment).toHaveBeenCalled()
expect(prismaMock.donation.findUnique).toHaveBeenCalled()
expect(prismaMock.donation.create).not.toHaveBeenCalled()
expect(mockDecremementVaultAmount).toHaveBeenCalled()
expect(prismaMock.donation.update).toHaveBeenCalled()
})
})

it('calculate payment-intent.created', async () => {
const billingDetails = getPaymentData(mockPaymentIntentCreated)
expect(billingDetails.netAmount).toEqual(0)
Expand Down
49 changes: 49 additions & 0 deletions apps/api/src/donations/events/stripe-payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
PaymentData,
} from '../helpers/payment-intent-helpers'
import { DonationStatus, CampaignState } from '@prisma/client'
import { EmailService } from '../../email/email.service'
import { RefundDonationEmailDto } from '../../email/template.interface'
import { PrismaService } from '../../prisma/prisma.service'

/** Testing Stripe on localhost is described here:
* https://github.com/podkrepi-bg/api/blob/master/TESTING.md#testing-stripe
Expand All @@ -25,6 +28,8 @@ export class StripePaymentService {
constructor(
private campaignService: CampaignService,
private recurringDonationService: RecurringDonationService,
private sendEmail: EmailService,
private prismaService: PrismaService,
) {}

@StripeWebhookHandler('payment_intent.created')
Expand Down Expand Up @@ -147,6 +152,50 @@ export class StripePaymentService {
}
}

@StripeWebhookHandler('charge.refunded')
async handleRefundCreated(event: Stripe.Event) {
const chargePaymentIntent: Stripe.Charge = event.data.object as Stripe.Charge
Logger.log(
'[ handleRefundCreated ]',
chargePaymentIntent,
chargePaymentIntent.metadata as DonationMetadata,
)

const metadata: DonationMetadata = chargePaymentIntent.metadata as DonationMetadata

if (!metadata.campaignId) {
Logger.debug('[ handleRefundCreated ] No campaignId in metadata ' + chargePaymentIntent.id)
return
}

const billingData = getPaymentDataFromCharge(chargePaymentIntent)

const campaign = await this.campaignService.getCampaignById(metadata.campaignId)

await this.campaignService.updateDonationPayment(
campaign,
billingData,
DonationStatus.refund,
metadata,
)

if (billingData.billingEmail !== undefined) {
const recepient = { to: [billingData.billingEmail] }
const mail = new RefundDonationEmailDto({
campaignName: campaign.title,
currency: billingData.currency.toUpperCase(),
netAmount: billingData.netAmount / 100,
taxAmount: (billingData.chargedAmount - billingData.netAmount) / 100,
})
// Send Notification

await this.sendEmail.sendFromTemplate(mail, recepient, {
//Allow users to receive the mail, regardles of unsubscribes
bypassUnsubscribeManagement: { enable: true },
})
}
}

@StripeWebhookHandler('customer.subscription.created')
async handleSubscriptionCreated(event: Stripe.Event) {
const subscription: Stripe.Subscription = event.data.object as Stripe.Subscription
Expand Down
Loading

0 comments on commit 4a6e572

Please sign in to comment.