From dbdaab00f0dea1287fc50437e5f27f99edb034be Mon Sep 17 00:00:00 2001 From: i553818 Date: Sat, 30 Sep 2023 21:22:13 +0300 Subject: [PATCH 01/11] Add refund optionallity --- .../src/assets/templates/refund-donation.json | 3 + .../src/assets/templates/refund-donation.mjml | 62 +++++++++++++++++++ apps/api/src/campaign/campaign.service.ts | 13 ++++ apps/api/src/donations/donations.module.ts | 2 + .../events/stripe-payment.service.ts | 52 ++++++++++++++++ .../helpers/donation-status-updates.ts | 7 ++- apps/api/src/email/template.interface.ts | 9 +++ apps/api/src/vault/vault.service.ts | 25 ++++++++ 8 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/assets/templates/refund-donation.json create mode 100644 apps/api/src/assets/templates/refund-donation.mjml 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..5b1f03c7c --- /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..e8551d915 --- /dev/null +++ b/apps/api/src/assets/templates/refund-donation.mjml @@ -0,0 +1,62 @@ + + + + + + Върнати пари от дарение в Подкрепи.бг + + + + + + + Здравейте {{firstName}} {{lastName}}, +

+
+ + Това е автоматичен e-mail, който потвърждава, че ще получите обратно вашите пари от

+ вашето дарение към кампанията {{campaignName}}.

+ Транзакцията ще отнеме от 5 до 10 дни и за съжеление ще е без таксите който Stripe удържа.

+ + + Благодарим ви за разбирането! +
+ + Поздрави,
+ Екипът на Подкрепи.бг
+
+
+
+
diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index e46c60d62..05b95e120 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -651,6 +651,19 @@ 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 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/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 77c8b27ab..e26156fcb 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 { RefundDonationDto } 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,53 @@ 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, + ) + + const user = await this.prismaService.person.findFirst({ + where: { email: billingData.billingEmail }, + }) + + if (user) { + const recepient = { to: [user.email] } + const mail = new RefundDonationDto({ + campaignName: campaign.title, + firstName: user.firstName, + lastName: user.lastName, + }) + // 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/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..9ab23a816 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,11 @@ export class CampaignNewsDraftEmailDto extends EmailTemplate<{ }> { name = TemplateType.campaignNewsDraft } + +export class RefundDonationDto extends EmailTemplate<{ + campaignName: string + firstName: string + lastName: string +}> { + name = TemplateType.campaignNewsDraft +} diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 1e03986a5..f4b6e3315 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -133,6 +133,31 @@ export class VaultService { const vault = await tx.vault.update(updateStatement) 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 { + if (amount <= 0) { + throw new Error('Amount cannot be negative or zero.') + } + + const updateStatement = { + where: { id: vaultId }, + data: { + amount: { + decrement: amount, + }, + }, + } + + const vault = await tx.vault.update(updateStatement) + return vault } } From ac1817441aee2f7851488c838d6577b86f72af92 Mon Sep 17 00:00:00 2001 From: i553818 Date: Sun, 1 Oct 2023 13:18:07 +0300 Subject: [PATCH 02/11] Improve email sent to the user --- apps/api/src/assets/templates/refund-donation.mjml | 14 ++++++++++---- apps/api/src/campaign/campaign.service.ts | 3 ++- .../src/donations/events/stripe-payment.service.ts | 13 +++++-------- apps/api/src/email/template.interface.ts | 7 ++++--- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/api/src/assets/templates/refund-donation.mjml b/apps/api/src/assets/templates/refund-donation.mjml index e8551d915..175dc4c42 100644 --- a/apps/api/src/assets/templates/refund-donation.mjml +++ b/apps/api/src/assets/templates/refund-donation.mjml @@ -29,7 +29,7 @@ font-family="open Sans Helvetica, Arial, sans-serif" padding-left="25px" padding-right="25px"> - Здравейте {{firstName}} {{lastName}}, + Здравейте,

- Това е автоматичен e-mail, който потвърждава, че ще получите обратно вашите пари от

- вашето дарение към кампанията {{campaignName}}.

- Транзакцията ще отнеме от 5 до 10 дни и за съжеление ще е без таксите който Stripe удържа.

+ Това е автоматичен 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 05b95e120..3f34cdd1b 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -664,8 +664,9 @@ export class CampaignService { ...updatedDonation, person: updatedDonation.person, }) + Logger.debug('tuk3') } - + Logger.debug('tuk4') return updatedDonation } catch (error) { Logger.error( diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index e26156fcb..9c3cb5768 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -179,16 +179,13 @@ export class StripePaymentService { metadata, ) - const user = await this.prismaService.person.findFirst({ - where: { email: billingData.billingEmail }, - }) - - if (user) { - const recepient = { to: [user.email] } + if (billingData.billingEmail !== undefined) { + const recepient = { to: [billingData.billingEmail] } const mail = new RefundDonationDto({ campaignName: campaign.title, - firstName: user.firstName, - lastName: user.lastName, + currency: billingData.currency.toUpperCase(), + netAmount: billingData.netAmount / 100, + taxAmount: (billingData.chargedAmount - billingData.netAmount) / 100, }) // Send Notification diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts index 9ab23a816..d383df36b 100644 --- a/apps/api/src/email/template.interface.ts +++ b/apps/api/src/email/template.interface.ts @@ -94,8 +94,9 @@ export class CampaignNewsDraftEmailDto extends EmailTemplate<{ export class RefundDonationDto extends EmailTemplate<{ campaignName: string - firstName: string - lastName: string + netAmount: number + taxAmount: number + currency: string }> { - name = TemplateType.campaignNewsDraft + name = TemplateType.refundDonation } From 124f0f88a9c65c7edf9bc27735692436420e51b2 Mon Sep 17 00:00:00 2001 From: i553818 Date: Sun, 1 Oct 2023 13:19:33 +0300 Subject: [PATCH 03/11] Remove not needed debug --- apps/api/src/campaign/campaign.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 3f34cdd1b..3402d6adc 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -664,9 +664,7 @@ export class CampaignService { ...updatedDonation, person: updatedDonation.person, }) - Logger.debug('tuk3') } - Logger.debug('tuk4') return updatedDonation } catch (error) { Logger.error( From f5c6bee1108795bcd9c2df4a5ca71df9ceae1b6b Mon Sep 17 00:00:00 2001 From: i553818 Date: Sun, 1 Oct 2023 17:09:15 +0300 Subject: [PATCH 04/11] Add test for charge.refunded --- .../events/stripe-payment.service.spec.ts | 89 ++++++++++++++- .../events/stripe-payment.testdata.ts | 107 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) 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.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', From dc03c240c5f60516a522db0eef18769ec245a6c5 Mon Sep 17 00:00:00 2001 From: i553818 Date: Sun, 1 Oct 2023 17:28:57 +0300 Subject: [PATCH 05/11] Refactor vault service --- apps/api/src/vault/vault.service.ts | 31 ++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index f4b6e3315..5b9a1209e 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -117,24 +117,10 @@ export class VaultService { amount: number, tx: Prisma.TransactionClient, ): Promise { - if (amount <= 0) { - throw new Error('Amount cannot be negative or zero.') - } - - const updateStatement = { - where: { id: vaultId }, - data: { - amount: { - increment: amount, - }, - }, - } - - const vault = await tx.vault.update(updateStatement) - await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx) - + const vault = this.updateVaultAmount(vaultId, amount, tx, 'increment') return vault } + /** * Decrement vault amount as part of donation in prisma transaction */ @@ -143,6 +129,16 @@ export class VaultService { amount: number, tx: Prisma.TransactionClient, ): Promise { + const vault = this.updateVaultAmount(vaultId, amount, tx, 'decrement') + return vault + } + + async updateVaultAmount( + vaultId: string, + amount: number, + tx: Prisma.TransactionClient, + operationType: string, + ) { if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } @@ -151,13 +147,12 @@ export class VaultService { where: { id: vaultId }, data: { amount: { - decrement: amount, + [operationType]: amount, }, }, } const vault = await tx.vault.update(updateStatement) - return vault } } From e1b88c25d14aa47f78f08162f0bbe7f21b0cf1b5 Mon Sep 17 00:00:00 2001 From: i553818 Date: Sun, 1 Oct 2023 17:37:44 +0300 Subject: [PATCH 06/11] Fix vault-service --- apps/api/src/vault/vault.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 5b9a1209e..4282e3ddc 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -117,7 +117,10 @@ export class VaultService { amount: number, tx: Prisma.TransactionClient, ): Promise { - const vault = this.updateVaultAmount(vaultId, amount, tx, 'increment') + const vault = await this.updateVaultAmount(vaultId, amount, tx, 'increment') + + await this.campaignService.updateCampaignStatusIfTargetReached(vault.campaignId, tx) + return vault } @@ -129,8 +132,7 @@ export class VaultService { amount: number, tx: Prisma.TransactionClient, ): Promise { - const vault = this.updateVaultAmount(vaultId, amount, tx, 'decrement') - return vault + return this.updateVaultAmount(vaultId, amount, tx, 'decrement') } async updateVaultAmount( From 75c4806bc1bc3d226e01ec9c08b6927c5f0994ee Mon Sep 17 00:00:00 2001 From: Nikolay Nachev <44066540+Nnachevvv@users.noreply.github.com> Date: Mon, 2 Oct 2023 16:02:12 +0300 Subject: [PATCH 07/11] Remove breakline from email --- apps/api/src/assets/templates/refund-donation.mjml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/assets/templates/refund-donation.mjml b/apps/api/src/assets/templates/refund-donation.mjml index 175dc4c42..18db8e073 100644 --- a/apps/api/src/assets/templates/refund-donation.mjml +++ b/apps/api/src/assets/templates/refund-donation.mjml @@ -39,7 +39,7 @@ font-family="open Sans Helvetica, Arial, sans-serif" padding-left="25px" padding-right="25px"> - Това е автоматичен e-mail, който потвърждава, че ще получите обратно парите от
+ Това е автоматичен e-mail, който потвърждава, че ще получите обратно парите от вашето дарение към кампанията {{campaignName}}.
За съжаление, поради ограничения на Stripe, не можем да ви възстановим таксите, които са удържани от тях.

From 5af12e211d5ac0be13cbaf97f0df337c4bca337e Mon Sep 17 00:00:00 2001 From: Nnachevvv Date: Sat, 28 Oct 2023 23:33:31 +0300 Subject: [PATCH 08/11] Add refund endpoint --- .../donations/donations.controller.spec.ts | 16 ++++++++++++++ .../api/src/donations/donations.controller.ts | 9 ++++++++ apps/api/src/donations/donations.service.ts | 22 +++++++++++++++++++ 3 files changed, 47 insertions(+) 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 1d153de38..d2d18b994 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -228,6 +228,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.service.ts b/apps/api/src/donations/donations.service.ts index 33ac3c344..ef8f40a0e 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 From 4ba18d71ec94a027f176c4e717807e0c02d98b50 Mon Sep 17 00:00:00 2001 From: Nnachevvv Date: Sun, 29 Oct 2023 08:10:49 +0200 Subject: [PATCH 09/11] Update refund comment --- apps/api/src/donations/donations.service.ts | 2 +- apps/api/src/donations/events/stripe-payment.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index f60198a36..2580d40f9 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -486,7 +486,7 @@ export class DonationsService { * Refund a stipe payment donation * https://stripe.com/docs/api/refunds/create * @param inputDto Refund-stripe params - * @returns {Promise>} + * @returns {Promise>} */ async refundStripePayment(paymentIntentId: string): Promise> { const intent = await this.stripeClient.paymentIntents.retrieve(paymentIntentId) diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 9c3cb5768..be89934c7 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -17,7 +17,7 @@ import { } from '../helpers/payment-intent-helpers' import { DonationStatus, CampaignState } from '@prisma/client' import { EmailService } from '../../email/email.service' -import { RefundDonationDto } from '../../email/template.interface' +import { RefundDonationEmailDto } from '../../email/template.interface' import { PrismaService } from '../../prisma/prisma.service' /** Testing Stripe on localhost is described here: @@ -181,7 +181,7 @@ export class StripePaymentService { if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } - const mail = new RefundDonationDto({ + const mail = new RefundDonationEmailDto({ campaignName: campaign.title, currency: billingData.currency.toUpperCase(), netAmount: billingData.netAmount / 100, From 1bed959f58baedffb5708802b34e70b2a5de4d4b Mon Sep 17 00:00:00 2001 From: Nnachevvv Date: Sun, 29 Oct 2023 08:11:03 +0200 Subject: [PATCH 10/11] Udate emailVariable func name --- apps/api/src/assets/templates/refund-donation.json | 2 +- apps/api/src/email/template.interface.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/assets/templates/refund-donation.json b/apps/api/src/assets/templates/refund-donation.json index 5b1f03c7c..1d50e5b52 100644 --- a/apps/api/src/assets/templates/refund-donation.json +++ b/apps/api/src/assets/templates/refund-donation.json @@ -1,3 +1,3 @@ { - "subject": "Заявка за връщане на пари от дарение в Подкрепи.бг" + "subject": "Заявка за възстановяване на пари от дарение в Подкрепи.бг" } diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts index d383df36b..bcc0fd937 100644 --- a/apps/api/src/email/template.interface.ts +++ b/apps/api/src/email/template.interface.ts @@ -92,7 +92,7 @@ export class CampaignNewsDraftEmailDto extends EmailTemplate<{ name = TemplateType.campaignNewsDraft } -export class RefundDonationDto extends EmailTemplate<{ +export class RefundDonationEmailDto extends EmailTemplate<{ campaignName: string netAmount: number taxAmount: number From a7ce4214651cb8b7ae4b27993c25087cfc8ec764 Mon Sep 17 00:00:00 2001 From: Nnachevvv Date: Sun, 29 Oct 2023 15:27:23 +0200 Subject: [PATCH 11/11] Fix text in the e-mail --- apps/api/src/assets/templates/refund-donation.mjml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/assets/templates/refund-donation.mjml b/apps/api/src/assets/templates/refund-donation.mjml index 18db8e073..46f6cb73d 100644 --- a/apps/api/src/assets/templates/refund-donation.mjml +++ b/apps/api/src/assets/templates/refund-donation.mjml @@ -39,16 +39,16 @@ font-family="open Sans Helvetica, Arial, sans-serif" padding-left="25px" padding-right="25px"> - Това е автоматичен e-mail, който потвърждава, че ще получите обратно парите от - вашето дарение към кампанията {{campaignName}}.
+ Това е автоматичен e-mail, който потвърждава, че ще получите обратно парите от вашето + дарение към кампанията {{campaignName}}.
За съжаление, поради ограничения на Stripe, не можем да ви възстановим таксите, които са удържани от тях.

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

+ Транзакцията ще отнеме от 5 до 10 дни и парите ще ви бъдат върнати към същата карта, с + която сте превели дарението към Страйп.

Благодарим ви за разбирането!