From 87bb9dea088972034c50366f6dea38f811a09d36 Mon Sep 17 00:00:00 2001 From: Bogomil Tsvetkov Date: Sat, 17 Jun 2023 10:30:02 +0300 Subject: [PATCH 1/3] Admin notifications (#499) --- .env | 1 + .env.example | 2 + apps/api/src/app/app.module.ts | 2 +- .../templates/expiring-iris-consent.json | 3 + .../templates/expiring-iris-consent.mjml | 72 ++++++ .../templates/unrecognized-donation.json | 3 + .../templates/unrecognized-donation.mjml | 94 ++++++++ apps/api/src/config/configuration.ts | 4 +- .../dto/create-bankTransaction.dto.ts | 1 + .../dto/update-bankTransaction.dto.ts | 1 + .../entities/bankTransaction.entity.ts | 1 + apps/api/src/email/template.interface.ts | 19 ++ .../src/tasks/bank-import/dto/response.dto.ts | 25 ++ .../import-transactions.task.spec.ts | 218 ++++++++++++++---- .../bank-import/import-transactions.task.ts | 166 ++++++++++--- .../api/src/tasks/bank-import/tasks.module.ts | 11 - .../tasks/tasks-initializer.service.spec.ts | 100 ++++++++ .../src/tasks/tasks-initializer.service.ts | 55 +++++ apps/api/src/tasks/tasks.module.ts | 14 ++ .../migration.sql | 2 + podkrepi.dbml | 1 + schema.prisma | 2 + 22 files changed, 707 insertions(+), 90 deletions(-) create mode 100644 apps/api/src/assets/templates/expiring-iris-consent.json create mode 100644 apps/api/src/assets/templates/expiring-iris-consent.mjml create mode 100644 apps/api/src/assets/templates/unrecognized-donation.json create mode 100644 apps/api/src/assets/templates/unrecognized-donation.mjml delete mode 100644 apps/api/src/tasks/bank-import/tasks.module.ts create mode 100644 apps/api/src/tasks/tasks-initializer.service.spec.ts create mode 100644 apps/api/src/tasks/tasks-initializer.service.ts create mode 100644 apps/api/src/tasks/tasks.module.ts create mode 100644 migrations/20230518053741_add_notified_field_to_bank_transaction/migration.sql diff --git a/.env b/.env index d73bb7f3a..246550d97 100644 --- a/.env +++ b/.env @@ -95,3 +95,4 @@ IRIS_USER_HASH = BANK_BIC = UNCRBGSF PLATFORM_IBAN = IMPORT_TRX_TASK_INTERVAL_MINUTES = 60 +CHECK_IRIS_CONSENT_TASK_HOUR = 10 diff --git a/.env.example b/.env.example index 2645c69fb..452831a86 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,7 @@ SENDGRID_API_KEY=sendgrid-key SENDGRID_SENDER_EMAIL=info@podkrepi.bg SENDGRID_INTERNAL_EMAIL=dev@podkrepi.bg SENDGRID_CONTACTS_URL=/v3/marketing/contacts +ADMIN_NOTIFICATION_GROUP = the group that will receive admin notifications ## Stripe ## ############ @@ -94,3 +95,4 @@ IRIS_USER_HASH = BANK_BIC = UNCRBGSF PLATFORM_IBAN = IMPORT_TRX_TASK_INTERVAL_MINUTES = 60 +CHECK_IRIS_CONSENT_TASK_HOUR = 10 -> which hour of the day to run the check at diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 5f73df514..5e6b32465 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -50,7 +50,7 @@ import { ExportModule } from '../export/export.module' import { JwtModule } from '@nestjs/jwt' import { NotificationModule } from '../sockets/notifications/notification.module' import { ScheduleModule } from '@nestjs/schedule' -import { TasksModule } from '../tasks//bank-import/tasks.module' +import { TasksModule } from '../tasks/tasks.module' import { BankTransactionsModule } from '../bank-transactions/bank-transactions.module' @Module({ diff --git a/apps/api/src/assets/templates/expiring-iris-consent.json b/apps/api/src/assets/templates/expiring-iris-consent.json new file mode 100644 index 000000000..a43549176 --- /dev/null +++ b/apps/api/src/assets/templates/expiring-iris-consent.json @@ -0,0 +1,3 @@ +{ + "subject": "Изтичащо съгласие за достъп до банкова информация" +} diff --git a/apps/api/src/assets/templates/expiring-iris-consent.mjml b/apps/api/src/assets/templates/expiring-iris-consent.mjml new file mode 100644 index 000000000..7639d29b1 --- /dev/null +++ b/apps/api/src/assets/templates/expiring-iris-consent.mjml @@ -0,0 +1,72 @@ + + + + + + Засечени са неразпознати банкови дарения + + + + + + +

+
+ + Остават {{daysToExpire}} дни до изтичането на разрешението за достъп до + банковата информация, необходимо за извличане на транзакциите в системата на Podkrepi.bg. + Дата на изтичане е {{expiresAt}} . Можете да подновите разрешението за + още 90 дни(максимален период) като кликнете на бутона: + + + + Поднови банковото разрешение + + + + Поздрави,
+ Екипът на Подкрепи.бг +
+
+
+
+
diff --git a/apps/api/src/assets/templates/unrecognized-donation.json b/apps/api/src/assets/templates/unrecognized-donation.json new file mode 100644 index 000000000..d271539b5 --- /dev/null +++ b/apps/api/src/assets/templates/unrecognized-donation.json @@ -0,0 +1,3 @@ +{ + "subject": "Неразпознато банково дарение/я" +} diff --git a/apps/api/src/assets/templates/unrecognized-donation.mjml b/apps/api/src/assets/templates/unrecognized-donation.mjml new file mode 100644 index 000000000..7205ec759 --- /dev/null +++ b/apps/api/src/assets/templates/unrecognized-donation.mjml @@ -0,0 +1,94 @@ + + + + + + Засечени са неразпознати банкови дарения + + + + + + +

+
+ + При последния извършен импорт на банкови дарения от {{ importDate}} бяха засечени + транзакции, чиито код на кампания не е бил разпознат или импортът е бил неуспешен: + + + + + Транз. № + Изпращач + Сума + Основание + Статус + + {{#each transactions}} + + {{ id }} + {{ senderName }} + {{ amount }} + {{ description }} + {{ bankDonationStatus }} + + {{/each}} + + + + Към Банкови Транзакции + + + + Поздрави,
+ Екипът на Подкрепи.бг +
+
+
+
+
diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 12a68fa81..e0ab7073d 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -21,6 +21,7 @@ export default () => ({ sender: process.env.SENDGRID_SENDER_EMAIL, internalNotificationsEmail: process.env.SENDGRID_INTERNAL_EMAIL, contactsUrl: process.env.SENDGRID_CONTACTS_URL, + adminEmailGroup: process.env.ADMIN_NOTIFICATION_GROUP, }, stripe: { secretKey: process.env.STRIPE_SECRET_KEY, @@ -38,7 +39,8 @@ export default () => ({ bankBIC: process.env.BANK_BIC, platformIBAN: process.env.PLATFORM_IBAN, apiUrl: process.env.IRIS_API_URL, - consentEndPoint: process.env.IRIS_API_URL + '/consent', + getConsentEndPoint: process.env.IRIS_API_URL + '/consent', + checkConsentEndPoint: process.env.IRIS_API_URL + '/consents/{ibanID}', banksEndPoint: process.env.IRIS_API_URL + '/banks?country=bulgaria', ibansEndPoint: process.env.IRIS_API_URL + '/ibans', transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', diff --git a/apps/api/src/domain/generated/bankTransaction/dto/create-bankTransaction.dto.ts b/apps/api/src/domain/generated/bankTransaction/dto/create-bankTransaction.dto.ts index 347c046a2..05096c122 100644 --- a/apps/api/src/domain/generated/bankTransaction/dto/create-bankTransaction.dto.ts +++ b/apps/api/src/domain/generated/bankTransaction/dto/create-bankTransaction.dto.ts @@ -17,4 +17,5 @@ export class CreateBankTransactionDto { type: BankTransactionType @ApiProperty({ enum: BankDonationStatus }) bankDonationStatus?: BankDonationStatus + notified?: boolean } diff --git a/apps/api/src/domain/generated/bankTransaction/dto/update-bankTransaction.dto.ts b/apps/api/src/domain/generated/bankTransaction/dto/update-bankTransaction.dto.ts index 9013f9a94..c01df91e4 100644 --- a/apps/api/src/domain/generated/bankTransaction/dto/update-bankTransaction.dto.ts +++ b/apps/api/src/domain/generated/bankTransaction/dto/update-bankTransaction.dto.ts @@ -16,4 +16,5 @@ export class UpdateBankTransactionDto { type?: BankTransactionType @ApiProperty({ enum: BankDonationStatus }) bankDonationStatus?: BankDonationStatus + notified?: boolean } diff --git a/apps/api/src/domain/generated/bankTransaction/entities/bankTransaction.entity.ts b/apps/api/src/domain/generated/bankTransaction/entities/bankTransaction.entity.ts index 893f13ef7..1aeb5e393 100644 --- a/apps/api/src/domain/generated/bankTransaction/entities/bankTransaction.entity.ts +++ b/apps/api/src/domain/generated/bankTransaction/entities/bankTransaction.entity.ts @@ -16,4 +16,5 @@ export class BankTransaction { matchedRef: string | null type: BankTransactionType bankDonationStatus: BankDonationStatus | null + notified: boolean | null } diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts index e58c55b24..f7736a149 100644 --- a/apps/api/src/email/template.interface.ts +++ b/apps/api/src/email/template.interface.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client' import { CreatePersonDto } from '../person/dto/create-person.dto' import { CreateInquiryDto } from '../support/dto/create-inquiry.dto' import { CreateRequestDto } from '../support/dto/create-request.dto' @@ -8,6 +9,8 @@ export enum TemplateType { inquiryReceived = 'inquiry-received', inquiryReceivedInternal = 'inquiry-received-internal', forgotPass = 'forgot-password', + unrecognizedDonation = 'unrecognized-donation', + expiringIrisConsent = 'expiring-iris-consent', } export type TemplateTypeKeys = keyof typeof TemplateType export type TemplateTypeValues = typeof TemplateType[TemplateTypeKeys] @@ -54,3 +57,19 @@ export class InquiryReceivedEmailDto extends EmailTemplate { export class InquiryReceivedInternalEmailDto extends EmailTemplate { name = TemplateType.inquiryReceivedInternal } + +export class UnrecognizedDonationEmailDto extends EmailTemplate<{ + transactions: Partial[] + importDate: string + link: string +}> { + name = TemplateType.unrecognizedDonation +} + +export class ExpiringIrisConsentEmailDto extends EmailTemplate<{ + daysToExpire: number + expiresAt: string + renewLink: string +}> { + name = TemplateType.expiringIrisConsent +} diff --git a/apps/api/src/tasks/bank-import/dto/response.dto.ts b/apps/api/src/tasks/bank-import/dto/response.dto.ts index 3f7335221..41eb020f1 100644 --- a/apps/api/src/tasks/bank-import/dto/response.dto.ts +++ b/apps/api/src/tasks/bank-import/dto/response.dto.ts @@ -67,3 +67,28 @@ export type IrisTransactionInfo = { } export type GetIrisTransactionInfoResponse = { transactions: IrisTransactionInfo[] } + +export type IbanAccountConsentInfo = { + ibanId: string | null + consentId: string | null + iban: string + consentPermissions: ('BALANCES' | 'TRANSACTIONS')[] + validUntil: string + frequencyPerDay: number + givenAt: string + status: 'valid' | string + country: string | null +} + +export type GetIrisUserIbanConsentsResponse = { consents: IbanAccountConsentInfo[] } + +export type GetConsentLinkResponse = { + startUrl: string + endUrl: string | null + psuIdType: string | null + sca: string | null + gatherPsu: string | null + hasAuthorization: boolean + externalApp: boolean + authorizationId: string | null +} 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 64bdfba53..f5e802d3c 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 @@ -1,7 +1,7 @@ import { IrisIbanAccountInfo, IrisTransactionInfo } from './dto/response.dto' -import { ImportTransactionsTask } from './import-transactions.task' +import { IrisTasks } from './import-transactions.task' import { Test, TestingModule } from '@nestjs/testing' -import { HttpModule } from '@nestjs/axios' +import { HttpModule, HttpService } from '@nestjs/axios' import { MockPrismaService, prismaMock } from '../../prisma/prisma-client.mock' import { SchedulerRegistry } from '@nestjs/schedule/dist' @@ -17,11 +17,23 @@ import { ExportService } from '../../export/export.service' import { BankDonationStatus, BankTransaction, Campaign, Vault } from '@prisma/client' import { toMoney } from '../../common/money' import { DateTime } from 'luxon' +import { EmailService } from '../../email/email.service' +import { TemplateService } from '../../email/template.service' + +const IBAN = 'BG77UNCR92900016740920' + +class MockIrisTasks extends IrisTasks { + protected IBAN = IBAN +} describe('ImportTransactionsTask', () => { - let taskService: ImportTransactionsTask + let irisTasks: MockIrisTasks let testModule: TestingModule let scheduler: SchedulerRegistry + let emailService: EmailService + let httpService: HttpService + let configService: ConfigService + const personServiceMock = { findOneByKeycloakId: jest.fn(() => { return { id: 'mock' } @@ -148,14 +160,14 @@ describe('ImportTransactionsTask', () => { // Mock this before instantiating service - else it fails jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'checkForRequiredVariables') + .spyOn(IrisTasks.prototype as any, 'checkForRequiredVariables') .mockImplementation(() => true) beforeEach(async () => { testModule = await Test.createTestingModule({ imports: [HttpModule, NotificationModule], providers: [ - ImportTransactionsTask, + MockIrisTasks, MockPrismaService, { provide: ConfigService, @@ -173,14 +185,19 @@ describe('ImportTransactionsTask', () => { PersonService, ExportService, SchedulerRegistry, + EmailService, + TemplateService, ], }) .overrideProvider(PersonService) .useValue(personServiceMock) .compile() - taskService = await testModule.get(ImportTransactionsTask) + irisTasks = await testModule.get(MockIrisTasks) scheduler = testModule.get(SchedulerRegistry) + emailService = testModule.get(EmailService) + httpService = testModule.get(HttpService) + configService = testModule.get(ConfigService) }) afterEach(() => { @@ -189,63 +206,58 @@ describe('ImportTransactionsTask', () => { }) it('should be defined', () => { - expect(taskService).toBeDefined() - }) - - describe('initImportTransactionsTask', () => { - it('should register the import task', async () => { - jest.spyOn(taskService, 'initImportTransactionsTask') - jest.spyOn(scheduler, 'addInterval') - - await taskService.initImportTransactionsTask() - - expect(scheduler.addInterval).toHaveBeenCalledWith( - 'import-bank-transactions', - expect.anything(), - ) - expect(scheduler.getIntervals()).toEqual(['import-bank-transactions']) - }) + expect(irisTasks).toBeDefined() }) - describe('importBankTransactions', () => { + describe('importBankTransactionsTASK', () => { it('should import IRIS transactions', async () => { const donationService = testModule.get(DonationsService) const getIBANSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'getIrisUserIBANaccount') + .spyOn(IrisTasks.prototype as any, 'getIrisUserIBANaccount') .mockImplementation(() => irisIBANAccountMock) const getTrxSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'getTransactions') + .spyOn(IrisTasks.prototype as any, 'getTransactions') .mockImplementation(() => mockIrisTransactions) const checkTrxsSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'hasNewOrNonImportedTransactions') + .spyOn(IrisTasks.prototype as any, 'hasNewOrNonImportedTransactions') const prepareBankTrxSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'prepareBankTransactionRecords', ) const processDonationsSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'processDonations', ) const prepareBankPaymentSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'prepareBankPaymentObject', ) const donationSpy = jest.spyOn(donationService, 'createUpdateBankPayment') // eslint-disable-next-line @typescript-eslint/no-explicit-any - const saveTrxSpy = jest.spyOn(ImportTransactionsTask.prototype as any, 'saveBankTrxRecords') + const saveTrxSpy = jest.spyOn(IrisTasks.prototype as any, 'saveBankTrxRecords') + const notifyUnrecognizedSpy = jest.spyOn( + IrisTasks.prototype as any, + 'sendUnrecognizedDonationsMail', + ) + + // Spy email sending + jest.spyOn(emailService, 'sendFromTemplate').mockImplementation(async () => {}) jest.spyOn(prismaMock.bankTransaction, 'count').mockResolvedValue(0) jest.spyOn(prismaMock, '$transaction').mockResolvedValue('SUCCESS') jest.spyOn(prismaMock.campaign, 'findMany').mockResolvedValue(mockDonatedCampaigns) jest.spyOn(prismaMock.bankTransaction, 'createMany').mockResolvedValue({ count: 2 }) jest.spyOn(prismaMock.bankTransaction, 'updateMany') + jest + .spyOn(prismaMock.bankTransaction, 'findMany') + .mockResolvedValue([{ id: '1' }, { id: '2' }] as BankTransaction[]) const filteredIrisTransactions = mockIrisTransactions.filter( (trx) => @@ -254,7 +266,7 @@ describe('ImportTransactionsTask', () => { ) // Run task - await taskService.importBankTransactions() + await irisTasks.importBankTransactionsTASK() // 1. Should get IRIS iban account expect(getIBANSpy).toHaveBeenCalled() @@ -353,42 +365,80 @@ describe('ImportTransactionsTask', () => { skipDuplicates: true, }), ) + + // 7.Notify for unrecognized bank donations + expect(notifyUnrecognizedSpy).toHaveBeenCalledWith( + // Outgoing and Stripe payments should have been filtered + expect.arrayContaining( + filteredIrisTransactions.map((trx) => + expect.objectContaining({ + id: trx.transactionId, + description: trx.remittanceInformationUnstructured, + amount: toMoney(trx.transactionAmount.amount), + transactionDate: new Date(trx.valueDate), + type: trx.creditDebitIndicator.toLowerCase(), + }), + ), + ), + ) + + expect(emailService.sendFromTemplate).toHaveBeenCalled() + // Filter the unnotified failed/unrecognized transactions + expect(prismaMock.bankTransaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + bankDonationStatus: { + in: [BankDonationStatus.importFailed, BankDonationStatus.unrecognized], + }, + notified: false, + }), + }), + ) + + // Update trx notification status + expect(prismaMock.bankTransaction.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + notified: true, + }, + }), + ) }) it('should not run if all current transactions for the day have been processed', async () => { const donationService = testModule.get(DonationsService) const getIBANSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'getIrisUserIBANaccount') + .spyOn(IrisTasks.prototype as any, 'getIrisUserIBANaccount') .mockImplementation(() => irisIBANAccountMock) const getTrxSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'getTransactions') + .spyOn(IrisTasks.prototype as any, 'getTransactions') .mockImplementation(() => mockIrisTransactions) const checkTrxsSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'hasNewOrNonImportedTransactions', ) const prepareBankTrxSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'prepareBankTransactionRecords', ) const processDonationsSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'processDonations', ) const prepareBankPaymentSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'prepareBankPaymentObject', ) const donationSpy = jest.spyOn(donationService, 'createUpdateBankPayment') const saveTrxSpy = jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'saveBankTrxRecords') + .spyOn(IrisTasks.prototype as any, 'saveBankTrxRecords') // The length of the imported transactions is the same as the ones received from IRIS -meaning everything is up-to date jest.spyOn(prismaMock.bankTransaction, 'count').mockResolvedValue(mockIrisTransactions.length) @@ -396,7 +446,7 @@ describe('ImportTransactionsTask', () => { jest.spyOn(prismaMock.campaign, 'findMany').mockResolvedValue(mockDonatedCampaigns) // Run task - await taskService.importBankTransactions() + await irisTasks.importBankTransactionsTASK() // 1. Should get IRIS iban account expect(getIBANSpy).toHaveBeenCalled() @@ -420,22 +470,104 @@ describe('ImportTransactionsTask', () => { it('should not run if no transactions have been fetched', async () => { jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'getIrisUserIBANaccount') + .spyOn(IrisTasks.prototype as any, 'getIrisUserIBANaccount') .mockImplementation(() => irisIBANAccountMock) jest // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(ImportTransactionsTask.prototype as any, 'getTransactions') + .spyOn(IrisTasks.prototype as any, 'getTransactions') .mockImplementation(() => []) const prepareBankTrxSpy = jest.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any - ImportTransactionsTask.prototype as any, + IrisTasks.prototype as any, 'prepareBankTransactionRecords', ) // Run task - await taskService.importBankTransactions() + await irisTasks.importBankTransactionsTASK() expect(prepareBankTrxSpy).not.toHaveBeenCalled() }) }) + + describe('notifyForExpiringIrisConsentTASK', () => { + it('should notify for expiring Iris Consent', async () => { + const getIBANSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(IrisTasks.prototype as any, 'getIrisUserIBANaccount') + .mockImplementation(() => irisIBANAccountMock) + const getConsentLinkSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(IrisTasks.prototype as any, 'getConsentLink') + .mockImplementation(() => 'consent-link.com') + + // Spy email sending + jest.spyOn(emailService, 'sendFromTemplate').mockImplementation(async () => {}) + // Mock Config + jest.spyOn(configService, 'get').mockReturnValue('www.link.com/{ibanID}') + + const httpSpy = jest.spyOn(httpService.axiosRef, 'get').mockResolvedValue({ + data: { + consents: [ + { + iban: IBAN, + validUntil: DateTime.now().plus({ days: 3 }).toFormat('yyyy-MM-dd'), + }, + ], + }, + }) + + // Run task + await irisTasks.notifyForExpiringIrisConsentTASK() + + // 1. Get IBAN Account Info + expect(getIBANSpy).toHaveBeenCalled() + + // 2. Get the consent info for the IBAN + expect(httpSpy).toHaveBeenCalled() + + // 3 < 5 => notify for expiring consent + expect(getConsentLinkSpy).toHaveBeenCalled() + expect(emailService.sendFromTemplate).toHaveBeenCalled() + }) + }) + + it('should NOT notify for expiring Iris Consent before expTreshold is met', async () => { + const getIBANSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(IrisTasks.prototype as any, 'getIrisUserIBANaccount') + .mockImplementation(() => irisIBANAccountMock) + const getConsentLinkSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(IrisTasks.prototype as any, 'getConsentLink') + .mockImplementation((arg) => 'consent-link.com') + + // Spy email sending + jest.spyOn(emailService, 'sendFromTemplate').mockImplementation(async () => {}) + // Mock Config + jest.spyOn(configService, 'get').mockReturnValue('www.link.com/{ibanID}') + + const httpSpy = jest.spyOn(httpService.axiosRef, 'get').mockResolvedValue({ + data: { + consents: [ + { + iban: IBAN, + validUntil: DateTime.now().plus({ days: 6 }).toFormat('yyyy-MM-dd'), + }, + ], + }, + }) + + // Run task + await irisTasks.notifyForExpiringIrisConsentTASK() + + // 1. Get IBAN Account Info + expect(getIBANSpy).toHaveBeenCalled() + + // 2. Get the consent info for the IBAN + expect(httpSpy).toHaveBeenCalled() + + // 6 > 5 => don't notify for expiring consent + expect(getConsentLinkSpy).not.toHaveBeenCalled() + expect(emailService.sendFromTemplate).not.toHaveBeenCalled() + }) }) 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 6da15d3db..eb8fcfecb 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -13,9 +13,11 @@ import { } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' import { + GetConsentLinkResponse, GetIrisBanksResponse, GetIrisTransactionInfoResponse, GetIrisUserIbanAccountsResponse, + GetIrisUserIbanConsentsResponse, IrisIbanAccountInfo, IrisTransactionInfo, } from './dto/response.dto' @@ -24,20 +26,26 @@ import { DateTime } from 'luxon' import { toMoney } from '../../common/money' import { DonationsService } from '../../donations/donations.service' import { CreateBankPaymentDto } from '../../donations/dto/create-bank-payment.dto' +import { EmailService } from '../../email/email.service' +import { + ExpiringIrisConsentEmailDto, + UnrecognizedDonationEmailDto, +} from '../../email/template.interface' -type filteredTransaction = Prisma.BankTransactionCreateManyInput & { - matchedRef?: string | null -} +type filteredTransaction = Prisma.BankTransactionCreateManyInput @Injectable() -export class ImportTransactionsTask { +export class IrisTasks { private agentHash: string private userHash: string private bankBIC: string - private IBAN: string + protected IBAN: string private apiUrl: string + private adminEmailGroup: string private paymentMethodId = 'IRIS bank import' private regexPaymentRef = /\b[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}\b/g + // Consent expiration days left for notification + private daysToExpCondition = 5 // Used to check if the task should be stopped private canRun = true @@ -47,47 +55,68 @@ export class ImportTransactionsTask { private schedulerRegistry: SchedulerRegistry, private readonly donationsService: DonationsService, private prisma: PrismaService, + private sendEmail: EmailService, ) { this.agentHash = this.config.get('iris.agentHash', '') this.userHash = this.config.get('iris.userHash', '') this.bankBIC = this.config.get('iris.bankBIC', '') this.IBAN = this.config.get('iris.platformIBAN', '') this.apiUrl = this.config.get('iris.apiUrl', '') + this.adminEmailGroup = this.config.get('sendgrid.adminEmailGroup', '') this.checkForRequiredVariables() } - // NestJS Lifecycle Hook - onModuleInit() { - try { - this.initImportTransactionsTask() - } catch (e) { - Logger.error('Failed to initialize ImportTransactionsTask') - } - } + /** TASKS */ - initImportTransactionsTask() { - // Set the interval at which the import task will run - default 6 hours - const minutes = this.config.get('tasks.import_transactions.interval', 60 * 6) - const interval = 1000 * 60 * Number(minutes) + async notifyForExpiringIrisConsentTASK() { + Logger.debug('RUNNING TASK - Check Iris Consent') - const callback = async () => { - try { - await this.importBankTransactions() - } catch (e) { - Logger.error('An error occured while executing importBankTransactions') - } - } + const account = await this.getIrisUserIBANaccount() - const task = setInterval(callback, interval) + //TODO - Notify that the iban is not registered + if (!account) return - this.schedulerRegistry.addInterval('import-bank-transactions', task) + // Get consent details + const endpoint = this.config + .get('iris.checkConsentEndPoint', '') + .replace('{ibanID}', `${account.id}`) - Logger.debug(`import-bank-transactions task registered to run every ${minutes} minutes`) + const consents = ( + await this.httpService.axiosRef.get(endpoint, { + headers: { + 'x-user-hash': this.userHash, + }, + }) + ).data + + // Filter to current IBAN + const consent = consents.consents.find((consent) => consent.iban.trim() === this.IBAN) + + if (!consent) return + + const expDate = DateTime.fromFormat(consent.validUntil, 'yyyy-MM-dd') + const daysToExpire = Math.ceil(expDate.diff(DateTime.local(), 'days').toObject().days || 0) + + // If less than 5 days till expiration -> notify + if (daysToExpire <= this.daysToExpCondition) { + // Get consent renew link + const renewLink = await this.getConsentLink(account.bankHash) + + // Prepare Email data + const recepient = { to: [this.adminEmailGroup] } + const mail = new ExpiringIrisConsentEmailDto({ + daysToExpire, + expiresAt: consent.validUntil, + renewLink, + }) + + // Send Notification + await this.sendEmail.sendFromTemplate(mail, recepient) + } } - // Actual task to run - async importBankTransactions() { + async importBankTransactionsTASK() { // De-register the task, so that it doesn't waste server resources if (!this.canRun) { this.deregisterTask('import-bank-transactions') @@ -101,8 +130,9 @@ export class ImportTransactionsTask { try { const account = await this.getIrisUserIBANaccount() - // TODO - Notify that consent should be given if (!account) return Logger.error(`no consent granted for IBAN: ${this.IBAN}`) + if (account.consents.consents[0].status !== 'valid') + return Logger.error(`consent expired for IBAN: ${this.IBAN}`) ibanAccount = account } catch (e) { @@ -156,9 +186,18 @@ export class ImportTransactionsTask { return Logger.error('Failed to import transactions into DB') } + //7. Notify about unrecognized donations + try { + await this.sendUnrecognizedDonationsMail(processedBankTrxRecords) + } catch (e) { + return Logger.error('Failed to notify about bad transaction donations') + } + return } + /** METHODS */ + private async getIrisBankHash() { const endpoint = this.config.get('iris.banksEndPoint', '') @@ -189,10 +228,7 @@ export class ImportTransactionsTask { ).data // Find if provided IBAN is registered on IRIS and has a valid consent - const account = ibanAccounts.find( - (account) => - account.iban.trim() === this.IBAN && account.consents.consents[0].status === 'valid', - ) + const account = ibanAccounts.find((account) => account.iban.trim() === this.IBAN) return account } @@ -358,6 +394,68 @@ export class ImportTransactionsTask { return inserted } + private async sendUnrecognizedDonationsMail(data: filteredTransaction[]) { + // Filter the unnotified failed/unrecognized transactions + const transactions = await this.prisma.bankTransaction.findMany({ + where: { + id: { + in: data.map((trx) => trx.id), + }, + bankDonationStatus: { + in: [BankDonationStatus.importFailed, BankDonationStatus.unrecognized], + }, + notified: false, + }, + }) + + if (!transactions?.length) return + + // Build the link to bank-transactions section + const stage = this.config.get('APP_ENV') === 'development' ? 'APP_URL_LOCAL' : 'APP_URL' + const appUrl = this.config.get(stage) + const link = `${appUrl}/admin/bank-transactions` + + // Prepare Email data + const recepient = { to: [this.adminEmailGroup] } + const mail = new UnrecognizedDonationEmailDto({ + transactions, + importDate: DateTime.now().toFormat('dd-MM-yyyy'), + link, + }) + + // Send Notification + await this.sendEmail.sendFromTemplate(mail, recepient) + + // Mark notified + await this.prisma.bankTransaction.updateMany({ + where: { id: { in: transactions.map((trx) => trx.id) } }, + data: { + notified: true, + }, + }) + } + + private async getConsentLink(bankHash: string) { + const endpoint = this.config.get('iris.getConsentEndPoint', '') + + const response = ( + await this.httpService.axiosRef.post( + endpoint, + { + bankHash, + iban: this.IBAN, + }, + { + headers: { + 'x-user-hash': this.userHash, + }, + }, + ) + ).data + + return response.startUrl + } + private deregisterTask(taskName: string) { Logger.debug(`${taskName} task can't run, removing task from TaskRegistry`) this.schedulerRegistry.deleteInterval(taskName) diff --git a/apps/api/src/tasks/bank-import/tasks.module.ts b/apps/api/src/tasks/bank-import/tasks.module.ts deleted file mode 100644 index 8dbb6349c..000000000 --- a/apps/api/src/tasks/bank-import/tasks.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { HttpModule } from '@nestjs/axios' -import { Module } from '@nestjs/common' -import { DonationsModule } from '../../donations/donations.module' -import { PrismaService } from '../../prisma/prisma.service' -import { ImportTransactionsTask } from './import-transactions.task' - -@Module({ - imports: [HttpModule, DonationsModule], - providers: [ImportTransactionsTask, PrismaService], -}) -export class TasksModule {} diff --git a/apps/api/src/tasks/tasks-initializer.service.spec.ts b/apps/api/src/tasks/tasks-initializer.service.spec.ts new file mode 100644 index 000000000..feb2638ed --- /dev/null +++ b/apps/api/src/tasks/tasks-initializer.service.spec.ts @@ -0,0 +1,100 @@ +import { IrisTasks } from './bank-import/import-transactions.task' +import { Test, TestingModule } from '@nestjs/testing' +import { HttpModule } from '@nestjs/axios' + +import { MockPrismaService } from '../prisma/prisma-client.mock' +import { SchedulerRegistry } from '@nestjs/schedule/dist' +import { DonationsService } from '../donations/donations.service' +import { ConfigService } from '@nestjs/config' +import { PersonService } from '../person/person.service' +import { STRIPE_CLIENT_TOKEN } from '@golevelup/nestjs-stripe' +import { CampaignService } from '../campaign/campaign.service' + +import { VaultService } from '../vault/vault.service' +import { NotificationModule } from '../sockets/notifications/notification.module' +import { ExportService } from '../export/export.service' +import { TasksInitializer } from './tasks-initializer.service' +import { EmailService } from '../email/email.service' +import { TemplateService } from '../email/template.service' + +describe('ImportTransactionsTask', () => { + let taskService: TasksInitializer + let testModule: TestingModule + let scheduler: SchedulerRegistry + const personServiceMock = { + findOneByKeycloakId: jest.fn(() => { + return { id: 'mock' } + }), + } + const stripeMock = { + checkout: { sessions: { create: jest.fn() } }, + } + + // Mock this before instantiating service - else it failss + jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(IrisTasks.prototype as any, 'checkForRequiredVariables') + .mockImplementation(() => true) + + beforeEach(async () => { + testModule = await Test.createTestingModule({ + imports: [HttpModule, NotificationModule], + providers: [ + IrisTasks, + MockPrismaService, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: STRIPE_CLIENT_TOKEN, + useValue: stripeMock, + }, + DonationsService, + VaultService, + CampaignService, + PersonService, + ExportService, + SchedulerRegistry, + TasksInitializer, + EmailService, + TemplateService, + ], + }) + .overrideProvider(PersonService) + .useValue(personServiceMock) + .compile() + + taskService = await testModule.get(TasksInitializer) + scheduler = testModule.get(SchedulerRegistry) + }) + + afterEach(() => { + jest.clearAllMocks() + scheduler.getIntervals().forEach((el) => scheduler.deleteInterval(el)) + }) + + it('should be defined', () => { + expect(taskService).toBeDefined() + }) + + describe('initIrisTasks', () => { + it('should init all dynamicaly scheduled tasks', async () => { + jest.spyOn(taskService, 'initImportTransactionsTask') + jest.spyOn(scheduler, 'addInterval') + + // On module initiation all dynamic jobs must be scheduled + taskService.onModuleInit() + + expect(taskService.initImportTransactionsTask).toHaveBeenCalled() + + expect(scheduler.addInterval).toHaveBeenCalledWith( + 'import-bank-transactions', + expect.anything(), + ) + expect(scheduler.getIntervals()).toEqual(['import-bank-transactions']) + }) + }) +}) diff --git a/apps/api/src/tasks/tasks-initializer.service.ts b/apps/api/src/tasks/tasks-initializer.service.ts new file mode 100644 index 000000000..a2d17edd5 --- /dev/null +++ b/apps/api/src/tasks/tasks-initializer.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common' +import { IrisTasks } from './bank-import/import-transactions.task' +import { ConfigService } from '@nestjs/config' +import { Cron, SchedulerRegistry } from '@nestjs/schedule' + +// Schedules all background tasks +@Injectable() +export class TasksInitializer { + constructor( + private readonly irisTasks: IrisTasks, + private readonly config: ConfigService, + private schedulerRegistry: SchedulerRegistry, + ) {} + + /* DYNAMICALY SCHEDULED TASKS */ + + onModuleInit() { + try { + this.initImportTransactionsTask() + } catch (e) { + Logger.error('Failed to initialize ImportTransactionsTask') + } + } + + initImportTransactionsTask() { + // Set the interval at which the import task will run - default 6 hours + const minutes = this.config.get('tasks.import_transactions.interval', 60 * 6) + const interval = 1000 * 60 * Number(minutes) + + const callback = async () => { + try { + await this.irisTasks.importBankTransactionsTASK() + } catch (e) { + Logger.error('An error occured while executing importBankTransactions \n', e) + } + } + + const task = setInterval(callback, interval) + + this.schedulerRegistry.addInterval('import-bank-transactions', task) + + Logger.debug(`import-bank-transactions task registered to run every ${minutes} minutes`) + } + + /* DECLARATIVELY SCHEDULED TAKS */ + + @Cron(`0 ${process.env.CHECK_IRIS_CONSENT_TASK_HOUR} * * *`) + async initNotifyForExpiringConsentTask() { + try { + await this.irisTasks.notifyForExpiringIrisConsentTASK() + } catch (e) { + Logger.error('An error occured while checking for bank consent \n', e) + } + } +} diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts new file mode 100644 index 000000000..bc0a5d648 --- /dev/null +++ b/apps/api/src/tasks/tasks.module.ts @@ -0,0 +1,14 @@ +import { HttpModule } from '@nestjs/axios' +import { Module } from '@nestjs/common' +import { DonationsModule } from '../donations/donations.module' +import { PrismaService } from '../prisma/prisma.service' +import { IrisTasks } from './bank-import/import-transactions.task' +import { EmailService } from '../email/email.service' +import { TemplateService } from '../email/template.service' +import { TasksInitializer } from './tasks-initializer.service' + +@Module({ + imports: [HttpModule, DonationsModule], + providers: [IrisTasks, PrismaService, EmailService, TemplateService, TasksInitializer], +}) +export class TasksModule {} diff --git a/migrations/20230518053741_add_notified_field_to_bank_transaction/migration.sql b/migrations/20230518053741_add_notified_field_to_bank_transaction/migration.sql new file mode 100644 index 000000000..f2a0db681 --- /dev/null +++ b/migrations/20230518053741_add_notified_field_to_bank_transaction/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "bank_transactions" ADD COLUMN "notified" BOOLEAN DEFAULT false; diff --git a/podkrepi.dbml b/podkrepi.dbml index ec583a438..a207a4399 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -422,6 +422,7 @@ Table bank_transactions { matchedRef String type BankTransactionType [not null] bankDonationStatus BankDonationStatus + notified Boolean [default: false] } Table expenses { diff --git a/schema.prisma b/schema.prisma index 3ce5fb770..3caf04ad0 100644 --- a/schema.prisma +++ b/schema.prisma @@ -512,6 +512,8 @@ model BankTransaction { type BankTransactionType // For Bank donations, describes if campaign was recognized and donation imported bankDonationStatus BankDonationStatus? + // If a notification was sent about the failed status of the donation + notified Boolean? @default(false) @@map("bank_transactions") } From 5e480f1f4cc81ff1ba38e6f4c2035e67fb01cce1 Mon Sep 17 00:00:00 2001 From: quantum-grit <91589884+quantum-grit@users.noreply.github.com> Date: Sat, 17 Jun 2023 12:32:09 +0300 Subject: [PATCH 2/3] added notification env variables for deployment (#505) --- .env | 15 ++++++++------- .env.example | 17 +++++++++-------- apps/api/src/config/configuration.ts | 2 +- .../bank-import/import-transactions.task.ts | 8 ++++---- manifests/base/deployment.yaml | 4 ++++ 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.env b/.env index 246550d97..4cd7f31bf 100644 --- a/.env +++ b/.env @@ -89,10 +89,11 @@ JWT_SECRET_KEY=VerySecretPrivateKey ## Iris Bank Imports ## ########## -IRIS_API_URL = https://developer.sandbox.irispay.bg/api/8 -IRIS_AGENT_HASH = -IRIS_USER_HASH = -BANK_BIC = UNCRBGSF -PLATFORM_IBAN = -IMPORT_TRX_TASK_INTERVAL_MINUTES = 60 -CHECK_IRIS_CONSENT_TASK_HOUR = 10 +IRIS_API_URL=https://developer.sandbox.irispay.bg/api/8 +IRIS_AGENT_HASH= +IRIS_USER_HASH= +BANK_BIC=UNCRBGSF +PLATFORM_IBAN= +IMPORT_TRX_TASK_INTERVAL_MINUTES=60 +CHECK_IRIS_CONSENT_TASK_HOUR=10 +BILLING_ADMIN_MAIL=billing_admin@podkrepi.bg diff --git a/.env.example b/.env.example index 452831a86..0efda0b4d 100644 --- a/.env.example +++ b/.env.example @@ -69,7 +69,6 @@ SENDGRID_API_KEY=sendgrid-key SENDGRID_SENDER_EMAIL=info@podkrepi.bg SENDGRID_INTERNAL_EMAIL=dev@podkrepi.bg SENDGRID_CONTACTS_URL=/v3/marketing/contacts -ADMIN_NOTIFICATION_GROUP = the group that will receive admin notifications ## Stripe ## ############ @@ -89,10 +88,12 @@ JWT_SECRET_KEY=VerySecretPrivateKey ## Iris Bank Imports ## ########## -IRIS_API_URL = https://developer.sandbox.irispay.bg/api/8 -IRIS_AGENT_HASH = -IRIS_USER_HASH = -BANK_BIC = UNCRBGSF -PLATFORM_IBAN = -IMPORT_TRX_TASK_INTERVAL_MINUTES = 60 -CHECK_IRIS_CONSENT_TASK_HOUR = 10 -> which hour of the day to run the check at +IRIS_API_URL=https://developer.sandbox.irispay.bg/api/8 +IRIS_AGENT_HASH= +IRIS_USER_HASH= +BANK_BIC=UNCRBGSF +PLATFORM_IBAN= +IMPORT_TRX_TASK_INTERVAL_MINUTES=60 +#which hour of the day to run the check for consent +CHECK_IRIS_CONSENT_TASK_HOUR=10 +BILLING_ADMIN_MAIL=billing_admin@podkrepi.bg diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index e0ab7073d..a7a8e9381 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -21,7 +21,6 @@ export default () => ({ sender: process.env.SENDGRID_SENDER_EMAIL, internalNotificationsEmail: process.env.SENDGRID_INTERNAL_EMAIL, contactsUrl: process.env.SENDGRID_CONTACTS_URL, - adminEmailGroup: process.env.ADMIN_NOTIFICATION_GROUP, }, stripe: { secretKey: process.env.STRIPE_SECRET_KEY, @@ -44,6 +43,7 @@ export default () => ({ banksEndPoint: process.env.IRIS_API_URL + '/banks?country=bulgaria', ibansEndPoint: process.env.IRIS_API_URL + '/ibans', transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', + billingAdminEmail: process.env.BILLING_ADMIN_MAIL, }, tasks: { import_transactions: { interval: process.env.IMPORT_TRX_TASK_INTERVAL_MINUTES }, 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 eb8fcfecb..495c58dfc 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -41,7 +41,7 @@ export class IrisTasks { private bankBIC: string protected IBAN: string private apiUrl: string - private adminEmailGroup: string + private billingAdminEmail: string private paymentMethodId = 'IRIS bank import' private regexPaymentRef = /\b[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}\b/g // Consent expiration days left for notification @@ -62,7 +62,7 @@ export class IrisTasks { this.bankBIC = this.config.get('iris.bankBIC', '') this.IBAN = this.config.get('iris.platformIBAN', '') this.apiUrl = this.config.get('iris.apiUrl', '') - this.adminEmailGroup = this.config.get('sendgrid.adminEmailGroup', '') + this.billingAdminEmail = this.config.get('iris.billingAdminEmail', '') this.checkForRequiredVariables() } @@ -104,7 +104,7 @@ export class IrisTasks { const renewLink = await this.getConsentLink(account.bankHash) // Prepare Email data - const recepient = { to: [this.adminEmailGroup] } + const recepient = { to: [this.billingAdminEmail] } const mail = new ExpiringIrisConsentEmailDto({ daysToExpire, expiresAt: consent.validUntil, @@ -416,7 +416,7 @@ export class IrisTasks { const link = `${appUrl}/admin/bank-transactions` // Prepare Email data - const recepient = { to: [this.adminEmailGroup] } + const recepient = { to: [this.billingAdminEmail] } const mail = new UnrecognizedDonationEmailDto({ transactions, importDate: DateTime.now().toFormat('dd-MM-yyyy'), diff --git a/manifests/base/deployment.yaml b/manifests/base/deployment.yaml index 7fbe0c6af..878f81aa7 100644 --- a/manifests/base/deployment.yaml +++ b/manifests/base/deployment.yaml @@ -121,6 +121,10 @@ spec: value: BG66UNCR70001524349032 - name: IMPORT_TRX_TASK_INTERVAL_MINUTES value: '60' + - name: CHECK_IRIS_CONSENT_TASK_HOUR + value: '10' + - name: BILLING_ADMIN_MAIL + value: billing_admin@podkrepi.bg - name: IRIS_AGENT_HASH valueFrom: secretKeyRef: From a7c100ffb7cfd5f26a99a90557577bd365280fca Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Sat, 17 Jun 2023 13:09:29 +0300 Subject: [PATCH 3/3] src/donations: Expand user's donations query to check for billingEmail (#503) Currently anonnymous donations are not included in user's profile. Solve this by making necessary queries look for match in either keycloakId, or billingEmail. Note: This solution would only work if the logged in user has, either left the email field empty, or has entered the same email as the one belonging to the account. --- .../api/src/donations/donations.controller.ts | 26 ++++++++++++++++--- apps/api/src/donations/donations.service.ts | 19 +++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index da938c48e..2ba42d9ae 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -10,6 +10,9 @@ import { Query, Logger, Res, + Inject, + forwardRef, + NotFoundException, } from '@nestjs/common' import { ApiQuery, ApiTags } from '@nestjs/swagger' import { DonationStatus } from '@prisma/client' @@ -28,11 +31,15 @@ import { CreatePaymentIntentDto } from './dto/create-payment-intent.dto' import { DonationQueryDto } from '../common/dto/donation-query-dto' import { CancelPaymentIntentDto } from './dto/cancel-payment-intent.dto' import { DonationsApiQuery } from './queries/donations.apiquery' +import { PersonService } from '../person/person.service' @ApiTags('donation') @Controller('donation') export class DonationsController { - constructor(private readonly donationsService: DonationsService) {} + constructor( + private readonly donationsService: DonationsService, + @Inject(forwardRef(() => PersonService)) private readonly personService: PersonService, + ) {} @Get('export-excel') @DonationsApiQuery() @@ -97,7 +104,9 @@ export class DonationsController { @Get('user-donations') async userDonations(@AuthenticatedUser() user: KeycloakTokenParsed) { - return await this.donationsService.getDonationsByUser(user.sub) + const person = await this.personService.findOneByKeycloakId(user.sub); + if(!person) throw new NotFoundException("User was not found"); + return await this.donationsService.getDonationsByUser(user.sub, person.email) } @Get('money') @@ -160,8 +169,17 @@ export class DonationsController { } @Get('user/:id') - userDonationById(@Param('id') id: string, @AuthenticatedUser() user: KeycloakTokenParsed) { - return this.donationsService.getUserDonationById(id, user.sub) + async userDonationById(@Param('id') id: string, @AuthenticatedUser() user: KeycloakTokenParsed) { + const person = await this.personService.findOneByKeycloakId(user.sub); + if(!person) throw new NotFoundException("User was not found"); + const donation = await this.donationsService.getUserDonationById(id, user.sub, person.email) + return { + ...donation, + person: { + firstName: person.firstName, + lastName: person.lastName + } + } } @Post('create-payment') diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 3cc246073..45a9ff7e9 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -422,9 +422,17 @@ export class DonationsService { async getUserDonationById( id: string, keycloakId: string, + email: string, ): Promise<(Donation & { person: Person | null }) | null> { return await this.prisma.donation.findFirst({ - where: { id, person: { keycloakId }, status: DonationStatus.succeeded }, + where: { + id, + status: DonationStatus.succeeded, + OR:[ + {billingEmail: email}, + {person: { keycloakId }} + ] + }, include: { person: { select: { @@ -629,9 +637,14 @@ export class DonationsService { } } - async getDonationsByUser(keycloakId: string) { + async getDonationsByUser(keycloakId: string, email: string) { const donations = await this.prisma.donation.findMany({ - where: { person: { keycloakId } }, + where: { + OR:[ + {billingEmail: email}, + {person: { keycloakId }}, + ] + } , orderBy: [{ createdAt: 'desc' }], include: { targetVault: {