diff --git a/apps/api/src/assets/templates/refund-donation.json b/apps/api/src/assets/templates/refund-donation.json
new file mode 100644
index 000000000..1d50e5b52
--- /dev/null
+++ b/apps/api/src/assets/templates/refund-donation.json
@@ -0,0 +1,3 @@
+{
+ "subject": "Заявка за възстановяване на пари от дарение в Подкрепи.бг"
+}
diff --git a/apps/api/src/assets/templates/refund-donation.mjml b/apps/api/src/assets/templates/refund-donation.mjml
new file mode 100644
index 000000000..46f6cb73d
--- /dev/null
+++ b/apps/api/src/assets/templates/refund-donation.mjml
@@ -0,0 +1,68 @@
+
+
+
+
+
+ Върнати пари от дарение в Подкрепи.бг
+
+
+
+
+
+
+ Здравейте,
+
+
+
+ Това е автоматичен e-mail, който потвърждава, че ще получите обратно парите от вашето
+ дарение към кампанията {{campaignName}}.
+ За съжаление, поради ограничения на Stripe, не можем да ви възстановим таксите, които са
+ удържани от тях.
+
+ Допълнителни подробности:
+ Сумата която ще възстановим е на стойност {{netAmount}} {{currency}}.
+ Таксата удържана от Stripe е на стойност {{taxAmount}} {{currency}}
+ Транзакцията ще отнеме от 5 до 10 дни и парите ще ви бъдат върнати към същата карта, с
+ която сте превели дарението към Страйп.
+
+ Благодарим ви за разбирането!
+
+
+ Поздрави,
+ Екипът на Подкрепи.бг
+
+
+
+
diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts
index e46c60d62..3402d6adc 100644
--- a/apps/api/src/campaign/campaign.service.ts
+++ b/apps/api/src/campaign/campaign.service.ts
@@ -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(
diff --git a/apps/api/src/donations/donations.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts
index 99fb20391..12c3dcdff 100644
--- a/apps/api/src/donations/donations.controller.spec.ts
+++ b/apps/api/src/donations/donations.controller.spec.ts
@@ -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',
@@ -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',
+ })
+ })
})
diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts
index d79272110..200733c51 100644
--- a/apps/api/src/donations/donations.controller.ts
+++ b/apps/api/src/donations/donations.controller.ts
@@ -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],
diff --git a/apps/api/src/donations/donations.module.ts b/apps/api/src/donations/donations.module.ts
index 715190984..613a47fe8 100644
--- a/apps/api/src/donations/donations.module.ts
+++ b/apps/api/src/donations/donations.module.ts
@@ -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: [
@@ -43,6 +44,7 @@ import { MarketingNotificationsModule } from '../notifications/notifications.mod
VaultService,
PersonService,
ExportService,
+ EmailService,
],
exports: [DonationsService],
})
diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts
index ed6eb1f0f..2580d40f9 100644
--- a/apps/api/src/donations/donations.service.ts
+++ b/apps/api/src/donations/donations.service.ts
@@ -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>}
+ */
+ async refundStripePayment(paymentIntentId: string): Promise> {
+ 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
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 07f4a997b..32db9513f 100644
--- a/apps/api/src/donations/events/stripe-payment.service.spec.ts
+++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts
@@ -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'
@@ -68,6 +69,11 @@ describe('StripePaymentService', () => {
},
},
}
+ const emailServiceMock = {
+ sendFromTemplate: jest.fn(() => {
+ return true
+ }),
+ }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -99,7 +105,10 @@ describe('StripePaymentService', () => {
useValue: mockDeep(),
},
],
- }).compile()
+ })
+ .overrideProvider(EmailService)
+ .useValue(emailServiceMock)
+ .compile()
app = module.createNestApplication()
await app.init()
@@ -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)
+ const vaultService = app.get(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)
diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts
index 77c8b27ab..be89934c7 100644
--- a/apps/api/src/donations/events/stripe-payment.service.ts
+++ b/apps/api/src/donations/events/stripe-payment.service.ts
@@ -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
@@ -25,6 +28,8 @@ export class StripePaymentService {
constructor(
private campaignService: CampaignService,
private recurringDonationService: RecurringDonationService,
+ private sendEmail: EmailService,
+ private prismaService: PrismaService,
) {}
@StripeWebhookHandler('payment_intent.created')
@@ -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
diff --git a/apps/api/src/donations/events/stripe-payment.testdata.ts b/apps/api/src/donations/events/stripe-payment.testdata.ts
index 423048da3..f7954b595 100644
--- a/apps/api/src/donations/events/stripe-payment.testdata.ts
+++ b/apps/api/src/donations/events/stripe-payment.testdata.ts
@@ -388,6 +388,113 @@ export const mockChargeEventSucceeded: Stripe.Event = {
type: 'charge.succeeded',
}
+export const mockChargeRefundEventSucceeded: Stripe.Event = {
+ id: 'evt_3MmYVtKApGjVGa9t1d6zrtYm',
+ object: 'event',
+ api_version: '2022-11-15',
+ created: 1679041190,
+ data: {
+ object: {
+ id: 'ch_3MmYVtKApGjVGa9t1GWiGyBC',
+ object: 'charge',
+ amount: 10000,
+ amount_captured: 10000,
+ amount_refunded: 0,
+ application: null,
+ application_fee: null,
+ application_fee_amount: null,
+ balance_transaction: 'txn_3MmYVtKApGjVGa9t1uZzmKjk',
+ billing_details: {
+ address: {
+ city: null,
+ country: 'BG',
+ line1: null,
+ line2: null,
+ postal_code: null,
+ state: null,
+ },
+ email: 'test@podkrepi.bg',
+ name: '42424242',
+ phone: null,
+ },
+ calculated_statement_descriptor: 'PODKREPI.BG',
+ captured: true,
+ created: 1679041190,
+ currency: 'bgn',
+ customer: null,
+ description: null,
+ destination: null,
+ dispute: null,
+ disputed: false,
+ failure_balance_transaction: null,
+ failure_code: null,
+ failure_message: null,
+ fraud_details: {},
+ invoice: null,
+ livemode: false,
+ metadata: {
+ campaignId: campaignId,
+ isAnonymous: 'false',
+ },
+ on_behalf_of: null,
+ order: null,
+ outcome: {
+ network_status: 'approved_by_network',
+ reason: null,
+ risk_level: 'normal',
+ risk_score: 41,
+ seller_message: 'Payment complete.',
+ type: 'authorized',
+ },
+ paid: true,
+ payment_intent: 'pi_3MmYVtKApGjVGa9t1BtdkBrz',
+ payment_method: 'pm_1MmYVsKApGjVGa9t0VgSblb6',
+ payment_method_details: {
+ card: {
+ brand: 'visa',
+ checks: {
+ address_line1_check: null,
+ address_postal_code_check: null,
+ cvc_check: 'pass',
+ },
+ country: 'US',
+ exp_month: 4,
+ exp_year: 2024,
+ fingerprint: '2BUDwUpZNgnepjrE',
+ funding: 'credit',
+ installments: null,
+ last4: '4242',
+ mandate: null,
+ network: 'visa',
+ three_d_secure: null,
+ wallet: null,
+ },
+ type: 'card',
+ },
+ receipt_email: 'test@podkrepi.bg',
+ receipt_number: null,
+ receipt_url: 'https://pay.stripe.com/receipts/payment/',
+ refunded: true,
+ review: null,
+ shipping: null,
+ source: null,
+ source_transfer: null,
+ statement_descriptor: null,
+ statement_descriptor_suffix: null,
+ status: 'succeeded',
+ transfer_data: null,
+ transfer_group: null,
+ },
+ },
+ livemode: false,
+ pending_webhooks: 0,
+ request: {
+ id: 'req_A1IpSniPFBiLfk',
+ idempotency_key: '7f63041e-6ad1-48bf-b838-d12e61f80a9c',
+ },
+ type: 'charge.refunded',
+}
+
export const mockPaymentIntentCreated: Stripe.PaymentIntent = {
id: 'pi_3LNwijKApGjVGa9t1F9QYd5s',
object: 'payment_intent',
diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts
index c49a11d08..5c17b8565 100644
--- a/apps/api/src/donations/helpers/donation-status-updates.ts
+++ b/apps/api/src/donations/helpers/donation-status-updates.ts
@@ -26,6 +26,11 @@ function isChangeable(status: DonationStatus) {
function isFinal(status: DonationStatus) {
return final.includes(status)
}
+
+function isRefundable(oldStatus: DonationStatus, newStatus: DonationStatus) {
+ return oldStatus === DonationStatus.succeeded && newStatus === DonationStatus.refund
+}
+
/**
* The function returns the allowed previous status that can be changed/updated by the incoming donation event
* @param newStatus the incoming status of the payment event
@@ -35,7 +40,7 @@ export function shouldAllowStatusChange(
oldStatus: DonationStatus,
newStatus: DonationStatus,
): boolean {
- if (oldStatus === newStatus) {
+ if (oldStatus === newStatus || isRefundable(oldStatus, newStatus)) {
return true
}
diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts
index 035fe0fc6..bcc0fd937 100644
--- a/apps/api/src/email/template.interface.ts
+++ b/apps/api/src/email/template.interface.ts
@@ -13,6 +13,7 @@ export enum TemplateType {
expiringIrisConsent = 'expiring-iris-consent',
confirmConsent = 'confirm-notifications-consent',
campaignNewsDraft = 'campaign-news-draft',
+ refundDonation = 'refund-donation',
}
export type TemplateTypeKeys = keyof typeof TemplateType
export type TemplateTypeValues = typeof TemplateType[TemplateTypeKeys]
@@ -90,3 +91,12 @@ export class CampaignNewsDraftEmailDto extends EmailTemplate<{
}> {
name = TemplateType.campaignNewsDraft
}
+
+export class RefundDonationEmailDto extends EmailTemplate<{
+ campaignName: string
+ netAmount: number
+ taxAmount: number
+ currency: string
+}> {
+ name = TemplateType.refundDonation
+}
diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts
index 1e03986a5..4282e3ddc 100644
--- a/apps/api/src/vault/vault.service.ts
+++ b/apps/api/src/vault/vault.service.ts
@@ -117,6 +117,30 @@ export class VaultService {
amount: number,
tx: Prisma.TransactionClient,
): Promise {
+ const vault = await this.updateVaultAmount(vaultId, amount, tx, 'increment')
+
+ await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx)
+
+ return vault
+ }
+
+ /**
+ * Decrement vault amount as part of donation in prisma transaction
+ */
+ public async decrementVaultAmount(
+ vaultId: string,
+ amount: number,
+ tx: Prisma.TransactionClient,
+ ): Promise {
+ return this.updateVaultAmount(vaultId, amount, tx, 'decrement')
+ }
+
+ async updateVaultAmount(
+ vaultId: string,
+ amount: number,
+ tx: Prisma.TransactionClient,
+ operationType: string,
+ ) {
if (amount <= 0) {
throw new Error('Amount cannot be negative or zero.')
}
@@ -125,14 +149,12 @@ export class VaultService {
where: { id: vaultId },
data: {
amount: {
- increment: amount,
+ [operationType]: amount,
},
},
}
const vault = await tx.vault.update(updateStatement)
- await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx)
-
return vault
}
}