diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 127c1e9b1..f4e111217 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -44,6 +44,8 @@ export class BankTransactionsController { @ApiQuery({ name: 'from', required: false, type: Date }) @ApiQuery({ name: 'to', required: false, type: Date }) @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, type: String }) findAll(@Query() query?: BankTransactionsQueryDto) { return this.bankTransactionsService.listBankTransactions( query?.status, @@ -53,6 +55,8 @@ export class BankTransactionsController { query?.search, query?.pageindex, query?.pagesize, + query?.sortBy, + query?.sortOrder, ) } diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index 0fede8086..99c004600 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -11,6 +11,7 @@ import { } from '@prisma/client' import { ExportService } from '../export/export.service' import { getTemplateByTable } from '../export/helpers/exportableData' +import { Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { Response } from 'express' import { CreateBankPaymentDto } from '../donations/dto/create-bank-payment.dto' @@ -35,6 +36,8 @@ export class BankTransactionsService { * @param search (Optional) Search by sender info or description * @param pageIndex (Optional) * @param pageSize (Optional) + * @param sortBy (Optional) Sort by a specific field + * @param sortOrder (Optional) Sort order (ascending or descending) */ async listBankTransactions( bankDonationStatus?: BankDonationStatus, @@ -44,7 +47,13 @@ export class BankTransactionsService { search?: string, pageIndex?: number, pageSize?: number, + sortBy?: string, + sortOrder?: string, ) { + const defaultSort: Prisma.BankTransactionOrderByWithRelationInput = { + transactionDate: 'desc', + } + const data = await this.prisma.bankTransaction.findMany({ where: { bankDonationStatus, @@ -65,6 +74,7 @@ export class BankTransactionsService { }, skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, take: pageSize ? pageSize : undefined, + orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : defaultSort], }) const count = await this.prisma.bankTransaction.count({ diff --git a/apps/api/src/bank-transactions/dto/bank-transactions-query-dto.ts b/apps/api/src/bank-transactions/dto/bank-transactions-query-dto.ts index 943e5687a..fd6d47a0c 100644 --- a/apps/api/src/bank-transactions/dto/bank-transactions-query-dto.ts +++ b/apps/api/src/bank-transactions/dto/bank-transactions-query-dto.ts @@ -43,6 +43,16 @@ export class BankTransactionsQueryDto { @IsOptional() @Transform(({ value }) => toNumber(value)) pagesize?: number + + @Expose() + @IsOptional() + @Transform(({ value }) => falsyToUndefined(value)) + sortBy?: string + + @Expose() + @IsOptional() + @Transform(({ value }) => falsyToUndefined(value)) + sortOrder?: string } function toNumber(value: string, opts: ToNumberOptions = {}): number | undefined { 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 48988c2c1..07f4a997b 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -36,6 +36,7 @@ import { mockedCampaignCompeleted, mockedVault, mockChargeEventSucceeded, + mockPaymentEventFailed, } from './stripe-payment.testdata' import { DonationStatus } from '@prisma/client' import { RecurringDonationService } from '../../recurring-donation/recurring-donation.service' @@ -198,6 +199,42 @@ describe('StripePaymentService', () => { }) }) + it('should handle payment_intent.payment_failed', () => { + const payloadString = JSON.stringify(mockPaymentEventFailed, null, 2) + + const header = stripe.webhooks.generateTestHeaderString({ + payload: payloadString, + secret: stripeSecret, + }) + + const campaignService = app.get(CampaignService) + const mockedCampaignById = jest + .spyOn(campaignService, 'getCampaignById') + .mockImplementation(() => Promise.resolve(mockedCampaign)) + + const paymentData = getPaymentData(mockPaymentEventFailed.data.object as Stripe.PaymentIntent) + + const mockedUpdateDonationPayment = jest + .spyOn(campaignService, 'updateDonationPayment') + .mockImplementation(() => Promise.resolve('')) + .mockName('updateDonationPayment') + + 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).toHaveBeenCalledWith( + mockedCampaign, + paymentData, + DonationStatus.declined, + ) + }) + }) + it('should handle charge.succeeded for not anonymous user', () => { //Set not anonymous explicitly in the test data ;(mockChargeEventSucceeded.data.object as Stripe.Charge).metadata.isAnonymous = 'false' diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 6658ea241..77c8b27ab 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -13,6 +13,7 @@ import { string2RecurringDonationStatus, getInvoiceData, getPaymentDataFromCharge, + PaymentData, } from '../helpers/payment-intent-helpers' import { DonationStatus, CampaignState } from '@prisma/client' @@ -69,6 +70,30 @@ export class StripePaymentService { paymentIntent.metadata as DonationMetadata, ) + const billingData = getPaymentData(paymentIntent) + + this.updatePaymentDonationStatus(paymentIntent, billingData, DonationStatus.cancelled) + } + + @StripeWebhookHandler('payment_intent.payment_failed') + async handlePaymentIntentFailed(event: Stripe.Event) { + const paymentIntent: Stripe.PaymentIntent = event.data.object as Stripe.PaymentIntent + Logger.log( + '[ handlePaymentIntentFailed ]', + paymentIntent, + paymentIntent.metadata as DonationMetadata, + ) + + const billingData = getPaymentData(paymentIntent) + + await this.updatePaymentDonationStatus(paymentIntent, billingData, DonationStatus.declined) + } + + async updatePaymentDonationStatus( + paymentIntent: Stripe.PaymentIntent, + billingData: PaymentData, + donationStatus: DonationStatus, + ) { const metadata: DonationMetadata = paymentIntent.metadata as DonationMetadata if (!metadata.campaignId) { throw new BadRequestException( @@ -79,12 +104,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - const billingData = getPaymentData(paymentIntent) - await this.campaignService.updateDonationPayment( - campaign, - billingData, - DonationStatus.cancelled, - ) + await this.campaignService.updateDonationPayment(campaign, billingData, donationStatus) } @StripeWebhookHandler('charge.succeeded') diff --git a/apps/api/src/donations/events/stripe-payment.testdata.ts b/apps/api/src/donations/events/stripe-payment.testdata.ts index 0f01525a2..423048da3 100644 --- a/apps/api/src/donations/events/stripe-payment.testdata.ts +++ b/apps/api/src/donations/events/stripe-payment.testdata.ts @@ -206,6 +206,80 @@ export const mockPaymentEventCancelled: Stripe.Event = { type: 'payment_intent.canceled', } +export const mockPaymentEventFailed: Stripe.Event = { + id: 'evt_3LUzB4KApGjVGa9t0lyGsAk8', + object: 'event', + api_version: '2020-08-27', + created: 1660163846, + data: { + object: { + id: 'pi_3LUzB4KApGjVGa9t0NGvE94K', + object: 'payment_intent', + amount: 1065, + amount_capturable: 0, + amount_details: { + tip: {}, + }, + amount_received: 0, + application: null, + application_fee_amount: null, + automatic_payment_methods: null, + canceled_at: null, + cancellation_reason: null, + capture_method: 'automatic', + charges: { + object: 'list', + data: [], + has_more: false, + total_count: 0, + url: '/v1/charges?payment_intent=pi_3LUzB4KApGjVGa9t0NGvE94K', + }, + client_secret: 'pi', + confirmation_method: 'automatic', + created: 1660077446, + currency: 'bgn', + customer: null, + description: null, + invoice: null, + last_payment_error: null, + livemode: false, + metadata: { + campaignId: campaignId, + }, + next_action: null, + on_behalf_of: null, + payment_method: null, + payment_method_options: { + card: { + installments: null, + mandate_options: null, + network: null, + request_three_d_secure: 'automatic', + }, + }, + payment_method_types: ['card'], + processing: null, + receipt_email: null, + review: null, + setup_future_usage: null, + shipping: null, + source: null, + statement_descriptor: null, + statement_descriptor_suffix: null, + status: 'payment_failed', + transfer_data: null, + transfer_group: null, + }, + }, + livemode: false, + pending_webhooks: 1, + request: { + id: null, + idempotency_key: '8a2367c4-45bc-40a0-86c0-cff4c2229bec', + }, + type: 'payment_intent.payment_failed', +} + export const mockChargeEventSucceeded: Stripe.Event = { id: 'evt_3MmYVtKApGjVGa9t1d6zrtYm', object: 'event', diff --git a/apps/api/src/person/person.service.ts b/apps/api/src/person/person.service.ts index dee1199a8..3f8c553fa 100644 --- a/apps/api/src/person/person.service.ts +++ b/apps/api/src/person/person.service.ts @@ -91,7 +91,23 @@ export class PersonService { } async findOne(id: string) { - return await this.prisma.person.findFirst({ where: { id } }) + return await this.prisma.person.findFirst({ + where: { id }, + include: { + organizer: { select: { id: true, _count: { select: { campaigns: true } } } }, + coordinators: { select: { id: true, _count: { select: { campaigns: true } } } }, + beneficiaries: { + select: { + id: true, + countryCode: true, + cityId: true, + description: true, + organizerRelation: true, + _count: { select: { campaigns: true } }, + }, + }, + }, + }) } async findByEmail(email: string) { diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts index cce78f1af..8602e1a59 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts @@ -559,8 +559,7 @@ describe('ImportTransactionsTask', () => { it('should handle USD currency and parse the BGN equivalent from the transactionId', () => { const eurTransaction: IrisTransactionInfo = { - transactionId: - 'Booked_6516347588_70001524349032963FTRO23184809601C2023010361.12_20230103', + transactionId: 'Booked_6516347588_70001524349032963FTRO23184809601C2023010361.12_20230103', bookingDate: '2023-01-03', creditorAccount: { iban: 'BG66UNCR70001524349032', @@ -764,8 +763,14 @@ describe('ImportTransactionsTask', () => { consents: [ { iban: IBAN, + status: 'valid', validUntil: DateTime.now().plus({ days: 3 }).toFormat('yyyy-MM-dd'), }, + { + iban: IBAN, + status: 'expired', + validUntil: DateTime.now().minus({ days: 3 }).toFormat('yyyy-MM-dd'), + }, ], }, }) @@ -781,6 +786,7 @@ describe('ImportTransactionsTask', () => { // 3 < 5 => notify for expiring consent expect(getConsentLinkSpy).toHaveBeenCalled() + expect(emailService.sendFromTemplate).toHaveBeenCalled() }) }) @@ -805,6 +811,7 @@ describe('ImportTransactionsTask', () => { consents: [ { iban: IBAN, + status: 'valid', validUntil: DateTime.now().plus({ days: 6 }).toFormat('yyyy-MM-dd'), }, ], diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index 1d5332517..9ff1ac896 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -90,13 +90,15 @@ export class IrisTasks { }) ).data - // Filter to current IBAN - const consent = consents.consents.find((consent) => consent.iban.trim() === this.IBAN) + // Filter valid consents to the current IBAN + const consent = consents.consents.find( + (consent) => consent.iban.trim() === this.IBAN && consent.status === 'valid', + ) - if (!consent) return - - const expDate = DateTime.fromFormat(consent.validUntil, 'yyyy-MM-dd') - const daysToExpire = Math.ceil(expDate.diff(DateTime.local(), 'days').toObject().days || 0) + const expDate = consent + ? DateTime.fromFormat(consent.validUntil, 'yyyy-MM-dd') + : DateTime.local() //if no valid consent use today to send mail with days to expire 0 + const daysToExpire = Math.ceil(expDate.diff(DateTime.local(), 'days').days || 0) // If less than 5 days till expiration -> notify if (daysToExpire <= this.daysToExpCondition) { @@ -107,7 +109,7 @@ export class IrisTasks { const recepient = { to: [this.billingAdminEmail] } const mail = new ExpiringIrisConsentEmailDto({ daysToExpire, - expiresAt: consent.validUntil, + expiresAt: expDate.toISODate(), renewLink, })