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 } }