From 22ffb13fea31e3a66c0eba9c73fcb737617b50e2 Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Tue, 14 Nov 2023 15:46:05 +0200 Subject: [PATCH] [2/2] Affiliate program Integration v1 (#564) Affiliate Program integration All changes below: * auth: Allow for corporate registrations The corporate registrations will be manually verified, and activated by podkrepi.bg administrator. * auth: Don't create new corporate profile if company exists * Affiliate Program: Initial changes -Added Affiliate model -Removed company field from Person model. That information will now be received from the company relation -Added endpoints to handle adding new affiliates, and updating their status * Run prisma migrate * schema.prisma: Add donation relation to Affiliate model * schema.prisma: Add guaranteed status for donations * Add affiliateId to testing and seeding scripts * validation: Set exposeDefaultValues to true globally Would allow us to set default values to fields directly in the dto, which might result in cleaner code. * src/affiliate: Implement endpoints for creating/cancelling donations Affiliate donations will be marked as guaranteed by default * src/campaign: Include guaranteedAmount in campaign's summary * tasks/bank-import: Handle affiliate donations * bank-transactions: Add endpoint to simulate IRIS transactions. Available only on development/staging enviroment * src/affiliate: Change affiliateCode structure * affiliate: Make affiliateCode available only for active affiliates * jest: Fix test breakage * src/donations: Include guaranteed donations in listPublic Removed unnecessary count query. Replaced with the length of the findMany query * eslint: Address errors and warnings * src/donations: Include companyName in listPublic endpoint * corporateUser: Add space between first and last name for legalpersonname * src/affiliate: Generate new code whenever affiliate is set to active * Run nx format * src/donations:c Add affiliateId to donation mock * src/affiliates: Add tests * bank-transaction: Return unprocessed if no guaranteed donations are found for affiliate * tasks/bank-import: Set transaction to importFailed if no guaranteed donations are found for affiliate * tasks/bank-import: Include test for affiliate donations * src/affiliate: Fix redundant space on ForbiddenException * affiliate.controller.spec: Rename test to AffiliateController * schema.prisma: Add companyId to person model * auth.service: Add companyId in createPerson method * src/register.controller: Use single endpoint for registration - Removed CompanyRegisterDto, and moved companyNumber and companyName to RegisterDto. - Added type to RegisterDto, to differentiate individual/corporate profiles - Made it so companyName and companyNumber fields will be validated only if profile's type is corporate * Consider bank transaction as successfull if no guaranteed donations are found * affiliate: Rename endpoints * bank-transaction: Update bankDonationStatus on duplicate record * affiliate: Create endpoint to get donations with pagination * schema.prisma:Affiliate: Make companyId not null * affiliateCodeGenerator: Get the first 8 characters * eslint: Address error and warnings * tasks/bank-import: Change bankstatus only if donation is affiliate * jest: Fix tests * swagger: Provide schema for iris-transaction-test endpoint * bank-import: Initialize variable with affiliate code prefix * donation: Add metadata field to donation Model Similar to how metadata field works in Stripe, this field will include additional data, for the donation, such as the name of the person who donated through the affiliate API, and additional field to store necessary for the affiliate data. * schema.prisma: Map DonationMetadata model * src/affiliate: Include metadata in affiliateDonationDto in jest * src/auth: Allow for corporate fields to be either string or undefined * src/affiliate: Improve semantics CreateAffiliateDonationDto: Removed validator decorators from affiliateId, personId, billingName and billigEmail, as those fields shouldn't be part of the request body itself. createAffiliateDonation(): Use chain conditioning to find whether affiliate exists or not. * src/affiliate: Fix test * src/affiliate: Include JWT_SECRET_KEY when generating affiliate code When generating affiliate code using only affiliateId, the affiliate code could easily be leaked, if some API response returns the affiliateId. This change ensures that this doesn't happen * src/affiliate: Create new endpoint to refresh affiliate code Would be useful in case of a leak * src/donations: Fix error handling if no vault has been found vaultService.findByCampaignId returns an array, thus we need to also check if the length of that array is 0 * src/affiliate: Check whether campaign accepts more donations before making one * src/donations: Bring back count query to listPublic service endpoint The result of donation's query is paginated, thus its length doesnt represent the total amount of donations, and thus breaks the pagination on the front-end * src/donations: Handle corporate donations * src/donations: Don't update updatedAt property on imported donation When we show the donations on frontend they are ordered by the updatedAt property in descending order, thus when bank transaction is imported, those affiliate donations will be shown again on top of the list, which might cause confusion * src/donation-wish: Extend selector to include companyName and donation type * src/affiliate: Include donation wish in query * updateAffiliateBankPayment: Use value of donation's updatedAt field When passing undefined to the query, prisma still updates the updatedAt field, as it uses default property * getUserDonationById: Include companyName inside the query Needed in order to show the corporation's name inside the certificate * userDonationById(): Get first and last name from keycloak token Needed in order to show the name of the donor on the certificate, if the donation is anonymous * src/affiliate: Add new endpoint to get affiliate's data by userid * src/affiliate: cleanup console.log * schema.prisma: Add profileEnabled to person table Makes it easier to track whether a corporate profile is enabled or not * src/account: Make endpoint for manual activation/deactivation of profile * src/person: Fix ordering by profile type not working * src/auth: Prevent deactivation of profile if it is in podkrepi-admin group * src/affiliate: Solve ts errors on controller.spec * src/affiliate: Add new endpoint to list all affiliates Needed for the admin panel * src/affiliate: Include person's email in findAll query result * src/auth: Fix test breakage * schema.prisma: Add createdAt and updatedAt fields to Affiliate model * src/donations: Improve validation of metadata fields if not undefined -if metadata is included in the request body -it should not be empty -should contain either name or extraData -extraData should be an object * src/donations: Fix ts errors * bank-transaction: iris-simulation should be done only by admin * dto/donation-metadata: Fix extraData field being empty on valid Object being sent Apparently the latest changes broke the extraData field. - Removed the unecessary dto extension, as it is enough to validated whether the extraData is Object - Used more specific type of the expected request body of extraData * src/auth: Change admin roles lookup In keycloak admin access is granted through team-support role - which is compose of view-supporters, view-contact-requests. * events/stripe: Resolve errors after merge of master * ESLint: Remove unused imports * auth.service.spec: Bring back issueToken testcase Not quite sure why it was removed --- apps/api/src/account/account.controller.ts | 17 +- apps/api/src/account/account.service.ts | 6 +- .../affiliate/affiliate.controller.spec.ts | 353 ++++++++++++++++++ .../api/src/affiliate/affiliate.controller.ts | 165 ++++++++ apps/api/src/affiliate/affiliate.module.ts | 15 + .../src/affiliate/affiliate.service.spec.ts | 19 + apps/api/src/affiliate/affiliate.service.ts | 119 ++++++ .../dto/affiliate-status-update.dto.ts | 12 + .../dto/create-affiliate-donation.dto.ts | 108 ++++++ .../affiliate/utils/affiliateCodeGenerator.ts | 10 + apps/api/src/app/app.module.ts | 3 + apps/api/src/auth/auth.module.ts | 2 + apps/api/src/auth/auth.service.spec.ts | 14 +- apps/api/src/auth/auth.service.ts | 75 +++- apps/api/src/auth/dto/register.dto.ts | 37 +- apps/api/src/auth/register.controller.spec.ts | 4 +- apps/api/src/auth/register.controller.ts | 17 +- .../bank-transactions-import-status.dto.ts | 1 + .../bank-transactions.controller.spec.ts | 2 + .../bank-transactions.controller.ts | 33 +- .../bank-transactions.module.ts | 10 +- .../bank-transactions.service.ts | 9 + .../bank-transactions-iris-simulate.dto.ts | 22 ++ .../dto/iris-bank-account-info.dto.ts | 38 ++ .../dto/iris-bank-transaction-info.dto.ts | 98 +++++ .../src/campaign/campaign.controller.spec.ts | 6 +- apps/api/src/campaign/campaign.service.ts | 31 +- .../src/campaign/dto/campaign-summary.dto.ts | 3 + .../src/campaign/dto/create-campaign.dto.ts | 12 +- apps/api/src/company/company.module.ts | 1 + apps/api/src/company/company.service.ts | 7 +- apps/api/src/config/validation.config.ts | 1 + .../affiliate/dto/connect-affiliate.dto.ts | 5 + .../affiliate/dto/create-affiliate.dto.ts | 3 + .../domain/generated/affiliate/dto/index.ts | 3 + .../affiliate/dto/update-affiliate.dto.ts | 3 + .../affiliate/entities/affiliate.entity.ts | 14 + .../generated/affiliate/entities/index.ts | 1 + .../company/dto/connect-company.dto.ts | 1 + .../company/dto/create-company.dto.ts | 1 + .../company/dto/update-company.dto.ts | 1 + .../company/entities/company.entity.ts | 5 + .../donation/entities/donation.entity.ts | 5 + .../dto/connect-donationMetadata.dto.ts | 3 + .../dto/create-donationMetadata.dto.ts | 6 + .../generated/donationMetadata/dto/index.ts | 3 + .../dto/update-donationMetadata.dto.ts | 6 + .../entities/donationMetadata.entity.ts | 10 + .../donationMetadata/entities/index.ts | 1 + .../person/dto/connect-person.dto.ts | 1 + .../generated/person/dto/create-person.dto.ts | 1 - .../generated/person/dto/update-person.dto.ts | 1 - .../person/entities/person.entity.ts | 5 +- .../donation-wish/donation-wish.service.ts | 18 +- .../donations/donations.controller.spec.ts | 12 +- .../api/src/donations/donations.controller.ts | 9 +- apps/api/src/donations/donations.service.ts | 126 +++++-- .../donations/dontation-metadata.interface.ts | 2 + .../src/donations/dto/create-session.dto.ts | 7 + .../donations/dto/donation-metadata.dto.ts | 21 ++ .../events/stripe-payment.service.spec.ts | 5 +- .../events/stripe-payment.service.ts | 9 +- .../helpers/donation-status-updates.ts | 1 + .../helpers/payment-intent-helpers.ts | 13 + .../donations/queries/donation.validator.ts | 7 +- apps/api/src/paypal/paypal.service.ts | 7 +- apps/api/src/person/dto/create-person.dto.ts | 6 - apps/api/src/person/dto/update-person.dto.ts | 11 +- apps/api/src/person/person.service.ts | 5 +- .../api/src/support/dto/create-inquiry.dto.ts | 2 - .../import-transactions.task.spec.ts | 105 +++++- .../bank-import/import-transactions.task.ts | 148 +++++++- db/seed/company/factory.ts | 1 + db/seed/donation/factory.ts | 1 + db/seed/person/data.ts | 5 - db/seed/person/factory.ts | 2 +- .../src/lib/person/create-person.dto.ts | 7 - .../migration.sql | 14 + .../migration.sql | 48 +++ .../migration.sql | 5 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 17 + .../migration.sql | 14 + .../migration.sql | 15 + .../migration.sql | 27 ++ .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 3 + podkrepi.dbml | 47 ++- schema.prisma | 87 +++-- 91 files changed, 1967 insertions(+), 177 deletions(-) create mode 100644 apps/api/src/affiliate/affiliate.controller.spec.ts create mode 100644 apps/api/src/affiliate/affiliate.controller.ts create mode 100644 apps/api/src/affiliate/affiliate.module.ts create mode 100644 apps/api/src/affiliate/affiliate.service.spec.ts create mode 100644 apps/api/src/affiliate/affiliate.service.ts create mode 100644 apps/api/src/affiliate/dto/affiliate-status-update.dto.ts create mode 100644 apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts create mode 100644 apps/api/src/affiliate/utils/affiliateCodeGenerator.ts create mode 100644 apps/api/src/bank-transactions/dto/bank-transactions-iris-simulate.dto.ts create mode 100644 apps/api/src/bank-transactions/dto/iris-bank-account-info.dto.ts create mode 100644 apps/api/src/bank-transactions/dto/iris-bank-transaction-info.dto.ts create mode 100644 apps/api/src/domain/generated/affiliate/dto/connect-affiliate.dto.ts create mode 100644 apps/api/src/domain/generated/affiliate/dto/create-affiliate.dto.ts create mode 100644 apps/api/src/domain/generated/affiliate/dto/index.ts create mode 100644 apps/api/src/domain/generated/affiliate/dto/update-affiliate.dto.ts create mode 100644 apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts create mode 100644 apps/api/src/domain/generated/affiliate/entities/index.ts create mode 100644 apps/api/src/domain/generated/donationMetadata/dto/connect-donationMetadata.dto.ts create mode 100644 apps/api/src/domain/generated/donationMetadata/dto/create-donationMetadata.dto.ts create mode 100644 apps/api/src/domain/generated/donationMetadata/dto/index.ts create mode 100644 apps/api/src/domain/generated/donationMetadata/dto/update-donationMetadata.dto.ts create mode 100644 apps/api/src/domain/generated/donationMetadata/entities/donationMetadata.entity.ts create mode 100644 apps/api/src/domain/generated/donationMetadata/entities/index.ts create mode 100644 apps/api/src/donations/dto/donation-metadata.dto.ts create mode 100644 migrations/20231009123833_link_company_to_person/migration.sql create mode 100644 migrations/20231009151309_affiliate_program_initial_changes/migration.sql create mode 100644 migrations/20231012153756_add_donation_relation_to_affiliate/migration.sql create mode 100644 migrations/20231013085126_add_guaranteed_donation_status/migration.sql create mode 100644 migrations/20231017120302_add_incomplete_bank_donation_status/migration.sql create mode 100644 migrations/20231024102451_add_company_id_to_person_field/migration.sql create mode 100644 migrations/20231024142056_affiliate_make_company_id_not_null/migration.sql create mode 100644 migrations/20231026144115_add_donation_metadata/migration.sql create mode 100644 migrations/20231027074921_map_donation_metadata/migration.sql create mode 100644 migrations/20231031091416_add_corporate_to_donation_type/migration.sql create mode 100644 migrations/20231106124859_add_profile_enabled_to_person/migration.sql create mode 100644 migrations/20231107131150_add_datetime_fields_to_affiliate/migration.sql diff --git a/apps/api/src/account/account.controller.ts b/apps/api/src/account/account.controller.ts index ce49b7317..14c7c07f7 100644 --- a/apps/api/src/account/account.controller.ts +++ b/apps/api/src/account/account.controller.ts @@ -1,5 +1,5 @@ import CredentialRepresentation from '@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation' -import { Body, Controller, Get, Put, Logger, Delete } from '@nestjs/common' +import { Body, Controller, Get, Put, Logger, Delete, Patch, Param } from '@nestjs/common' import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' @@ -114,4 +114,19 @@ export class AccountController { adminRole() { return { status: 'OK' } } + + @Patch(':keycloakId/status') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async changeProfileStatus( + @Param('keycloakId') keycloakId: string, + @Body() data: UpdatePersonDto, + ) { + return await this.accountService.changeProfileActivationStatus( + keycloakId, + !!data.profileEnabled, + ) + } } diff --git a/apps/api/src/account/account.service.ts b/apps/api/src/account/account.service.ts index 8cfb36e70..10f70d43a 100644 --- a/apps/api/src/account/account.service.ts +++ b/apps/api/src/account/account.service.ts @@ -25,6 +25,10 @@ export class AccountService { } async disableUser(user: KeycloakTokenParsed) { - return await this.authService.disableUser(user.sub) + return await this.authService.changeEnabledStatus(user.sub, false) + } + + async changeProfileActivationStatus(keycloakId: string, newStatus: boolean) { + return await this.authService.changeEnabledStatus(keycloakId, newStatus) } } diff --git a/apps/api/src/affiliate/affiliate.controller.spec.ts b/apps/api/src/affiliate/affiliate.controller.spec.ts new file mode 100644 index 000000000..62a523ef5 --- /dev/null +++ b/apps/api/src/affiliate/affiliate.controller.spec.ts @@ -0,0 +1,353 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { MockPrismaService, prismaMock } from '../prisma/prisma-client.mock' +import { AffiliateService } from './affiliate.service' +import { AffiliateController } from './affiliate.controller' +import { DonationsService } from '../donations/donations.service' +import { PersonService } from '../person/person.service' +import { STRIPE_CLIENT_TOKEN } from '@golevelup/nestjs-stripe' +import { ConfigService } from '@nestjs/config' +import { CampaignService } from '../campaign/campaign.service' +import { VaultService } from '../vault/vault.service' +import { NotificationModule } from '../sockets/notifications/notification.module' +import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { ExportService } from '../export/export.service' +import { Affiliate, Campaign, CampaignState, Donation, Prisma, Vault } from '@prisma/client' +import { KeycloakTokenParsed } from '../auth/keycloak' +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common' +import { AffiliateStatusUpdateDto } from './dto/affiliate-status-update.dto' +import * as afCodeGenerator from './utils/affiliateCodeGenerator' +import { CreateAffiliateDonationDto } from './dto/create-affiliate-donation.dto' + +type PersonWithPayload = Prisma.PersonGetPayload<{ include: { company: true } }> +type AffiliateWithPayload = Prisma.AffiliateGetPayload<{ + include: { company: { include: { person: true } }; donations: true } +}> + +describe('AffiliateController', () => { + let controller: AffiliateController + let service: AffiliateService + let donationService: DonationsService + const affiliateCodeMock = 'af_12345' + const stripeMock = { + checkout: { sessions: { create: jest.fn() } }, + } + + afterEach(() => { + jest.clearAllMocks() + }) + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [NotificationModule, MarketingNotificationsModule], + controllers: [AffiliateController], + providers: [ + AffiliateService, + MockPrismaService, + DonationsService, + { + provide: STRIPE_CLIENT_TOKEN, + useValue: stripeMock, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, + PersonService, + CampaignService, + VaultService, + ExportService, + ], + }).compile() + + controller = module.get(AffiliateController) + service = module.get(AffiliateService) + donationService = module.get(DonationsService) + }) + + const affiliateUpdateDto: AffiliateStatusUpdateDto = { + newStatus: 'active', + } + + const mockIndividualProfile: PersonWithPayload = { + id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', + firstName: 'John', + lastName: 'Doe', + companyId: null, + keycloakId: '123', + email: 'test@podkrepi.bg', + emailConfirmed: false, + phone: null, + picture: null, + createdAt: new Date('2021-10-07T13:38:11.097Z'), + updatedAt: new Date('2021-10-07T13:38:11.097Z'), + newsletter: false, + address: null, + birthday: null, + personalNumber: null, + stripeCustomerId: null, + company: null, + profileEnabled: true, + } + + const vaultMock: Vault = { + id: 'vault-id', + currency: 'BGN', + amount: 0, + blockedAmount: 0, + campaignId: 'campaign-id', + createdAt: new Date(), + updatedAt: new Date(), + name: 'vault-name', + } + const affiliateMock: Affiliate = { + id: '1234567', + companyId: '1234572', + affiliateCode: null, + status: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + } + const activeAffiliateMock: AffiliateWithPayload = { + ...affiliateMock, + status: 'active', + id: '12345', + affiliateCode: affiliateCodeMock, + company: { + id: '12345675', + companyName: 'Podkrepi BG Association', + companyNumber: '123456789', + legalPersonName: 'John Doe', + createdAt: new Date(), + updatedAt: new Date(), + personId: mockIndividualProfile.id, + cityId: null, + countryCode: null, + person: { ...mockIndividualProfile }, + }, + donations: [], + } + + const donationResponseMock: Donation = { + id: 'donation-id', + type: 'donation', + status: 'guaranteed', + amount: 5000, + affiliateId: activeAffiliateMock.id, + personId: null, + extCustomerId: '', + extPaymentIntentId: '123456', + extPaymentMethodId: '1234', + billingEmail: 'test@podkrepi.bg', + billingName: 'John Doe', + targetVaultId: vaultMock.id, + chargedAmount: 0, + currency: 'BGN', + createdAt: new Date(), + updatedAt: new Date(), + provider: 'bank', + } + + const userMock = { + sub: 'testKeycloackId', + 'allowed-origins': [], + } as KeycloakTokenParsed + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('Join program request', () => { + it('should throw error if request is from individual profile', async () => { + const createAffiliateSpy = jest.spyOn(service, 'create') + jest.spyOn(prismaMock.person, 'findFirst').mockResolvedValue(mockIndividualProfile) + expect(controller.joinAffiliateProgramRequest(userMock)).rejects.toThrow( + new BadRequestException('Must be corporate profile'), + ) + expect(createAffiliateSpy).not.toHaveBeenCalled() + }) + + it('should pass if request is coming from corporate profile', async () => { + const mockCorporateProfile = { + ...mockIndividualProfile, + company: { + id: '123', + companyName: 'Association Podkrepibg', + companyNumber: '1234', + legalPersonName: 'Podkrepibg Test', + createdAt: new Date(), + updatedAt: new Date(), + personId: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', + cityId: null, + countryCode: null, + }, + } + const createAffiliateSpy = jest.spyOn(service, 'create').mockResolvedValue(affiliateMock) + jest.spyOn(prismaMock.person, 'findFirst').mockResolvedValue(mockCorporateProfile) + + expect(await controller.joinAffiliateProgramRequest(userMock)).toEqual(affiliateMock) + expect(createAffiliateSpy).toHaveBeenCalled() + expect(createAffiliateSpy).toHaveBeenCalledWith(mockCorporateProfile.company.id) + }) + }) + + describe('Update affiliate status', () => { + it('should throw error if request is not coming from admin', async () => { + const affiliateSpy = jest.spyOn(service, 'findOneById') + const updateStatusSpy = jest.spyOn(service, 'updateStatus') + expect( + controller.updateAffiliateStatus(affiliateMock.id, affiliateUpdateDto, userMock), + ).rejects.toThrow(new ForbiddenException('Must be an admin')) + expect(affiliateSpy).not.toHaveBeenCalled() + expect(updateStatusSpy).not.toHaveBeenCalled() + }) + it('should generate affiliate code if status is changed to active', async () => { + const adminMock: KeycloakTokenParsed = { + ...userMock, + resource_access: { account: { roles: ['manage-account', 'account-view-supporters'] } }, + } + jest.spyOn(service, 'findOneById').mockResolvedValue(affiliateMock) + + const codeGenerationSpy = jest + .spyOn(afCodeGenerator, 'affiliateCodeGenerator') + .mockReturnValue(affiliateCodeMock) + const updateStatusMock = jest.spyOn(service, 'updateStatus') + + await controller.updateAffiliateStatus(affiliateMock.id, affiliateUpdateDto, adminMock) + + expect(codeGenerationSpy).toHaveBeenCalled() + expect(updateStatusMock).toHaveBeenCalledWith( + affiliateMock.id, + affiliateUpdateDto.newStatus, + affiliateCodeMock, + ) + }) + + it('affiliateCode should be null if newStatus is not active', async () => { + const adminMock: KeycloakTokenParsed = { + ...userMock, + resource_access: { account: { roles: ['manage-account', 'account-view-supporters'] } }, + } + + const activeAffiliateMock: Affiliate = { + ...affiliateMock, + status: 'active', + id: '12345', + affiliateCode: affiliateCodeMock, + } + + const mockCancelledStatus: AffiliateStatusUpdateDto = { + newStatus: 'cancelled', + } + jest.spyOn(service, 'findOneById').mockResolvedValue(activeAffiliateMock) + + const codeGenerationSpy = jest + .spyOn(afCodeGenerator, 'affiliateCodeGenerator') + .mockReturnValue(affiliateCodeMock) + const updateStatusSpy = jest.spyOn(service, 'updateStatus') + + await controller.updateAffiliateStatus(activeAffiliateMock.id, mockCancelledStatus, adminMock) + + expect(codeGenerationSpy).not.toHaveBeenCalled() + expect(updateStatusSpy).toHaveBeenCalledWith( + activeAffiliateMock.id, + mockCancelledStatus.newStatus, + null, + ) + }) + it('affiliateCode should be null if newStatus is not active', async () => { + const adminMock: KeycloakTokenParsed = { + ...userMock, + resource_access: { account: { roles: ['manage-account', 'account-view-supporters'] } }, + } + + jest.spyOn(service, 'findOneById').mockResolvedValue(activeAffiliateMock) + + const updateStatusDto: AffiliateStatusUpdateDto = { + newStatus: 'active', + } + const codeGenerationSpy = jest + .spyOn(afCodeGenerator, 'affiliateCodeGenerator') + .mockReturnValue(affiliateCodeMock) + + const updateStatusSpy = jest.spyOn(service, 'updateStatus') + + expect( + controller.updateAffiliateStatus(activeAffiliateMock.id, updateStatusDto, adminMock), + ).rejects.toThrow(new ConflictException('Status is the same')) + expect(codeGenerationSpy).not.toHaveBeenCalled() + expect(updateStatusSpy).not.toHaveBeenCalled() + }) + }) + + describe('Affiliate donations', () => { + it('should create donation', async () => { + const affiliateDonationDto: CreateAffiliateDonationDto = { + type: 'donation', + campaignId: '12345', + amount: 5000, + billingName: 'John Doe', + isAnonymous: true, + affiliateId: '123', + personId: null, + extCustomerId: '', + extPaymentIntentId: '123456', + extPaymentMethodId: '1234', + billingEmail: 'test@podkrepi.bg', + currency: 'BGN', + toEntity: new CreateAffiliateDonationDto().toEntity, + metadata: { + name: '', + extraData: {}, + }, + } + jest.spyOn(service, 'findOneByCode').mockResolvedValue(activeAffiliateMock) + const createAffiliateDonationSpy = jest + .spyOn(donationService, 'createAffiliateDonation') + .mockResolvedValue(donationResponseMock) + jest.spyOn(prismaMock.vault, 'findMany').mockResolvedValue([vaultMock]) + prismaMock.campaign.findFirst.mockResolvedValue({ + id: '123', + allowDonationOnComplete: false, + state: CampaignState.active, + } as Campaign) + await controller.createAffiliateDonation(affiliateCodeMock, affiliateDonationDto) + expect(createAffiliateDonationSpy).toHaveBeenCalledWith({ + ...affiliateDonationDto, + affiliateId: activeAffiliateMock.id, + }) + expect(await donationService.createAffiliateDonation(affiliateDonationDto)).toEqual( + donationResponseMock, + ) + }) + it('should cancel', async () => { + const cancelledDonationResponse: Donation = { + ...donationResponseMock, + status: 'cancelled', + } + jest + .spyOn(donationService, 'getAffiliateDonationById') + .mockResolvedValue(donationResponseMock) + jest.spyOn(donationService, 'update').mockResolvedValue(cancelledDonationResponse) + expect( + await controller.cancelAffiliateDonation(affiliateCodeMock, donationResponseMock.id), + ).toEqual(cancelledDonationResponse) + }) + it('should throw error if donation status is succeeded', async () => { + const succeededDonationResponse: Donation = { + ...donationResponseMock, + status: 'succeeded', + } + + jest + .spyOn(donationService, 'getAffiliateDonationById') + .mockResolvedValue(succeededDonationResponse) + const updateDonationStatus = jest.spyOn(donationService, 'update') + expect( + controller.cancelAffiliateDonation(affiliateCodeMock, donationResponseMock.id), + ).rejects.toThrow(new BadRequestException("Donation status can't be updated")) + expect(updateDonationStatus).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/api/src/affiliate/affiliate.controller.ts b/apps/api/src/affiliate/affiliate.controller.ts new file mode 100644 index 000000000..444e3479f --- /dev/null +++ b/apps/api/src/affiliate/affiliate.controller.ts @@ -0,0 +1,165 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + DefaultValuePipe, + ForbiddenException, + Get, + NotFoundException, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' +import { PersonService } from '../person/person.service' +import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' +import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' +import { AffiliateService } from './affiliate.service' +import { AffiliateStatusUpdateDto } from './dto/affiliate-status-update.dto' +import { CreateAffiliateDonationDto } from './dto/create-affiliate-donation.dto' +import { DonationsService } from '../donations/donations.service' +import { shouldAllowStatusChange } from '../donations/helpers/donation-status-updates' +import { affiliateCodeGenerator } from './utils/affiliateCodeGenerator' +import { DonationStatus } from '@prisma/client' +import { CampaignService } from '../campaign/campaign.service' +import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' + +@Controller('affiliate') +@ApiTags('affiliate') +export class AffiliateController { + constructor( + private readonly personService: PersonService, + private readonly affiliateService: AffiliateService, + private readonly donationService: DonationsService, + private readonly campaignService: CampaignService, + ) {} + + @Get('list-all') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async findAllAffiliates() { + return await this.affiliateService.findAll() + } + @Get('data') + async findAffiliateByUserId(@AuthenticatedUser() user: KeycloakTokenParsed) { + const affiliate = await this.affiliateService.getAffiliateDataByKeycloakId(user.sub) + return affiliate + } + + @Get(':affiliateCode') + @Public() + async affiliateSummary(@Param('affiliateCode') affilliateCode: string) { + const affiliate = await this.affiliateService.getAffiliateSummaryByCode(affilliateCode) + if (!affiliate) throw new NotFoundException('Affiliate not found') + return affiliate + } + + @Post('join') + async joinAffiliateProgramRequest(@AuthenticatedUser() user: KeycloakTokenParsed) { + const person = await this.personService.findOneByKeycloakId(user.sub) + if (!person) throw new NotFoundException('User is not found') + if (!person.company) throw new BadRequestException('Must be corporate profile') + return await this.affiliateService.create(person.company.id) + } + + @Patch(':affiliateId/status') + async updateAffiliateStatus( + @Param('affiliateId') affiliateId: string, + @Body() { newStatus }: AffiliateStatusUpdateDto, + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + if (!isAdmin(user)) throw new ForbiddenException('Must be an admin') + const affiliate = await this.affiliateService.findOneById(affiliateId) + + if (!affiliate) throw new NotFoundException('Affiliate not found') + + let affiliateCode: string | null = affiliate.affiliateCode + + if (affiliate.status === newStatus) { + throw new ConflictException('Status is the same') + } + + if (affiliate.status !== 'active' && newStatus === 'active') { + affiliateCode = affiliateCodeGenerator(affiliate.id) + } + + if (affiliate.status === 'active' && newStatus !== 'active') { + affiliateCode = null + } + + return await this.affiliateService.updateStatus(affiliateId, newStatus, affiliateCode) + } + + @Patch(':affiliateId/code-refresh') + async refreshAffiliateCode( + @Param('affiliateId') affiliateId: string, + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + if (!isAdmin(user)) throw new ForbiddenException('Must be an admin') + const affiliateCode = affiliateCodeGenerator(affiliateId) + return await this.affiliateService.updateCode(affiliateId, affiliateCode) + } + + @Post(':affiliateCode/donation') + @Public() + async createAffiliateDonation( + @Param('affiliateCode') affiliateCode: string, + @Body() donation: CreateAffiliateDonationDto, + ) { + const affiliate = await this.affiliateService.findOneByCode(affiliateCode) + if (!affiliate?.company?.person) throw new NotFoundException('Affiliate not found') + const campaign = await this.campaignService.getCampaignById(donation.campaignId) + const canAcceptDonation = await this.campaignService.canAcceptDonations(campaign) + if (!canAcceptDonation) { + throw new ConflictException('Campaign has been completed already') + } + const affiliateDonationDto: CreateAffiliateDonationDto = { + ...donation, + affiliateId: affiliate.id, + personId: donation.isAnonymous ? null : affiliate.company.person.id, + billingName: affiliate.company.person.firstName + ' ' + affiliate.company.person.lastName, + billingEmail: affiliate.company.person.email, + toEntity: donation.toEntity, + } + + return await this.donationService.createAffiliateDonation(affiliateDonationDto) + } + + @Get(':affiliateCode/donations') + @Public() + async getAffiliateDonations( + @Param('affiliateCode') affiliateCode: string, + @Query('status') status: DonationStatus | undefined, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return await this.affiliateService.findAffiliateDonationsWithPagination( + affiliateCode, + status, + page, + limit, + ) + } + + @Patch(':affiliateCode/donations/:donationId/cancel') + @Public() + async cancelAffiliateDonation( + @Param('affiliateCode') affiliateCode: string, + @Param('donationId') donationId: string, + ) { + const donation = await this.donationService.getAffiliateDonationById(donationId, affiliateCode) + if (!donation) { + throw new NotFoundException('Donation with this id is not found') + } + + if (!shouldAllowStatusChange(donation.status, 'cancelled')) + throw new BadRequestException("Donation status can't be updated") + + return await this.donationService.update(donation.id, { status: 'cancelled' }) + } +} diff --git a/apps/api/src/affiliate/affiliate.module.ts b/apps/api/src/affiliate/affiliate.module.ts new file mode 100644 index 000000000..dd834f2ee --- /dev/null +++ b/apps/api/src/affiliate/affiliate.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AffiliateController } from './affiliate.controller' +import { AffiliateService } from './affiliate.service' +import { PersonModule } from '../person/person.module' +import { PrismaService } from '../prisma/prisma.service' +import { DonationsModule } from '../donations/donations.module' +import { CampaignModule } from '../campaign/campaign.module' + +@Module({ + controllers: [AffiliateController], + providers: [AffiliateService, PrismaService], + imports: [PersonModule, DonationsModule, CampaignModule], + exports: [AffiliateService], +}) +export class AffiliateModule {} diff --git a/apps/api/src/affiliate/affiliate.service.spec.ts b/apps/api/src/affiliate/affiliate.service.spec.ts new file mode 100644 index 000000000..595a45d07 --- /dev/null +++ b/apps/api/src/affiliate/affiliate.service.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { MockPrismaService } from '../prisma/prisma-client.mock' +import { AffiliateService } from './affiliate.service' + +describe('AffiliateService', () => { + let service: AffiliateService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AffiliateService, MockPrismaService], + }).compile() + + service = module.get(AffiliateService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/affiliate/affiliate.service.ts b/apps/api/src/affiliate/affiliate.service.ts new file mode 100644 index 000000000..4cb4d173b --- /dev/null +++ b/apps/api/src/affiliate/affiliate.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../prisma/prisma.service' +import { AffiliateStatus, DonationStatus } from '@prisma/client' + +@Injectable() +export class AffiliateService { + constructor(private readonly prismaService: PrismaService) {} + async create(companyId: string) { + const affiliate = await this.prismaService.affiliate.create({ + data: { companyId }, + }) + return affiliate + } + + async findOneById(id: string) { + return await this.prismaService.affiliate.findUnique({ where: { id } }) + } + + async findAll() { + return await this.prismaService.affiliate.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + company: { + select: { + companyName: true, + companyNumber: true, + person: { + select: { + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }, + }, + }) + } + async findAffiliateByKeycloakId(keycloakId: string) { + return await this.prismaService.affiliate.count({ + where: { company: { person: { keycloakId } } }, + }) + } + + async getAffiliateDataByKeycloakId(keycloakId: string) { + return await this.prismaService.affiliate.findFirst({ + where: { company: { person: { keycloakId } } }, + include: { + donations: { + where: { status: DonationStatus.guaranteed }, + include: { + targetVault: { select: { campaign: { select: { title: true, slug: true } } } }, + affiliate: { select: { company: { select: { companyName: true } } } }, + metadata: { select: { name: true } }, + }, + }, + }, + }) + } + + async findAffiliateDonationsWithPagination( + affiliateCode: string, + status: DonationStatus | undefined, + currentPage: number, + limit: number, + ) { + return await this.prismaService.affiliate.findUnique({ + where: { affiliateCode }, + select: { + donations: { + orderBy: { createdAt: 'desc' }, + where: { status }, + take: limit, + skip: Number((currentPage - 1) * limit), + include: { metadata: true }, + }, + }, + }) + } + + async getAffiliateSummaryByCode(affiliateCode: string) { + return await this.prismaService.affiliate.findUnique({ + where: { affiliateCode }, + include: { + donations: { + orderBy: { createdAt: 'desc' }, + take: 10, + }, + company: { select: { companyName: true, companyNumber: true, legalPersonName: true } }, + }, + }) + } + + async findOneByCode(affiliateCode: string) { + return await this.prismaService.affiliate.findUnique({ + where: { affiliateCode }, + include: { company: { select: { person: true } } }, + }) + } + + async updateCode(affiliateId: string, affiliateCode: string) { + return await this.prismaService.affiliate.update({ + where: { id: affiliateId }, + data: { affiliateCode }, + }) + } + + async updateStatus( + affiliateId: string, + status: AffiliateStatus, + affiliateCode: string | null = null, + ) { + const affiliate = await this.prismaService.affiliate.update({ + where: { id: affiliateId }, + data: { status, affiliateCode }, + }) + return affiliate + } +} diff --git a/apps/api/src/affiliate/dto/affiliate-status-update.dto.ts b/apps/api/src/affiliate/dto/affiliate-status-update.dto.ts new file mode 100644 index 000000000..f0bb8e0b3 --- /dev/null +++ b/apps/api/src/affiliate/dto/affiliate-status-update.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger' +import { AffiliateStatus } from '@prisma/client' +import { Expose } from 'class-transformer' +import { IsEnum, IsString } from 'class-validator' + +export class AffiliateStatusUpdateDto { + @ApiProperty() + @IsString() + @Expose() + @IsEnum(AffiliateStatus) + newStatus: AffiliateStatus +} diff --git a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts new file mode 100644 index 000000000..838182a95 --- /dev/null +++ b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts @@ -0,0 +1,108 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Currency, DonationStatus, DonationType, PaymentProvider, Prisma } from '@prisma/client' +import { Expose, Type } from 'class-transformer' +import { + Equals, + IsBoolean, + IsEnum, + IsNotEmptyObject, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateIf, + ValidateNested, +} from 'class-validator' +import { randomUUID } from 'crypto' +import { DonationMetadataDto } from '../../donations/dto/donation-metadata.dto' + +export class CreateAffiliateDonationDto { + @ApiProperty() + @Expose() + @IsUUID() + @IsString() + campaignId: string + + @IsEnum(DonationType) + type: DonationType = DonationType.corporate + + affiliateId: string + + personId: string | null + + @IsString() + extPaymentIntentId: string = 'pi_' + randomUUID() + + @IsString() + @IsOptional() + extPaymentMethodId = 'affiliate' + + @ApiProperty() + @Expose() + @IsEnum(Currency) + //Only BGN is accepted for now + @Equals(Currency.BGN) + @IsOptional() + @IsString() + currency: Currency = Currency.BGN + + @ApiProperty() + @Expose() + @IsNumber() + amount: number + + billingName: string | null + + billingEmail: string | null + + @ApiProperty() + @Expose() + @IsBoolean() + isAnonymous: boolean + + @ApiProperty() + @Expose() + @IsUUID() + @IsString() + @IsOptional() + extCustomerId: string + + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + message?: string + + @ApiProperty() + @Expose() + @ValidateIf((req) => typeof req.metadata !== 'undefined') + @IsNotEmptyObject({ nullable: true }) + @Type(() => DonationMetadataDto) + @ValidateNested({ each: true }) + metadata: DonationMetadataDto | undefined + + public toEntity(targetVaultId: string): Prisma.DonationCreateInput { + return { + type: DonationType.corporate, + status: DonationStatus.guaranteed, + provider: PaymentProvider.bank, + currency: this.currency, + amount: this.amount, + extCustomerId: this.extCustomerId ?? '', + extPaymentIntentId: this.extPaymentIntentId, + extPaymentMethodId: this.extPaymentMethodId ?? '', + billingEmail: this.billingEmail, + billingName: this.billingName, + targetVault: { + connect: { + id: targetVaultId, + }, + }, + affiliate: this.affiliateId ? { connect: { id: this.affiliateId } } : {}, + person: + this.isAnonymous === false && this.billingEmail + ? { connect: { email: this.billingEmail } } + : {}, + } + } +} diff --git a/apps/api/src/affiliate/utils/affiliateCodeGenerator.ts b/apps/api/src/affiliate/utils/affiliateCodeGenerator.ts new file mode 100644 index 000000000..2a1658163 --- /dev/null +++ b/apps/api/src/affiliate/utils/affiliateCodeGenerator.ts @@ -0,0 +1,10 @@ +import crypto from 'crypto' + +export function affiliateCodeGenerator(affiliateId: string) { + const uniqueHash = crypto + .createHash('sha256') + .update(affiliateId + process.env.JWT_SECRET_KEY + new Date()) + .digest('hex') + .slice(0, 8) + return 'af_' + uniqueHash +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 61aba5f94..b5d5f935f 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -56,7 +56,9 @@ import { CacheModule } from '@nestjs/cache-manager' import { CampaignNewsModule } from '../campaign-news/campaign-news.module' import { CampaignNewsFileModule } from '../campaign-news-file/campaign-news-file.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' + import { StatisticsModule } from '../statistics/statistics.module' +import { AffiliateModule } from '../affiliate/affiliate.module' @Module({ imports: [ @@ -76,6 +78,7 @@ import { StatisticsModule } from '../statistics/statistics.module' TasksModule, /* Internal modules */ AuthModule, + AffiliateModule, AccountModule, CampaignModule, CampaignFileModule, diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 88df6f22b..ad5aa565e 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -14,6 +14,7 @@ import { JwtModule, JwtService } from '@nestjs/jwt' import { EmailService } from '../email/email.service' import { TemplateService } from '../email/template.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { CompanyModule } from '../company/company.module' @Module({ controllers: [LoginController, RegisterController, RefreshController, ProviderLoginController], @@ -27,6 +28,7 @@ import { MarketingNotificationsModule } from '../notifications/notifications.mod imports: [AppConfigModule], }), MarketingNotificationsModule, + CompanyModule, ], exports: [AuthService], }) diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 483dcf72d..5474f3239 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -37,11 +37,11 @@ describe('AuthService', () => { id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', firstName: 'Admin', lastName: 'Dev', + companyId: null, keycloakId: '123', email: 'test@podkrepi.bg', emailConfirmed: false, phone: null, - company: null, picture: null, createdAt: new Date('2021-10-07T13:38:11.097Z'), updatedAt: new Date('2021-10-07T13:38:11.097Z'), @@ -50,6 +50,7 @@ describe('AuthService', () => { birthday: null, personalNumber: null, stripeCustomerId: null, + profileEnabled: false, } beforeEach(async () => { @@ -324,6 +325,9 @@ describe('AuthService', () => { const password = 's3cret' const firstName = 'John' const lastName = 'Doe' + //if no company has been created company.id is expected to be undefined + const companyId = undefined + const profileEnabled = true const newsletter = true it('should call keycloak and prisma', async () => { @@ -374,7 +378,7 @@ describe('AuthService', () => { // Check db creation expect(prismaSpy).toHaveBeenCalledWith({ - create: { keycloakId, email, firstName, lastName, newsletter }, + create: { keycloakId, email, firstName, lastName, newsletter, companyId, profileEnabled }, update: { keycloakId }, where: { email }, }) @@ -420,11 +424,11 @@ describe('AuthService', () => { id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', firstName, lastName, + companyId: null, keycloakId, email, emailConfirmed: false, phone: null, - company: null, picture: null, createdAt: new Date('2021-10-07T13:38:11.097Z'), updatedAt: new Date('2021-10-07T13:38:11.097Z'), @@ -433,6 +437,7 @@ describe('AuthService', () => { birthday: null, personalNumber: null, stripeCustomerId: null, + profileEnabled: true, } jest.spyOn(prismaMock.person, 'upsert').mockResolvedValue(person) jest.spyOn(admin.users, 'create').mockResolvedValue({ id: keycloakId }) @@ -469,11 +474,11 @@ describe('AuthService', () => { id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', firstName, lastName, + companyId: null, keycloakId, email, emailConfirmed: false, phone: null, - company: null, picture: null, createdAt: new Date('2021-10-07T13:38:11.097Z'), updatedAt: new Date('2021-10-07T13:38:11.097Z'), @@ -482,6 +487,7 @@ describe('AuthService', () => { birthday: null, personalNumber: null, stripeCustomerId: null, + profileEnabled: true, } jest.spyOn(prismaMock.person, 'upsert').mockResolvedValue(person) jest.spyOn(admin.users, 'create').mockResolvedValue({ id: keycloakId }) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 0035171d3..bdad5b727 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Inject, Injectable, InternalServerErrorException, @@ -17,10 +18,10 @@ import { RequiredActionAlias } from '@keycloak/keycloak-admin-client/lib/defs/re import { AxiosResponse } from '@nestjs/terminus/dist/health-indicator/http/axios.interfaces' import { TokenResponseRaw } from '@keycloak/keycloak-admin-client/lib/utils/auth' -import { Person } from '@prisma/client' +import { Company, Person } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { LoginDto } from './dto/login.dto' -import { RegisterDto } from './dto/register.dto' +import { ProfileType, RegisterDto } from './dto/register.dto' import { RefreshDto } from './dto/refresh.dto' import { KeycloakTokenParsed } from './keycloak' import { ProviderDto } from './dto/provider.dto' @@ -156,12 +157,15 @@ export class AuthService { await this.authenticateAdmin() const userData = await this.admin.users.findOne({ id: user.sub }) const registerDto: RegisterDto = { + type: ProfileType.INDIVIDUAL, email: userData?.email ?? '', password: '', firstName: userData?.firstName ?? '', lastName: userData?.lastName ?? '', + companyName: undefined, + companyNumber: undefined, } - await this.createPerson(registerDto, user.sub) + await this.createPerson(registerDto, user.sub, undefined) } return { refreshToken: grant.refresh_token?.token, @@ -177,14 +181,21 @@ export class AuthService { } } - async createUser(registerDto: RegisterDto): Promise { + async createUser( + registerDto: RegisterDto, + isCorporateReg = false, + ): Promise { let person: Person + let company: Company | null = null try { await this.authenticateAdmin() // Create user in Keycloak - const user = await this.createKeycloakUser(registerDto, false) + const user = await this.createKeycloakUser(registerDto, false, !isCorporateReg) // Insert or connect person in app db - person = await this.createPerson(registerDto, user.id) + if (isCorporateReg) { + company = await this.createCompany(registerDto) + } + person = await this.createPerson(registerDto, user.id, company?.id) } catch (error) { const response = { error: error.message, @@ -225,13 +236,17 @@ export class AuthService { }) } - private async createKeycloakUser(registerDto: RegisterDto, verifyEmail: boolean) { + private async createKeycloakUser( + registerDto: RegisterDto, + verifyEmail: boolean, + activeProfile = true, + ) { return await this.admin.users.create({ username: registerDto.email, email: registerDto.email, firstName: registerDto.firstName, lastName: registerDto.lastName, - enabled: true, + enabled: activeProfile, emailVerified: true, groups: [], requiredActions: verifyEmail ? [RequiredActionAlias.VERIFY_EMAIL] : [], @@ -261,7 +276,11 @@ export class AuthService { return `${serverUrl}/realms/${realm}/protocol/openid-connect/token` } - private async createPerson(registerDto: RegisterDto, keycloakId: string) { + private async createPerson( + registerDto: RegisterDto, + keycloakId: string, + companyId: string | null | undefined, + ) { return await this.prismaService.person.upsert({ // Create a person with the provided keycloakId create: { @@ -270,6 +289,8 @@ export class AuthService { firstName: registerDto.firstName, lastName: registerDto.lastName, newsletter: registerDto.newsletter ? registerDto.newsletter : false, + companyId: companyId, + profileEnabled: companyId ? false : true, }, // Store keycloakId to the person with same email update: { keycloakId }, @@ -277,6 +298,21 @@ export class AuthService { }) } + private async createCompany(registerDto: RegisterDto): Promise { + if (!registerDto.companyName || !registerDto.companyNumber) { + throw new BadRequestException('Company name and companyNumber are missing') + } + return await this.prismaService.company.create({ + // Create a person with the provided keycloakId + data: { + companyNumber: registerDto.companyNumber, + companyName: registerDto.companyName, + legalPersonName: registerDto.firstName + ' ' + registerDto.lastName, + }, + // Store keycloakId to the person with same email + }) + } + async updateUser(keycloakId: string, updateDto: UpdatePersonDto) { await this.authenticateAdmin() await this.admin.users.update( @@ -315,14 +351,29 @@ export class AuthService { return true } - async disableUser(keycloakId: string) { + async changeEnabledStatus(keycloakId: string, enabled: boolean) { await this.authenticateAdmin() - return await this.admin.users.update( + // check if user is admin before attempting to activate/deactivate + const userGroups = await this.admin.users.listRoleMappings({ id: keycloakId }) + const isAdmin = userGroups.realmMappings?.some( + (obj) => + obj.name === 'team-support' || + obj.name === 'view-supporters' || + obj.name === 'view-contact-requests', + ) + if (isAdmin) { + throw new ForbiddenException("Admin profiles can't be deactivated") + } + await this.admin.users.update( { id: keycloakId }, { - enabled: false, + enabled, }, ) + return await this.prismaService.person.update({ + where: { keycloakId }, + data: { profileEnabled: enabled }, + }) } async sendMailForPasswordChange(forgotPasswordDto: ForgottenPasswordEmailDto) { diff --git a/apps/api/src/auth/dto/register.dto.ts b/apps/api/src/auth/dto/register.dto.ts index d2614dc87..fee8399b9 100644 --- a/apps/api/src/auth/dto/register.dto.ts +++ b/apps/api/src/auth/dto/register.dto.ts @@ -1,8 +1,27 @@ import { ApiProperty } from '@nestjs/swagger' import { Expose } from 'class-transformer' -import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator' +import { + IsBoolean, + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateIf, +} from 'class-validator' + +export enum ProfileType { + INDIVIDUAL = 'individual', + CORPORATE = 'corporate', +} export class RegisterDto { + @ApiProperty() + @Expose() + @IsEnum(ProfileType) + public readonly type: ProfileType = ProfileType.INDIVIDUAL + @ApiProperty() @Expose() @IsNotEmpty() @@ -32,4 +51,20 @@ export class RegisterDto { @IsOptional() @IsBoolean() public readonly newsletter?: boolean + + @ValidateIf((o) => o.type === ProfileType.CORPORATE) + @Expose() + @IsString() + @ApiProperty() + @MaxLength(100) + companyName: string | undefined + + @ValidateIf((o) => o.type === ProfileType.CORPORATE) + @Expose() + @IsString() + @ApiProperty({ + description: + 'BULSTAT Unified Identification Code (UIC) https://psc.egov.bg/en/psc-starting-a-business-bulstat', + }) + companyNumber: string | undefined } diff --git a/apps/api/src/auth/register.controller.spec.ts b/apps/api/src/auth/register.controller.spec.ts index 735130993..5a9e13ec5 100644 --- a/apps/api/src/auth/register.controller.spec.ts +++ b/apps/api/src/auth/register.controller.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { AuthService } from './auth.service' import { RegisterController } from './register.controller' import { RegisterDto } from './dto/register.dto' +import { CompanyModule } from '../company/company.module' describe('RegisterController', () => { let controller: RegisterController @@ -21,6 +22,7 @@ describe('RegisterController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [RegisterController], providers: [AuthService, AuthServiceProvider], + imports: [CompanyModule], }).compile() controller = module.get(RegisterController) @@ -36,7 +38,7 @@ describe('RegisterController', () => { it('should call createUser', async () => { expect(await controller.register(registerDto)) expect(spyService.createUser).toHaveBeenCalled() - expect(spyService.createUser).toHaveBeenCalledWith(registerDto) + expect(spyService.createUser).toHaveBeenCalledWith(registerDto, false) }) }) }) diff --git a/apps/api/src/auth/register.controller.ts b/apps/api/src/auth/register.controller.ts index 8eefa5a67..8197fcea1 100644 --- a/apps/api/src/auth/register.controller.ts +++ b/apps/api/src/auth/register.controller.ts @@ -1,19 +1,28 @@ -import { Body, Controller, Post } from '@nestjs/common' +import { Body, ConflictException, Controller, Post } from '@nestjs/common' import { Public, Resource, Scopes } from 'nest-keycloak-connect' import { AuthService } from './auth.service' -import { RegisterDto } from './dto/register.dto' +import { RegisterDto, ProfileType } from './dto/register.dto' import { ApiTags } from '@nestjs/swagger' +import { CompanyService } from '../company/company.service' @ApiTags('register') @Controller('register') @Resource('register') export class RegisterController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly companyService: CompanyService, + ) {} @Post() @Public() @Scopes('view') async register(@Body() registerDto: RegisterDto) { - return await this.authService.createUser(registerDto) + const isCorporateReg = registerDto.type === ProfileType.CORPORATE + if (isCorporateReg) { + const company = await this.companyService.findOneByEIK(registerDto.companyNumber) + if (company) throw new ConflictException('Company with this EIK already exists') + } + return await this.authService.createUser(registerDto, isCorporateReg) } } diff --git a/apps/api/src/bank-transactions-file/dto/bank-transactions-import-status.dto.ts b/apps/api/src/bank-transactions-file/dto/bank-transactions-import-status.dto.ts index 439d48ce7..281a5c880 100644 --- a/apps/api/src/bank-transactions-file/dto/bank-transactions-import-status.dto.ts +++ b/apps/api/src/bank-transactions-file/dto/bank-transactions-import-status.dto.ts @@ -3,6 +3,7 @@ export enum ImportStatus { SUCCESS = 'SUCCESS', FAILED = 'FAILED', UPDATED = 'UPDATED', + INCOMPLETE = 'INCOMPLETE', } export type BankImportResult = { diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts b/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts index dd18fd892..1fe099d13 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts @@ -25,6 +25,7 @@ import { SchedulerRegistry } from '@nestjs/schedule' import { EmailService } from '../email/email.service' import { TemplateService } from '../email/template.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { AffiliateService } from '../affiliate/affiliate.service' const bankTransactionsMock = [ { @@ -145,6 +146,7 @@ describe('BankTransactionsController', () => { SchedulerRegistry, EmailService, TemplateService, + AffiliateService, ], }).compile() diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 813e3bc6e..75db2fed5 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -3,7 +3,9 @@ import { BadRequestException, Body, Controller, + ForbiddenException, Get, + HttpCode, Logger, NotFoundException, Param, @@ -12,7 +14,7 @@ import { Query, Res, } from '@nestjs/common' -import { ApiQuery, ApiTags } from '@nestjs/swagger' +import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger' import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' import { Roles, RoleMatchingMode, AuthenticatedUser } from 'nest-keycloak-connect' import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' @@ -24,6 +26,9 @@ import { import { CampaignService } from '../campaign/campaign.service' import { BankDonationStatus } from '@prisma/client' import { DateTime, Interval } from 'luxon' +import { ConfigService } from '@nestjs/config' +import { IrisBankTransactionSimulationDto } from './dto/bank-transactions-iris-simulate.dto' +import { AffiliateService } from '../affiliate/affiliate.service' @ApiTags('bank-transaction') @Controller('bank-transaction') @@ -31,6 +36,8 @@ export class BankTransactionsController { constructor( private readonly bankTransactionsService: BankTransactionsService, private readonly campaignService: CampaignService, + private readonly configService: ConfigService, + private readonly affiliateService: AffiliateService, ) {} @Get('list') @@ -145,4 +152,28 @@ export class BankTransactionsController { await this.bankTransactionsService.rerunBankTransactionsForDate(new Date(d.start.toISODate())) }) } + + @ApiOperation({ summary: 'Simulating bank transaction response from IRIS API' }) + @ApiResponse({ status: 204 }) + @ApiBody({ + type: IrisBankTransactionSimulationDto, + description: 'Request body for simulating IRIS response', + }) + @HttpCode(204) + @Post('iris-transaction-test') + async testIrisInsertion( + @Body() irisDto: IrisBankTransactionSimulationDto, + @AuthenticatedUser() user: KeycloakTokenParsed, + ) { + const appEnv = this.configService.get('APP_ENV') + const isDev = appEnv === 'development' || appEnv === 'staging' + if (!isDev) throw new ForbiddenException('Endpoint available only for testing enviroments') + + if (!isAdmin(user)) throw new ForbiddenException('Must be either an admin or active affiliate') + + return await this.bankTransactionsService.simulateIrisTask( + irisDto.irisIbanAccountInfo, + irisDto.irisTransactionInfo, + ) + } } diff --git a/apps/api/src/bank-transactions/bank-transactions.module.ts b/apps/api/src/bank-transactions/bank-transactions.module.ts index 844e8f183..319d36820 100644 --- a/apps/api/src/bank-transactions/bank-transactions.module.ts +++ b/apps/api/src/bank-transactions/bank-transactions.module.ts @@ -10,9 +10,17 @@ import { IrisTasks } from '../tasks/bank-import/import-transactions.task' import { HttpModule } from '@nestjs/axios' import { EmailService } from '../email/email.service' import { TemplateService } from '../email/template.service' +import { AffiliateModule } from '../affiliate/affiliate.module' @Module({ - imports: [CampaignModule, DonationsModule, ConfigModule, ExportModule, HttpModule], + imports: [ + CampaignModule, + DonationsModule, + ConfigModule, + ExportModule, + HttpModule, + AffiliateModule, + ], controllers: [BankTransactionsController], providers: [BankTransactionsService, PrismaService, IrisTasks, EmailService, TemplateService], //TODO: Create Email module to not need to import each service exports: [BankTransactionsService], diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index aa08b9535..cc61ec955 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -17,6 +17,8 @@ import { Response } from 'express' import { CreateBankPaymentDto } from '../donations/dto/create-bank-payment.dto' import { DonationsService } from '../donations/donations.service' import { IrisTasks } from '../tasks/bank-import/import-transactions.task' +import { IrisIbanAccountInfoDto } from './dto/iris-bank-account-info.dto' +import { IrisTransactionInfoDto } from './dto/iris-bank-transaction-info.dto' @Injectable() export class BankTransactionsService { @@ -165,4 +167,11 @@ export class BankTransactionsService { async rerunBankTransactionsForDate(transactionsDate: Date) { await this.irisBankImport.importBankTransactionsTASK(transactionsDate) } + + async simulateIrisTask( + ibanAccount: IrisIbanAccountInfoDto, + transactions: IrisTransactionInfoDto[], + ) { + await this.irisBankImport.simulateBankTransactionImportTask(ibanAccount, transactions) + } } diff --git a/apps/api/src/bank-transactions/dto/bank-transactions-iris-simulate.dto.ts b/apps/api/src/bank-transactions/dto/bank-transactions-iris-simulate.dto.ts new file mode 100644 index 000000000..065200b78 --- /dev/null +++ b/apps/api/src/bank-transactions/dto/bank-transactions-iris-simulate.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose, Type } from 'class-transformer' +import { IsArray, IsObject, ValidateNested } from 'class-validator' + +import { IrisIbanAccountInfoDto } from './iris-bank-account-info.dto' +import { IrisTransactionInfoDto } from './iris-bank-transaction-info.dto' + +export class IrisBankTransactionSimulationDto { + @ApiProperty() + @Expose() + @IsObject() + @ValidateNested({ each: true }) + @Type(() => IrisIbanAccountInfoDto) + irisIbanAccountInfo: IrisIbanAccountInfoDto + + @ApiProperty({ type: () => IrisTransactionInfoDto }) + @Expose() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => IrisTransactionInfoDto) + irisTransactionInfo: IrisTransactionInfoDto[] +} diff --git a/apps/api/src/bank-transactions/dto/iris-bank-account-info.dto.ts b/apps/api/src/bank-transactions/dto/iris-bank-account-info.dto.ts new file mode 100644 index 000000000..1e671831d --- /dev/null +++ b/apps/api/src/bank-transactions/dto/iris-bank-account-info.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Currency } from '@prisma/client' +import { Expose } from 'class-transformer' +import { IsIBAN, IsString } from 'class-validator' + +class ConsentDTO { + consents: [{ status: string }] + errorCodes: unknown +} + +export class IrisIbanAccountInfoDto { + id: number + + name: string + + @ApiProperty() + @IsString() + @IsIBAN() + @Expose() + iban: string + + currency: Currency + + hasAuthorization: boolean + + bankHash: string + + @ApiProperty() + @IsString() + @Expose() + bankName: string + + country: string + + dateCreate: number + + consents: ConsentDTO +} diff --git a/apps/api/src/bank-transactions/dto/iris-bank-transaction-info.dto.ts b/apps/api/src/bank-transactions/dto/iris-bank-transaction-info.dto.ts new file mode 100644 index 000000000..6ff9cf07b --- /dev/null +++ b/apps/api/src/bank-transactions/dto/iris-bank-transaction-info.dto.ts @@ -0,0 +1,98 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Currency } from '@prisma/client' +import { Expose, Type } from 'class-transformer' +import { + IsEnum, + IsIBAN, + IsNumber, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator' + +class bankAccountDto { + @ApiProperty() + @IsString() + @IsIBAN() + @Expose() + iban: string +} + +class transactionAmountDto { + @ApiProperty() + @IsNumber() + @Expose() + amount: number + + @ApiProperty() + @IsEnum(Currency) + @Expose() + currency: Currency +} + +export class IrisTransactionInfoDto { + @ApiProperty() + @IsString() + @Expose() + transactionId: string + + @ApiProperty() + @IsString() + @Expose() + bookingDate: string + + @ApiProperty() + @IsObject() + @Expose() + @Type(() => bankAccountDto) + @ValidateNested({ each: true }) + debtorAccount: bankAccountDto + + @ApiProperty() + @IsObject() + @Expose() + @Type(() => bankAccountDto) + @ValidateNested({ each: true }) + creditorAccount: bankAccountDto + + @ApiProperty() + @IsOptional() + @IsString() + @Expose() + creditorName: string | null + + @ApiProperty() + @IsOptional() + @IsString() + @Expose() + debtorName: string | null + + @ApiProperty() + @IsString() + @Expose() + remittanceInformationUnstructured: string + + @ApiProperty() + @IsObject() + @Expose() + @Type(() => transactionAmountDto) + @ValidateNested({ each: true }) + transactionAmount: transactionAmountDto + + @ApiProperty() + @IsOptional() + @IsNumber() + @Expose() + exchangeRate: number | null + + @ApiProperty() + @Expose() + @IsString() + valueDate: string + + @ApiProperty() + @Expose() + @IsString() + creditDebitIndicator: 'DEBIT' | 'CREDIT' +} diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index 4220c0fc6..a8bdb2427 100644 --- a/apps/api/src/campaign/campaign.controller.spec.ts +++ b/apps/api/src/campaign/campaign.controller.spec.ts @@ -58,8 +58,8 @@ describe('CampaignController', () => { keycloakId: 'some-id', email: 'user@email.com', emailConfirmed: false, + companyId: null, phone: null, - company: null, picture: null, createdAt: new Date('2021-10-07T13:38:11.097Z'), updatedAt: new Date('2021-10-07T13:38:11.097Z'), @@ -226,6 +226,7 @@ describe('CampaignController', () => { blockedAmount: 0, withdrawnAmount: 0, donors: 2, + guaranteedAmount: 0, }, }, ]) @@ -263,6 +264,7 @@ describe('CampaignController', () => { blockedAmount: 0, withdrawnAmount: 0, donors: 2, + guaranteedAmount: 0, }, }, ]) @@ -287,6 +289,7 @@ describe('CampaignController', () => { reachedAmount: 110, currentAmount: 0, blockedAmount: 0, + guaranteedAmount: 0, withdrawnAmount: 0, donors: 2, }, @@ -325,6 +328,7 @@ describe('CampaignController', () => { blockedAmount: 0, withdrawnAmount: 0, donors: 2, + guaranteedAmount: 0, }, }, }) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index cad228c16..a1290b346 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -101,10 +101,11 @@ export class CampaignService { const result = await this.prisma.$queryRaw`SELECT SUM(d.reached)::INTEGER as "reachedAmount", + SUM(g.guaranteed)::INTEGER as "guaranteedAmount", (SUM(v.amount) - SUM(v."blockedAmount"))::INTEGER as "currentAmount", SUM(v."blockedAmount")::INTEGER as "blockedAmount", SUM(w."withdrawnAmount")::INTEGER as "withdrawnAmount", - SUM(d.donors)::INTEGER as donors, + SUM(COALESCE(g.donors, 0) + COALESCE(d.donors, 0))::INTEGER as donors, v.campaign_id as id FROM api.vaults v LEFT JOIN ( @@ -114,6 +115,13 @@ export class CampaignService { GROUP BY target_vault_id ) as d ON d.target_vault_id = v.id + LEFT JOIN ( + SELECT target_vault_id, sum(amount) as guaranteed, count(id) as donors + FROM api.donations d + WHERE status = 'guaranteed' + GROUP BY target_vault_id + ) as g + ON g.target_vault_id = v.id LEFT JOIN ( SELECT source_vault_id, sum(amount) as "withdrawnAmount" FROM api.withdrawals w @@ -524,6 +532,7 @@ export class CampaignService { id: true, type: true, status: true, + affiliateId: true, provider: true, createdAt: true, updatedAt: true, @@ -556,7 +565,6 @@ export class CampaignService { campaign: Campaign, paymentData: PaymentData, newDonationStatus: DonationStatus, - metadata?: DonationMetadata, ): Promise { const campaignId = campaign.id Logger.debug('Update donation to status: ' + newDonationStatus, { @@ -593,22 +601,6 @@ export class CampaignService { donationId = updatedDonation?.id } - //For successful donations we will also need to link them to user if not marked as anonymous - if (donationId && newDonationStatus === DonationStatus.succeeded) { - if (metadata?.isAnonymous !== 'true') { - await tx.donation.update({ - where: { id: donationId }, - data: { - person: { - connect: { - email: paymentData.billingEmail, - }, - }, - }, - }) - } - } - return donationId }) //end of the transaction scope } @@ -706,7 +698,7 @@ export class CampaignService { currency: campaign.currency, targetVault: targetVaultData, provider: paymentData.paymentProvider, - type: DonationType.donation, + type: paymentData.type as DonationType, status: newDonationStatus, extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, @@ -1114,6 +1106,7 @@ export class CampaignService { currentAmount: csum?.currentAmount || 0, blockedAmount: csum?.blockedAmount || 0, withdrawnAmount: csum?.withdrawnAmount || 0, + guaranteedAmount: csum?.guaranteedAmount || 0, donors: csum?.donors || 0, } } diff --git a/apps/api/src/campaign/dto/campaign-summary.dto.ts b/apps/api/src/campaign/dto/campaign-summary.dto.ts index 63af3e73f..1950a9743 100644 --- a/apps/api/src/campaign/dto/campaign-summary.dto.ts +++ b/apps/api/src/campaign/dto/campaign-summary.dto.ts @@ -9,6 +9,9 @@ export class CampaignSummaryDto { @ApiProperty() reachedAmount: number + @ApiProperty() + guaranteedAmount: number + @ApiProperty() currentAmount: number diff --git a/apps/api/src/campaign/dto/create-campaign.dto.ts b/apps/api/src/campaign/dto/create-campaign.dto.ts index 086fe4427..412da17bf 100644 --- a/apps/api/src/campaign/dto/create-campaign.dto.ts +++ b/apps/api/src/campaign/dto/create-campaign.dto.ts @@ -1,14 +1,4 @@ -import { - IsDate, - IsEnum, - IsNumber, - IsOptional, - IsPositive, - IsString, - IsUUID, - MaxLength, - MinLength, -} from 'class-validator' +import { IsDate, IsEnum, IsNumber, IsOptional, IsPositive, IsString, IsUUID } from 'class-validator' import { ApiProperty } from '@nestjs/swagger' import { Expose, Transform, Type } from 'class-transformer' import { CampaignState, Currency, Prisma } from '@prisma/client' diff --git a/apps/api/src/company/company.module.ts b/apps/api/src/company/company.module.ts index 1c466e2d9..a64abcf44 100644 --- a/apps/api/src/company/company.module.ts +++ b/apps/api/src/company/company.module.ts @@ -6,5 +6,6 @@ import { PrismaService } from '../prisma/prisma.service' @Module({ controllers: [CompanyController], providers: [CompanyService, PrismaService], + exports: [CompanyService], }) export class CompanyModule {} diff --git a/apps/api/src/company/company.service.ts b/apps/api/src/company/company.service.ts index d665b8ea8..b35759756 100644 --- a/apps/api/src/company/company.service.ts +++ b/apps/api/src/company/company.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common' +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' import { Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' @@ -42,6 +42,11 @@ export class CompanyService { return company } + async findOneByEIK(companyNumber: string | undefined) { + if (!companyNumber) throw new BadRequestException('Company number not provided') + return await this.prisma.company.findUnique({ where: { companyNumber } }) + } + async update(id: string, updateCompanyDto: UpdateCompanyDto) { try { const company = await this.prisma.company.update({ diff --git a/apps/api/src/config/validation.config.ts b/apps/api/src/config/validation.config.ts index 7928c67cc..6819acc64 100644 --- a/apps/api/src/config/validation.config.ts +++ b/apps/api/src/config/validation.config.ts @@ -9,6 +9,7 @@ const globalValidationPipe = new ValidationPipe({ transformOptions: { strategy: 'exposeAll', excludeExtraneousValues: true, + exposeDefaultValues: true, }, stopAtFirstError: false, forbidUnknownValues: true, diff --git a/apps/api/src/domain/generated/affiliate/dto/connect-affiliate.dto.ts b/apps/api/src/domain/generated/affiliate/dto/connect-affiliate.dto.ts new file mode 100644 index 000000000..aca06cc2c --- /dev/null +++ b/apps/api/src/domain/generated/affiliate/dto/connect-affiliate.dto.ts @@ -0,0 +1,5 @@ +export class ConnectAffiliateDto { + id?: string + affiliateCode?: string + companyId?: string +} diff --git a/apps/api/src/domain/generated/affiliate/dto/create-affiliate.dto.ts b/apps/api/src/domain/generated/affiliate/dto/create-affiliate.dto.ts new file mode 100644 index 000000000..d76e0c938 --- /dev/null +++ b/apps/api/src/domain/generated/affiliate/dto/create-affiliate.dto.ts @@ -0,0 +1,3 @@ +export class CreateAffiliateDto { + affiliateCode?: string +} diff --git a/apps/api/src/domain/generated/affiliate/dto/index.ts b/apps/api/src/domain/generated/affiliate/dto/index.ts new file mode 100644 index 000000000..d3aea23c7 --- /dev/null +++ b/apps/api/src/domain/generated/affiliate/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-affiliate.dto' +export * from './create-affiliate.dto' +export * from './update-affiliate.dto' diff --git a/apps/api/src/domain/generated/affiliate/dto/update-affiliate.dto.ts b/apps/api/src/domain/generated/affiliate/dto/update-affiliate.dto.ts new file mode 100644 index 000000000..51767a785 --- /dev/null +++ b/apps/api/src/domain/generated/affiliate/dto/update-affiliate.dto.ts @@ -0,0 +1,3 @@ +export class UpdateAffiliateDto { + affiliateCode?: string +} diff --git a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts new file mode 100644 index 000000000..3672e5d40 --- /dev/null +++ b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts @@ -0,0 +1,14 @@ +import { AffiliateStatus } from '@prisma/client' +import { Company } from '../../company/entities/company.entity' +import { Donation } from '../../donation/entities/donation.entity' + +export class Affiliate { + id: string + status: AffiliateStatus + affiliateCode: string | null + companyId: string + createdAt: Date + updatedAt: Date | null + company?: Company + donations?: Donation[] +} diff --git a/apps/api/src/domain/generated/affiliate/entities/index.ts b/apps/api/src/domain/generated/affiliate/entities/index.ts new file mode 100644 index 000000000..e07a7eb43 --- /dev/null +++ b/apps/api/src/domain/generated/affiliate/entities/index.ts @@ -0,0 +1 @@ +export * from './affiliate.entity' diff --git a/apps/api/src/domain/generated/company/dto/connect-company.dto.ts b/apps/api/src/domain/generated/company/dto/connect-company.dto.ts index 6a9934cb6..5731c5a4c 100644 --- a/apps/api/src/domain/generated/company/dto/connect-company.dto.ts +++ b/apps/api/src/domain/generated/company/dto/connect-company.dto.ts @@ -1,4 +1,5 @@ export class ConnectCompanyDto { id?: string companyNumber?: string + personId?: string } diff --git a/apps/api/src/domain/generated/company/dto/create-company.dto.ts b/apps/api/src/domain/generated/company/dto/create-company.dto.ts index b07f75937..983a813b2 100644 --- a/apps/api/src/domain/generated/company/dto/create-company.dto.ts +++ b/apps/api/src/domain/generated/company/dto/create-company.dto.ts @@ -4,4 +4,5 @@ export class CreateCompanyDto { legalPersonName?: string countryCode?: string cityId?: string + personId?: string } diff --git a/apps/api/src/domain/generated/company/dto/update-company.dto.ts b/apps/api/src/domain/generated/company/dto/update-company.dto.ts index d392ddae9..adf9c0db7 100644 --- a/apps/api/src/domain/generated/company/dto/update-company.dto.ts +++ b/apps/api/src/domain/generated/company/dto/update-company.dto.ts @@ -4,4 +4,5 @@ export class UpdateCompanyDto { legalPersonName?: string countryCode?: string cityId?: string + personId?: string } diff --git a/apps/api/src/domain/generated/company/entities/company.entity.ts b/apps/api/src/domain/generated/company/entities/company.entity.ts index d51860fdc..d577baea1 100644 --- a/apps/api/src/domain/generated/company/entities/company.entity.ts +++ b/apps/api/src/domain/generated/company/entities/company.entity.ts @@ -1,5 +1,7 @@ import { Beneficiary } from '../../beneficiary/entities/beneficiary.entity' import { Campaign } from '../../campaign/entities/campaign.entity' +import { Person } from '../../person/entities/person.entity' +import { Affiliate } from '../../affiliate/entities/affiliate.entity' export class Company { id: string @@ -8,8 +10,11 @@ export class Company { legalPersonName: string | null countryCode: string | null cityId: string | null + personId: string | null createdAt: Date updatedAt: Date | null beneficiaries?: Beneficiary[] Campaign?: Campaign[] + person?: Person | null + affiliate?: Affiliate | null } diff --git a/apps/api/src/domain/generated/donation/entities/donation.entity.ts b/apps/api/src/domain/generated/donation/entities/donation.entity.ts index d5c61d33e..57fc0ce8b 100644 --- a/apps/api/src/domain/generated/donation/entities/donation.entity.ts +++ b/apps/api/src/domain/generated/donation/entities/donation.entity.ts @@ -1,7 +1,9 @@ import { DonationType, DonationStatus, PaymentProvider, Currency } from '@prisma/client' import { Person } from '../../person/entities/person.entity' import { Vault } from '../../vault/entities/vault.entity' +import { Affiliate } from '../../affiliate/entities/affiliate.entity' import { DonationWish } from '../../donationWish/entities/donationWish.entity' +import { DonationMetadata } from '../../donationMetadata/entities/donationMetadata.entity' export class Donation { id: string @@ -16,11 +18,14 @@ export class Donation { updatedAt: Date | null amount: number currency: Currency + affiliateId: string | null personId: string | null billingEmail: string | null billingName: string | null chargedAmount: number person?: Person | null targetVault?: Vault + affiliate?: Affiliate | null DonationWish?: DonationWish | null + metadata?: DonationMetadata | null } diff --git a/apps/api/src/domain/generated/donationMetadata/dto/connect-donationMetadata.dto.ts b/apps/api/src/domain/generated/donationMetadata/dto/connect-donationMetadata.dto.ts new file mode 100644 index 000000000..9875d62d7 --- /dev/null +++ b/apps/api/src/domain/generated/donationMetadata/dto/connect-donationMetadata.dto.ts @@ -0,0 +1,3 @@ +export class ConnectDonationMetadataDto { + donationId: string +} diff --git a/apps/api/src/domain/generated/donationMetadata/dto/create-donationMetadata.dto.ts b/apps/api/src/domain/generated/donationMetadata/dto/create-donationMetadata.dto.ts new file mode 100644 index 000000000..fc3917bb7 --- /dev/null +++ b/apps/api/src/domain/generated/donationMetadata/dto/create-donationMetadata.dto.ts @@ -0,0 +1,6 @@ +import { Prisma } from '@prisma/client' + +export class CreateDonationMetadataDto { + name?: string + extraData?: Prisma.InputJsonValue +} diff --git a/apps/api/src/domain/generated/donationMetadata/dto/index.ts b/apps/api/src/domain/generated/donationMetadata/dto/index.ts new file mode 100644 index 000000000..f313f5d13 --- /dev/null +++ b/apps/api/src/domain/generated/donationMetadata/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-donationMetadata.dto' +export * from './create-donationMetadata.dto' +export * from './update-donationMetadata.dto' diff --git a/apps/api/src/domain/generated/donationMetadata/dto/update-donationMetadata.dto.ts b/apps/api/src/domain/generated/donationMetadata/dto/update-donationMetadata.dto.ts new file mode 100644 index 000000000..e2382d4b5 --- /dev/null +++ b/apps/api/src/domain/generated/donationMetadata/dto/update-donationMetadata.dto.ts @@ -0,0 +1,6 @@ +import { Prisma } from '@prisma/client' + +export class UpdateDonationMetadataDto { + name?: string + extraData?: Prisma.InputJsonValue +} diff --git a/apps/api/src/domain/generated/donationMetadata/entities/donationMetadata.entity.ts b/apps/api/src/domain/generated/donationMetadata/entities/donationMetadata.entity.ts new file mode 100644 index 000000000..59e23cdb4 --- /dev/null +++ b/apps/api/src/domain/generated/donationMetadata/entities/donationMetadata.entity.ts @@ -0,0 +1,10 @@ +import { Prisma } from '@prisma/client' +import { Donation } from '../../donation/entities/donation.entity' + +export class DonationMetadata { + donationId: string + name: string | null + createdAt: Date + extraData: Prisma.JsonValue | null + donation?: Donation +} diff --git a/apps/api/src/domain/generated/donationMetadata/entities/index.ts b/apps/api/src/domain/generated/donationMetadata/entities/index.ts new file mode 100644 index 000000000..c995d374a --- /dev/null +++ b/apps/api/src/domain/generated/donationMetadata/entities/index.ts @@ -0,0 +1 @@ +export * from './donationMetadata.entity' diff --git a/apps/api/src/domain/generated/person/dto/connect-person.dto.ts b/apps/api/src/domain/generated/person/dto/connect-person.dto.ts index d55e53344..cf8af9224 100644 --- a/apps/api/src/domain/generated/person/dto/connect-person.dto.ts +++ b/apps/api/src/domain/generated/person/dto/connect-person.dto.ts @@ -2,6 +2,7 @@ export class ConnectPersonDto { id?: string email?: string personalNumber?: string + companyId?: string keycloakId?: string stripeCustomerId?: string } diff --git a/apps/api/src/domain/generated/person/dto/create-person.dto.ts b/apps/api/src/domain/generated/person/dto/create-person.dto.ts index a00ddc345..d96ba291e 100644 --- a/apps/api/src/domain/generated/person/dto/create-person.dto.ts +++ b/apps/api/src/domain/generated/person/dto/create-person.dto.ts @@ -3,7 +3,6 @@ export class CreatePersonDto { lastName: string email?: string phone?: string - company?: string newsletter?: boolean address?: string birthday?: Date diff --git a/apps/api/src/domain/generated/person/dto/update-person.dto.ts b/apps/api/src/domain/generated/person/dto/update-person.dto.ts index 8ab942283..b171e7db8 100644 --- a/apps/api/src/domain/generated/person/dto/update-person.dto.ts +++ b/apps/api/src/domain/generated/person/dto/update-person.dto.ts @@ -3,7 +3,6 @@ export class UpdatePersonDto { lastName?: string email?: string phone?: string - company?: string newsletter?: boolean address?: string birthday?: Date diff --git a/apps/api/src/domain/generated/person/entities/person.entity.ts b/apps/api/src/domain/generated/person/entities/person.entity.ts index 70484e54e..fa0e3872a 100644 --- a/apps/api/src/domain/generated/person/entities/person.entity.ts +++ b/apps/api/src/domain/generated/person/entities/person.entity.ts @@ -18,6 +18,7 @@ import { Transfer } from '../../transfer/entities/transfer.entity' import { Withdrawal } from '../../withdrawal/entities/withdrawal.entity' import { CampaignNews } from '../../campaignNews/entities/campaignNews.entity' import { CampaignNewsFile } from '../../campaignNewsFile/entities/campaignNewsFile.entity' +import { Company } from '../../company/entities/company.entity' export class Person { id: string @@ -25,7 +26,6 @@ export class Person { lastName: string email: string | null phone: string | null - company: string | null createdAt: Date updatedAt: Date | null newsletter: boolean | null @@ -33,9 +33,11 @@ export class Person { birthday: Date | null emailConfirmed: boolean | null personalNumber: string | null + companyId: string | null keycloakId: string | null stripeCustomerId: string | null picture: string | null + profileEnabled: boolean benefactors?: Benefactor[] beneficiaries?: Beneficiary[] campaignFiles?: CampaignFile[] @@ -56,4 +58,5 @@ export class Person { withdrawals?: Withdrawal[] publishedNews?: CampaignNews[] newsFiles?: CampaignNewsFile[] + company?: Company | null } diff --git a/apps/api/src/donation-wish/donation-wish.service.ts b/apps/api/src/donation-wish/donation-wish.service.ts index ab0c357fb..f9eb148ff 100644 --- a/apps/api/src/donation-wish/donation-wish.service.ts +++ b/apps/api/src/donation-wish/donation-wish.service.ts @@ -65,8 +65,22 @@ export class DonationWishService { : { createdAt: 'desc' }, ], include: { - person: { select: { id: true, firstName: true, lastName: true } }, - donation: { select: { amount: true, currency: true } }, + person: { + select: { + id: true, + firstName: true, + lastName: true, + company: { select: { companyName: true } }, + }, + }, + donation: { + select: { + amount: true, + currency: true, + type: true, + metadata: { select: { name: true } }, + }, + }, }, }) diff --git a/apps/api/src/donations/donations.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts index 12c3dcdff..1ff530713 100644 --- a/apps/api/src/donations/donations.controller.spec.ts +++ b/apps/api/src/donations/donations.controller.spec.ts @@ -9,6 +9,7 @@ import { DonationStatus, DonationType, PaymentProvider, + Person, Vault, } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' @@ -55,6 +56,7 @@ describe('DonationsController', () => { type: DonationType.donation, status: DonationStatus.succeeded, amount: 10, + affiliateId: null, extCustomerId: 'gosho', extPaymentIntentId: 'pm1', extPaymentMethodId: 'bank', @@ -181,13 +183,13 @@ describe('DonationsController', () => { } const existingDonation = { ...mockDonation } - const existingTargetPerson = { + const existingTargetPerson: Person = { id: '2', firstName: 'string', lastName: 'string', email: 'string', phone: 'string', - company: 'string', + companyId: 'string', createdAt: new Date('2022-01-01'), updatedAt: new Date('2022-01-01'), newsletter: false, @@ -198,6 +200,7 @@ describe('DonationsController', () => { keycloakId: '00000000-0000-0000-0000-000000000012', stripeCustomerId: 'string', picture: 'string', + profileEnabled: true, } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) @@ -232,13 +235,13 @@ describe('DonationsController', () => { billingEmail: mockDonation.billingEmail, } - const existingTargetPerson = { + const existingTargetPerson: Person = { id: mockDonation.personId, firstName: 'string', lastName: 'string', email: mockDonation.billingEmail, phone: 'string', - company: 'string', + companyId: 'string', createdAt: new Date('2022-01-01'), updatedAt: new Date('2022-01-01'), newsletter: false, @@ -249,6 +252,7 @@ describe('DonationsController', () => { keycloakId: '00000000-0000-0000-0000-000000000012', stripeCustomerId: 'string', picture: 'string', + profileEnabled: true, } const existingDonation = { ...mockDonation, status: DonationStatus.initial } diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index fed254b41..90e004cee 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -177,7 +177,14 @@ export class DonationsController { @Get('user/:id') async userDonationById(@Param('id') id: string, @AuthenticatedUser() user: KeycloakTokenParsed) { - return await this.donationsService.getUserDonationById(id, user.sub, user.email) + const donation = await this.donationsService.getUserDonationById(id, user.sub, user.email) + return { + ...donation, + person: { + firstName: user.given_name, + lastName: user.family_name, + }, + } } @Post('payment-intent') diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index ff06cafe1..2b6795aad 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -27,6 +27,7 @@ import { donationWithPerson, DonationWithPerson } from './queries/donation.valid import { CreateStripePaymentDto } from './dto/create-stripe-payment.dto' import { ImportStatus } from '../bank-transactions-file/dto/bank-transactions-import-status.dto' import { DonationQueryDto } from '../common/dto/donation-query-dto' +import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-donation.dto' @Injectable() export class DonationsService { @@ -162,11 +163,13 @@ export class DonationsService { const campaign = await this.campaignService.validateCampaignId(sessionDto.campaignId) const { mode } = sessionDto const appUrl = this.config.get('APP_URL') + const metadata: DonationMetadata = { campaignId: sessionDto.campaignId, personId: sessionDto.personId, isAnonymous: sessionDto.isAnonymous ? 'true' : 'false', wish: sessionDto.message ?? null, + type: sessionDto.type, } const items = await this.prepareSessionItems(sessionDto, campaign) @@ -270,28 +273,38 @@ export class DonationsService { pageIndex?: number, pageSize?: number, ): Promise> { - const data = await this.prisma.donation.findMany({ - where: { status, targetVault: { campaign: { id: campaignId } } }, - orderBy: [{ updatedAt: 'desc' }], - select: { - id: true, - type: true, - status: true, - provider: true, - createdAt: true, - updatedAt: true, - amount: true, - chargedAmount: true, - currency: true, - person: { select: { firstName: true, lastName: true } }, - }, - skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, - take: pageSize ? pageSize : undefined, - }) - - const count = await this.prisma.donation.count({ - where: { status, targetVault: { campaign: { id: campaignId } } }, - }) + const [data, count] = await this.prisma.$transaction([ + this.prisma.donation.findMany({ + where: { + OR: [{ status: status }, { status: DonationStatus.guaranteed }], + targetVault: { campaign: { id: campaignId } }, + }, + orderBy: [{ updatedAt: 'desc' }], + select: { + id: true, + type: true, + status: true, + provider: true, + createdAt: true, + updatedAt: true, + amount: true, + chargedAmount: true, + currency: true, + person: { + select: { firstName: true, lastName: true, company: { select: { companyName: true } } }, + }, + metadata: { select: { name: true } }, + }, + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, + take: pageSize ? pageSize : undefined, + }), + this.prisma.donation.count({ + where: { + OR: [{ status: status }, { status: DonationStatus.guaranteed }], + targetVault: { campaign: { id: campaignId } }, + }, + }), + ]) const result = { items: data, @@ -301,6 +314,35 @@ export class DonationsService { return result } + async createAffiliateDonation(donationDto: CreateAffiliateDonationDto) { + const vault = await this.vaultService.findByCampaignId(donationDto.campaignId) + + if (!vault || vault.length === 0) throw new NotFoundException('Campaign or vault not found') + + const donation = await this.prisma.donation.create({ + data: donationDto.toEntity(vault[0].id), + }) + if (donationDto.metadata) { + await this.prisma.donationMetadata.create({ + data: { + donationId: donation.id, + ...donationDto.metadata, + }, + }) + } + if (donationDto.message) { + await this.prisma.donationWish.create({ + data: { + campaignId: donationDto.campaignId, + message: donationDto.message, + donationId: donation.id, + personId: donationDto.personId, + }, + }) + } + return donation + } + /** * Lists all donations with all fields only for admin roles * @param campaignId (Optional) Filter by campaign id @@ -397,6 +439,18 @@ export class DonationsService { } } + async getAffiliateDonationById(donationId: string, affiliateCode: string) { + try { + const donation = await this.prisma.donation.findFirstOrThrow({ + where: { id: donationId, affiliate: { affiliateCode: affiliateCode } }, + }) + return donation + } catch (err) { + const msg = 'No Donation record with ID: ' + donationId + Logger.warn(msg) + throw new NotFoundException(msg) + } + } /** * Get donation by id with person data attached * @param id Donation id @@ -420,8 +474,12 @@ export class DonationsService { id: true, firstName: true, lastName: true, + company: { select: { companyName: true } }, }, }, + affiliate: { + select: { company: { select: { companyName: true } } }, + }, targetVault: { select: { id: true, @@ -542,6 +600,30 @@ export class DonationsService { }) } + async updateAffiliateBankPayment(donationDto: Donation) { + return await this.prisma.$transaction(async (tx) => { + await Promise.all([ + this.vaultService.incrementVaultAmount(donationDto.targetVaultId, donationDto.amount, tx), + tx.donation.update({ + where: { id: donationDto.id }, + data: { status: DonationStatus.succeeded, updatedAt: donationDto.updatedAt }, + }), + ]) + }) + } + + async updateAffiliateDonations(donationId: string, affiliateId: string, status: DonationStatus) { + const donation = await this.prisma.donation.update({ + where: { + id: donationId, + affiliateId: affiliateId, + }, + data: { + status, + }, + }) + return donation + } /** * Updates the donation's status or donor. Note: completed donations cannot have status updates. * @param id diff --git a/apps/api/src/donations/dontation-metadata.interface.ts b/apps/api/src/donations/dontation-metadata.interface.ts index feb79827c..c979a561f 100644 --- a/apps/api/src/donations/dontation-metadata.interface.ts +++ b/apps/api/src/donations/dontation-metadata.interface.ts @@ -1,6 +1,8 @@ +import { DonationType } from '@prisma/client' import Stripe from 'stripe' export interface DonationMetadata extends Stripe.MetadataParam { + type: DonationType | null campaignId: string | null personId: string | null isAnonymous: string | null diff --git a/apps/api/src/donations/dto/create-session.dto.ts b/apps/api/src/donations/dto/create-session.dto.ts index b780bcc21..ccd281de5 100644 --- a/apps/api/src/donations/dto/create-session.dto.ts +++ b/apps/api/src/donations/dto/create-session.dto.ts @@ -4,6 +4,7 @@ import { ApiProperty } from '@nestjs/swagger' import { IsBoolean, IsEmail, + IsEnum, IsIn, IsNotEmpty, IsNumber, @@ -15,6 +16,7 @@ import { Min, ValidateIf, } from 'class-validator' +import { DonationType } from '@prisma/client' export class CreateSessionDto { @ApiProperty() @@ -23,6 +25,11 @@ export class CreateSessionDto { @IsIn(['payment', 'setup', 'subscription']) public readonly mode: Stripe.Checkout.Session.Mode + @ApiProperty() + @Expose() + @IsEnum(DonationType) + public readonly type: DonationType + @ApiProperty() @Expose() @IsNumber() diff --git a/apps/api/src/donations/dto/donation-metadata.dto.ts b/apps/api/src/donations/dto/donation-metadata.dto.ts new file mode 100644 index 000000000..b05d5f635 --- /dev/null +++ b/apps/api/src/donations/dto/donation-metadata.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { IsObject, IsOptional, IsString } from 'class-validator' + +type TExtraData = { + [key: string]: string | null | number | boolean +} + +export class DonationMetadataDto { + @ApiProperty() + @Expose() + @IsString() + @IsOptional() + name: string | undefined + + @ApiProperty() + @Expose() + @IsObject() + @IsOptional() + extraData: TExtraData | 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 32db9513f..21437da3d 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -276,6 +276,7 @@ describe('StripePaymentService', () => { type: DonationType.donation, status: DonationStatus.waiting, provider: 'stripe', + affiliateId: null, extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: 'card', @@ -331,7 +332,7 @@ describe('StripePaymentService', () => { expect(prismaMock.donation.findUnique).toHaveBeenCalled() expect(prismaMock.donation.create).not.toHaveBeenCalled() expect(mockedIncrementVaultAmount).toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalledTimes(2) //once for the amount and second time for assigning donation to the person + expect(prismaMock.donation.update).toHaveBeenCalledTimes(1) expect(mockedUpdateCampaignStatusIfTargetReached).toHaveBeenCalled() expect(prismaMock.campaign.update).toHaveBeenCalledWith({ where: { @@ -377,6 +378,7 @@ describe('StripePaymentService', () => { type: DonationType.donation, status: DonationStatus.waiting, provider: 'stripe', + affiliateId: '', extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: 'card', @@ -449,6 +451,7 @@ describe('StripePaymentService', () => { type: DonationType.donation, status: DonationStatus.succeeded, provider: 'stripe', + affiliateId: null, extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: 'card', diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index be89934c7..3d7d13160 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -140,7 +140,6 @@ export class StripePaymentService { campaign, billingData, DonationStatus.succeeded, - metadata, ) //updateDonationPayment will mark the campaign as completed if amount is reached @@ -172,12 +171,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment( - campaign, - billingData, - DonationStatus.refund, - metadata, - ) + await this.campaignService.updateDonationPayment(campaign, billingData, DonationStatus.refund) if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } @@ -338,6 +332,7 @@ export class StripePaymentService { Logger.log('[ handleInvoicePaid ]', invoice) let metadata: DonationMetadata = { + type: null, campaignId: null, personId: null, isAnonymous: null, diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 5c17b8565..c757e89ec 100644 --- a/apps/api/src/donations/helpers/donation-status-updates.ts +++ b/apps/api/src/donations/helpers/donation-status-updates.ts @@ -5,6 +5,7 @@ const changeable: DonationStatus[] = [ DonationStatus.incomplete, DonationStatus.paymentRequested, DonationStatus.waiting, + DonationStatus.guaranteed, ] const final: DonationStatus[] = [ DonationStatus.succeeded, diff --git a/apps/api/src/donations/helpers/payment-intent-helpers.ts b/apps/api/src/donations/helpers/payment-intent-helpers.ts index ae4518d0a..052b50f84 100644 --- a/apps/api/src/donations/helpers/payment-intent-helpers.ts +++ b/apps/api/src/donations/helpers/payment-intent-helpers.ts @@ -28,12 +28,15 @@ export type PaymentData = { stripeCustomerId?: string paymentProvider: PaymentProvider personId?: string + type: string } export function getPaymentData( paymentIntent: Stripe.PaymentIntent, charge?: Stripe.Charge, ): PaymentData { + const isAnonymous = paymentIntent.metadata.isAnonymous === 'true' + return { paymentProvider: PaymentProvider.stripe, paymentIntentId: paymentIntent.id, @@ -53,10 +56,13 @@ export function getPaymentData( billingEmail: charge?.billing_details?.email ?? paymentIntent.receipt_email ?? undefined, paymentMethodId: getPaymentMethodId(paymentIntent), stripeCustomerId: getPaymentCustomerId(paymentIntent), + type: paymentIntent.metadata.type, + personId: !isAnonymous ? paymentIntent.metadata.personId : undefined, } } export function getPaymentDataFromCharge(charge: Stripe.Charge): PaymentData { + const isAnonymous = charge.metadata.isAnonymous === 'true' return { paymentProvider: PaymentProvider.stripe, paymentIntentId: charge.payment_intent as string, @@ -74,6 +80,8 @@ export function getPaymentDataFromCharge(charge: Stripe.Charge): PaymentData { billingEmail: charge?.billing_details?.email ?? charge.receipt_email ?? undefined, paymentMethodId: 'card', stripeCustomerId: charge.billing_details?.email ?? undefined, + type: charge.metadata.type, + personId: !isAnonymous ? charge.metadata.personId : undefined, } } @@ -82,10 +90,14 @@ export function getInvoiceData(invoice: Stripe.Invoice): PaymentData { const country = invoice.account_country as string let personId = '' + let type = '' lines.map((line) => { if (line.metadata.personId) { personId = line.metadata.personId } + if (line.metadata.type) { + type = line.metadata.type + } }) return { @@ -106,6 +118,7 @@ export function getInvoiceData(invoice: Stripe.Invoice): PaymentData { paymentMethodId: invoice.collection_method, stripeCustomerId: invoice.customer as string, personId, + type, } } diff --git a/apps/api/src/donations/queries/donation.validator.ts b/apps/api/src/donations/queries/donation.validator.ts index 3203aa37b..ac3f6b0be 100644 --- a/apps/api/src/donations/queries/donation.validator.ts +++ b/apps/api/src/donations/queries/donation.validator.ts @@ -14,11 +14,12 @@ export const donationWithPerson = Prisma.validator( name: true, campaign: { select: { - id: true - } - } + id: true, + }, + }, }, }, + metadata: true, }, }) diff --git a/apps/api/src/paypal/paypal.service.ts b/apps/api/src/paypal/paypal.service.ts index 4b087b990..3eb13182a 100644 --- a/apps/api/src/paypal/paypal.service.ts +++ b/apps/api/src/paypal/paypal.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { CampaignService } from '../campaign/campaign.service' import { HttpService } from '@nestjs/axios' -import { DonationStatus, PaymentProvider } from '@prisma/client' +import { DonationStatus, DonationType, PaymentProvider } from '@prisma/client' @Injectable() export class PaypalService { @@ -165,6 +165,8 @@ export class PaypalService { parsePaypalPaymentOrder(paypalOrder): PaymentData { //note we store the money in db as cents so we multiply incoming amounts by 100 return { + //TODO: Find a way to attach type to metadata + type: DonationType.donation, paymentProvider: PaymentProvider.paypal, campaignId: paypalOrder.resource.purchase_units[0].custom_id, paymentIntentId: paypalOrder.resource.purchase_units[0].payments.captures[0].id, @@ -194,6 +196,8 @@ export class PaypalService { return { paymentProvider: PaymentProvider.paypal, campaignId: paypalCapture.resource.custom_id, + //TODO: Find a way to attach type to metadata + type: DonationType.donation, paymentIntentId: paypalCapture.resource.id, netAmount: 100 * Number(paypalCapture.resource.seller_receivable_breakdown.net_amount.value), chargedAmount: @@ -214,4 +218,5 @@ type PaymentData = { billingEmail?: string paymentMethodId?: string stripeCustomerId?: string + type: DonationType } diff --git a/apps/api/src/person/dto/create-person.dto.ts b/apps/api/src/person/dto/create-person.dto.ts index 64b3b25ac..267d78a81 100644 --- a/apps/api/src/person/dto/create-person.dto.ts +++ b/apps/api/src/person/dto/create-person.dto.ts @@ -31,12 +31,6 @@ export class CreatePersonDto { @IsOptional() phone?: string - @ApiProperty() - @Expose() - @IsString() - @IsOptional() - company?: string - @ApiProperty() @Expose() @IsBoolean() diff --git a/apps/api/src/person/dto/update-person.dto.ts b/apps/api/src/person/dto/update-person.dto.ts index b2b10ec4a..8ce34cc80 100644 --- a/apps/api/src/person/dto/update-person.dto.ts +++ b/apps/api/src/person/dto/update-person.dto.ts @@ -27,11 +27,6 @@ export class UpdatePersonDto { @IsOptional() phone?: string - @ApiProperty() - @Expose() - @IsOptional() - company?: string - @ApiProperty() @Expose() @IsBoolean() @@ -56,4 +51,10 @@ export class UpdatePersonDto { @IsOptional() @IsString() personalNumber?: string + + @ApiProperty() + @Expose() + @IsOptional() + @IsBoolean() + profileEnabled?: boolean } diff --git a/apps/api/src/person/person.service.ts b/apps/api/src/person/person.service.ts index a7d4ae702..da9f18fdb 100644 --- a/apps/api/src/person/person.service.ts +++ b/apps/api/src/person/person.service.ts @@ -62,6 +62,9 @@ export class PersonService { case 'beneficiaries': sort = { beneficiaries: { _count: sortOrder == 'asc' ? 'desc' : 'asc' } } break + case 'type': + sort = { company: { createdAt: sortOrder == 'asc' ? 'desc' : 'asc' } } + break default: sort = { [sortBy]: sortOrder ?? 'desc' } } @@ -115,7 +118,7 @@ export class PersonService { } async findOneByKeycloakId(keycloakId: string) { - return await this.prisma.person.findFirst({ where: { keycloakId } }) + return await this.prisma.person.findFirst({ where: { keycloakId }, include: { company: true } }) } async update(id: string, updatePersonDto: UpdatePersonDto) { diff --git a/apps/api/src/support/dto/create-inquiry.dto.ts b/apps/api/src/support/dto/create-inquiry.dto.ts index 19ec5a748..8bc1c304b 100644 --- a/apps/api/src/support/dto/create-inquiry.dto.ts +++ b/apps/api/src/support/dto/create-inquiry.dto.ts @@ -10,7 +10,6 @@ export class CreateInquiryDto extends PickType(CreatePersonDto, [ 'lastName', 'email', 'phone', - 'company', 'newsletter', ]) { @ApiProperty() @@ -28,7 +27,6 @@ export class CreateInquiryDto extends PickType(CreatePersonDto, [ lastName: this.lastName, email: this.email, phone: this.phone, - company: this.company, }, where: { email: this.email }, }, 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 ebdff5032..92ba78380 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 @@ -14,7 +14,14 @@ 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 { BankDonationStatus, BankTransaction, Campaign, Vault } from '@prisma/client' +import { + BankDonationStatus, + BankTransaction, + Campaign, + DonationStatus, + Prisma, + Vault, +} from '@prisma/client' import { toMoney } from '../../common/money' import { DateTime } from 'luxon' import { EmailService } from '../../email/email.service' @@ -24,6 +31,9 @@ import { SendGridNotificationsProvider } from '../../notifications/providers/not import { MarketingNotificationsService } from '../../notifications/notifications.service' const IBAN = 'BG77UNCR92900016740920' +type AffiliateWithPayload = Prisma.AffiliateGetPayload<{ + include: { donations: true } +}> class MockIrisTasks extends IrisTasks { protected IBAN = IBAN @@ -53,6 +63,34 @@ describe('ImportTransactionsTask', () => { }, ] as (Campaign & { vaults: Vault[] })[] + const affiliateMock: AffiliateWithPayload = { + id: '1234567', + companyId: '1234572', + affiliateCode: 'af_12345', + status: 'active', + donations: [ + { + id: 'donation-id', + type: 'donation', + status: 'guaranteed', + amount: 5000, + affiliateId: 'affiliate-id', + personId: null, + extCustomerId: '', + extPaymentIntentId: '123456', + extPaymentMethodId: '1234', + billingEmail: 'test@podkrepi.bg', + billingName: 'John doe', + targetVaultId: 'vault-id', + chargedAmount: 0, + currency: 'BGN', + createdAt: new Date(), + updatedAt: new Date(), + provider: 'bank', + }, + ], + } + const mockIrisTransactions: IrisTransactionInfo[] = [ // CORRECT BANK DONATION { @@ -138,6 +176,27 @@ describe('ImportTransactionsTask', () => { valueDate: '2023-03-01', creditDebitIndicator: 'DEBIT', }, + //Affiliate donation + { + transactionId: 'Booked_5954782144_70123543493054963FTRO23073A58G01C2023345440_20230914', + bookingDate: '2023-03-14', + creditorAccount: { + iban: 'BG66UNCR70001524349032', + }, + creditorName: 'СДРУЖЕНИЕ ПОДКРЕПИ БГ', + debtorAccount: { + iban: 'BG77UNCR92900016740920', + }, + debtorName: 'JOHN DOE', + remittanceInformationUnstructured: 'af_12345', + transactionAmount: { + amount: 50, + currency: 'BGN', + }, + exchangeRate: null, + valueDate: '2023-03-14', + creditDebitIndicator: 'CREDIT', + }, ] const irisIBANAccountMock: IrisIbanAccountInfo = { @@ -256,13 +315,17 @@ describe('ImportTransactionsTask', () => { IrisTasks.prototype as any, 'sendUnrecognizedDonationsMail', ) - + const processAffiliateDonationSpy = jest.spyOn( + IrisTasks.prototype as any, + 'processAffiliateDonations', + ) // 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.affiliate, 'findMany').mockResolvedValue([affiliateMock]) jest.spyOn(prismaMock.bankTransaction, 'createMany').mockResolvedValue({ count: 2 }) jest.spyOn(prismaMock.bankTransaction, 'updateMany') jest @@ -286,8 +349,8 @@ describe('ImportTransactionsTask', () => { expect(getTrxSpy).toHaveBeenCalledWith(irisIBANAccountMock, transactionsDate) // 3.Should prepare the bank-transaction records expect(prepareBankTrxSpy).toHaveBeenCalledWith(mockIrisTransactions, irisIBANAccountMock) - // 5.Should process transactions and parse donations + expect(processAffiliateDonationSpy).toHaveBeenCalledOnce() expect(processDonationsSpy).toHaveBeenCalledWith( // Outgoing and Stripe payments should have been filtered expect.arrayContaining( @@ -315,6 +378,30 @@ describe('ImportTransactionsTask', () => { include: { vaults: true }, }), ) + expect(prismaMock.affiliate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + affiliateCode: { + in: [ + // VALID Affiliate CODE + mockIrisTransactions[mockIrisTransactions.length - 1] + .remittanceInformationUnstructured, + ], + }, + }, + include: { + donations: { + where: { + status: DonationStatus.guaranteed, + }, + orderBy: { + createdAt: 'asc', + }, + include: { targetVault: true }, + }, + }, + }), + ) // Should be called only once - for the recognized campaign payment ref expect(prepareBankPaymentSpy).toHaveBeenCalledOnce() expect(prepareBankPaymentSpy).toHaveBeenCalledWith( @@ -354,17 +441,15 @@ describe('ImportTransactionsTask', () => { expect(parameters[0].bankDonationStatus).toEqual(BankDonationStatus.imported) // Bank donation 2 expect(parameters[1].bankDonationStatus).toEqual(BankDonationStatus.unrecognized) + // Affiliate donation + expect(parameters[2].bankDonationStatus).toEqual(BankDonationStatus.imported) // STRIPE Payment - expect(parameters[2]).not.toBeDefined() - // OUTGOING Payment expect(parameters[3]).not.toBeDefined() + // OUTGOING Payment + expect(parameters[4]).not.toBeDefined() // Only new transactions should be saved - expect(prismaMock.bankTransaction.createMany).toHaveBeenCalledWith( - expect.objectContaining({ - skipDuplicates: true, - }), - ) + expect(prismaMock.bankTransaction.upsert).toHaveBeenCalled() // 7.Notify for unrecognized bank donations expect(notifyUnrecognizedSpy).toHaveBeenCalledWith( 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 990d06327..27a6bf805 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -31,8 +31,14 @@ import { ExpiringIrisConsentEmailDto, UnrecognizedDonationEmailDto, } from '../../email/template.interface' +import { ImportStatus } from '../../bank-transactions-file/dto/bank-transactions-import-status.dto' +import { IrisIbanAccountInfoDto } from '../../bank-transactions/dto/iris-bank-account-info.dto' +import { IrisTransactionInfoDto } from '../../bank-transactions/dto/iris-bank-transaction-info.dto' type filteredTransaction = Prisma.BankTransactionCreateManyInput +type AffiliatePayload = Prisma.AffiliateGetPayload<{ + include: { donations: true } +}> @Injectable() export class IrisTasks { @@ -48,6 +54,7 @@ export class IrisTasks { private daysToExpCondition = 5 // Used to check if the task should be stopped private canRun = true + private readonly AFFILIATE_CODE_PREFIX = 'af_' constructor( private readonly httpService: HttpService, @@ -121,6 +128,44 @@ export class IrisTasks { } } + async simulateBankTransactionImportTask( + ibanAccount: IrisIbanAccountInfoDto, + transactions: IrisTransactionInfoDto[], + ) { + let bankTrxRecords: filteredTransaction[] + try { + bankTrxRecords = this.prepareBankTransactionRecords(transactions, ibanAccount) + Logger.debug(`Transactions for import after filtering: ${bankTrxRecords.length}`) + } catch (e) { + return Logger.error('Error while preparing BankTransaction records') + } + + // 5. Parse transactions and create the donations + let processedBankTrxRecords: filteredTransaction[] + try { + processedBankTrxRecords = await this.processDonations(bankTrxRecords) + } catch (e) { + return Logger.error('Failed to process transaction donations' + e.message) + } + + // 6. Save BankTransactions to DB + try { + const savedTransactions = await this.saveBankTrxRecords(processedBankTrxRecords) + Logger.debug('Saved transactions count: ' + savedTransactions.length) + } catch (e) { + return Logger.error('Failed to import transactions into DB: ' + e.message) + } + + //7. Notify about unrecognized donations + try { + await this.sendUnrecognizedDonationsMail(processedBankTrxRecords) + } catch (e) { + return Logger.error('Failed to notify about bad transaction donations ' + e.message) + } + + return + } + async importBankTransactionsTASK(transactionsDate: Date) { // De-register the task, so that it doesn't waste server resources if (!this.canRun) { @@ -175,7 +220,7 @@ export class IrisTasks { // 6. Save BankTransactions to DB try { const savedTransactions = await this.saveBankTrxRecords(processedBankTrxRecords) - Logger.debug('Saved transactions count: ' + savedTransactions.count) + Logger.debug('Saved transactions count: ' + savedTransactions.length) } catch (e) { return Logger.error('Failed to import transactions into DB: ' + e.message) } @@ -269,7 +314,7 @@ export class IrisTasks { ibanAccount: IrisIbanAccountInfo, ) { const filteredTransactions: filteredTransaction[] = [] - + let matchedRef: string | null for (const trx of transactions) { // We're interested in parsing only incoming trx's if (trx.creditDebitIndicator !== 'CREDIT') { @@ -281,12 +326,16 @@ export class IrisTasks { continue } + if (trx.remittanceInformationUnstructured.startsWith(this.AFFILIATE_CODE_PREFIX)) { + matchedRef = trx.remittanceInformationUnstructured + } else { + const ref = trx.remittanceInformationUnstructured + ?.trim() + .replace(/[ _]+/g, '-') + .match(this.regexPaymentRef) + matchedRef = ref ? ref[0] : null + } // Try to recognize campaign payment reference - let matchedRef = trx.remittanceInformationUnstructured - ?.trim() - .replace(/[ _]+/g, '-') - .match(this.regexPaymentRef) - const transactionAmount = { amount: trx.transactionAmount?.amount, currency: trx.transactionAmount?.currency, @@ -320,17 +369,40 @@ export class IrisTasks { currency: transactionAmount.currency, description: trx.remittanceInformationUnstructured?.trim(), // Not saved in the DB, it's added only for convinience and efficiency - matchedRef: matchedRef ? matchedRef[0] : null, + matchedRef: matchedRef ? matchedRef : null, }) } - return filteredTransactions } private async processDonations(bankTransactions: filteredTransaction[]) { - const matchedPaymentRef: string[] = [] + const campaignPaymentRef: string[] = [] + const affiliatePaymentRef: string[] = [] bankTransactions.forEach((trx) => { - if (trx.matchedRef) matchedPaymentRef.push(trx.matchedRef) + if (trx.matchedRef) { + trx.matchedRef.startsWith(this.AFFILIATE_CODE_PREFIX) + ? affiliatePaymentRef.push(trx.matchedRef) + : campaignPaymentRef.push(trx.matchedRef) + } + }) + + const affiliates = await this.prisma.affiliate.findMany({ + where: { + affiliateCode: { + in: affiliatePaymentRef, + }, + }, + include: { + donations: { + where: { + status: DonationStatus.guaranteed, + }, + orderBy: { + createdAt: 'asc', + }, + include: { targetVault: true }, + }, + }, }) /* @@ -338,10 +410,11 @@ export class IrisTasks { than execute a separate one for each transaction - more performent and reliable approach */ + const campaigns = await this.prisma.campaign.findMany({ where: { paymentReference: { - in: matchedPaymentRef, + in: campaignPaymentRef, }, }, include: { @@ -359,7 +432,13 @@ export class IrisTasks { const campaign = campaigns.find((cmpgn) => cmpgn.paymentReference === trx.matchedRef) if (!campaign) { - trx.bankDonationStatus = BankDonationStatus.unrecognized + //Campaign not found by paymentReference. Check if it is affiliate donation + const affiliate = affiliates.find((affiliate) => affiliate.affiliateCode === trx.matchedRef) + if (!affiliate) { + trx.bankDonationStatus = BankDonationStatus.unrecognized + continue + } + await this.processAffiliateDonations(affiliate, trx) continue } @@ -377,6 +456,35 @@ export class IrisTasks { return bankTransactions } + private async processAffiliateDonations(affiliate: AffiliatePayload, trx: filteredTransaction) { + let totalDonated = 0 + let updatedDonations = 0 + if (!trx.amount) { + trx.bankDonationStatus = BankDonationStatus.importFailed + return ImportStatus.UNPROCESSED + } + //If no guaranteed donations are found for the affiliate + //mark the transaction as imported + if (affiliate.donations.length === 0) { + trx.bankDonationStatus = BankDonationStatus.imported + return ImportStatus.SUCCESS + } + + for (const donation of affiliate.donations) { + if (trx.amount - totalDonated < donation.amount) continue + await this.donationsService.updateAffiliateBankPayment(donation) + totalDonated += donation.amount + updatedDonations++ + } + if (trx.amount - totalDonated > 0 || updatedDonations < affiliate.donations.length) { + trx.bankDonationStatus = BankDonationStatus.incomplete + return ImportStatus.INCOMPLETE + } + + trx.bankDonationStatus = BankDonationStatus.imported + return ImportStatus.SUCCESS + } + private prepareBankPaymentObject( bankTransaction: Prisma.BankTransactionCreateManyInput, vault: Vault, @@ -401,7 +509,19 @@ export class IrisTasks { private async saveBankTrxRecords(data: filteredTransaction[]) { // Insert new transactions - const inserted = await this.prisma.bankTransaction.createMany({ data, skipDuplicates: true }) + const inserted = await this.prisma.$transaction( + data.map((trx) => + this.prisma.bankTransaction.upsert({ + where: { id: trx.id }, + update: { + bankDonationStatus: trx.description.startsWith(this.AFFILIATE_CODE_PREFIX) + ? trx.bankDonationStatus + : undefined, + }, + create: { ...trx }, + }), + ), + ) return inserted } diff --git a/db/seed/company/factory.ts b/db/seed/company/factory.ts index 3b737f37d..224f8ffd3 100644 --- a/db/seed/company/factory.ts +++ b/db/seed/company/factory.ts @@ -8,6 +8,7 @@ export const companyFactory = Factory.define(({ associations }) => ({ companyName: faker.company.name(), companyNumber: faker.finance.account(9), legalPersonName: faker.name.fullName(), + personId: null, countryCode: faker.address.countryCode(), cityId: associations.cityId || faker.datatype.uuid(), createdAt: faker.date.past(), diff --git a/db/seed/donation/factory.ts b/db/seed/donation/factory.ts index c27af5ab1..d0f40799a 100644 --- a/db/seed/donation/factory.ts +++ b/db/seed/donation/factory.ts @@ -6,6 +6,7 @@ import { Currency, DonationStatus, DonationType, PaymentProvider } from '@prisma export const donationFactory = Factory.define(({ associations }) => ({ id: faker.datatype.uuid(), + affiliateId: null, type: faker.helpers.arrayElement(Object.values(DonationType)), status: faker.helpers.arrayElement(Object.values(DonationStatus)), provider: faker.helpers.arrayElement(Object.values(PaymentProvider)), diff --git a/db/seed/person/data.ts b/db/seed/person/data.ts index e76582749..7429be5fa 100644 --- a/db/seed/person/data.ts +++ b/db/seed/person/data.ts @@ -9,7 +9,6 @@ export const adminUser: Person = personFactory.build({ firstName: 'Admin', lastName: 'Dev', email: 'admin@podkrepi.bg', - company: 'Podkrepi.bg', keycloakId: '6892fe15-d116-4aec-a417-82ebd990b63a', }) @@ -17,7 +16,6 @@ export const coordinatorUser: Person = personFactory.build({ firstName: 'Coordinator', lastName: 'Dev', email: 'coordinator@podkrepi.bg', - company: 'Podkrepi.bg', keycloakId: '81d93c73-db28-4402-8ec0-a5b1709ed1cf', }) @@ -25,7 +23,6 @@ export const giverUser: Person = personFactory.build({ firstName: 'Giver', lastName: 'Dev', email: 'giver@podkrepi.bg', - company: 'Podkrepi.bg', keycloakId: '190486ff-7f0e-4e28-94ca-b624726b5389', }) @@ -33,7 +30,6 @@ export const receiverUser: Person = personFactory.build({ firstName: 'Receiver', lastName: 'Dev', email: 'receiver@podkrepi.bg', - company: 'Podkrepi.bg', keycloakId: '6c688460-73ec-414c-8252-986b0658002b', }) @@ -41,6 +37,5 @@ export const reviewerUser: Person = personFactory.build({ firstName: 'Reviewer', lastName: 'Dev', email: 'reviewer@podkrepi.bg', - company: 'Podkrepi.bg', keycloakId: '36bec201-b203-46ad-a8c3-43a0128c73e1', }) diff --git a/db/seed/person/factory.ts b/db/seed/person/factory.ts index 0792f32e5..518435f0f 100644 --- a/db/seed/person/factory.ts +++ b/db/seed/person/factory.ts @@ -5,6 +5,7 @@ import { Person } from '@prisma/client' export const personFactory = Factory.define(() => ({ id: faker.datatype.uuid(), + companyId: null, keycloakId: null, stripeCustomerId: null, firstName: faker.name.firstName(), @@ -14,7 +15,6 @@ export const personFactory = Factory.define(() => ({ picture: faker.image.imageUrl(), phone: faker.phone.number('+359########'), personalNumber: faker.random.numeric(10), - company: faker.company.name(), address: `${faker.address.street()}, ${faker.address.cityName()}`, birthday: faker.date.birthdate(), newsletter: faker.datatype.boolean(), diff --git a/libs/podkrepi-types/src/lib/person/create-person.dto.ts b/libs/podkrepi-types/src/lib/person/create-person.dto.ts index b4251c277..fa3263903 100644 --- a/libs/podkrepi-types/src/lib/person/create-person.dto.ts +++ b/libs/podkrepi-types/src/lib/person/create-person.dto.ts @@ -29,12 +29,6 @@ export class CreatePersonDto { @IsString() public readonly phone: string - @ApiProperty({ nullable: true, required: false }) - @Expose() - @IsOptional() - @IsString() - public readonly company: string | null - @ApiProperty({ nullable: true, required: false }) @Expose() @IsOptional() @@ -61,7 +55,6 @@ export class CreatePersonDto { email: this.email, phone: this.phone, newsletter: this.newsletter, - company: this.company, address: this.address, birthday: this.birthday, } diff --git a/migrations/20231009123833_link_company_to_person/migration.sql b/migrations/20231009123833_link_company_to_person/migration.sql new file mode 100644 index 000000000..b1e15f5eb --- /dev/null +++ b/migrations/20231009123833_link_company_to_person/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[personId]` on the table `companies` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "companies" ADD COLUMN "personId" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "companies_personId_key" ON "companies"("personId"); + +-- AddForeignKey +ALTER TABLE "companies" ADD CONSTRAINT "companies_personId_fkey" FOREIGN KEY ("personId") REFERENCES "people"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20231009151309_affiliate_program_initial_changes/migration.sql b/migrations/20231009151309_affiliate_program_initial_changes/migration.sql new file mode 100644 index 000000000..458dab97f --- /dev/null +++ b/migrations/20231009151309_affiliate_program_initial_changes/migration.sql @@ -0,0 +1,48 @@ +/* + Warnings: + + - You are about to drop the column `personId` on the `companies` table. All the data in the column will be lost. + - You are about to drop the column `company` on the `people` table. All the data in the column will be lost. + - A unique constraint covering the columns `[person_id]` on the table `companies` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "AffiliateStatus" AS ENUM ('active', 'pending', 'cancelled', 'rejected'); + +-- DropForeignKey +ALTER TABLE "companies" DROP CONSTRAINT "companies_personId_fkey"; + +-- DropIndex +DROP INDEX "companies_personId_key"; + +-- AlterTable +ALTER TABLE "companies" DROP COLUMN "personId", +ADD COLUMN "person_id" UUID; + +-- AlterTable +ALTER TABLE "people" DROP COLUMN "company"; + +-- CreateTable +CREATE TABLE "affiliates" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "status" "AffiliateStatus" NOT NULL DEFAULT 'pending', + "affiliate_code" TEXT, + "company_id" UUID, + + CONSTRAINT "affiliates_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "affiliates_affiliate_code_key" ON "affiliates"("affiliate_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "affiliates_company_id_key" ON "affiliates"("company_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "companies_person_id_key" ON "companies"("person_id"); + +-- AddForeignKey +ALTER TABLE "companies" ADD CONSTRAINT "companies_person_id_fkey" FOREIGN KEY ("person_id") REFERENCES "people"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "affiliates" ADD CONSTRAINT "affiliates_company_id_fkey" FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20231012153756_add_donation_relation_to_affiliate/migration.sql b/migrations/20231012153756_add_donation_relation_to_affiliate/migration.sql new file mode 100644 index 000000000..9a1aa4ec8 --- /dev/null +++ b/migrations/20231012153756_add_donation_relation_to_affiliate/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "donations" ADD COLUMN "affiliate_id" UUID; + +-- AddForeignKey +ALTER TABLE "donations" ADD CONSTRAINT "donations_affiliate_id_fkey" FOREIGN KEY ("affiliate_id") REFERENCES "affiliates"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20231013085126_add_guaranteed_donation_status/migration.sql b/migrations/20231013085126_add_guaranteed_donation_status/migration.sql new file mode 100644 index 000000000..0f5752d80 --- /dev/null +++ b/migrations/20231013085126_add_guaranteed_donation_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "donation_status" ADD VALUE 'guaranteed'; diff --git a/migrations/20231017120302_add_incomplete_bank_donation_status/migration.sql b/migrations/20231017120302_add_incomplete_bank_donation_status/migration.sql new file mode 100644 index 000000000..dc956e7df --- /dev/null +++ b/migrations/20231017120302_add_incomplete_bank_donation_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "bank_donation_status" ADD VALUE 'incomplete'; diff --git a/migrations/20231024102451_add_company_id_to_person_field/migration.sql b/migrations/20231024102451_add_company_id_to_person_field/migration.sql new file mode 100644 index 000000000..da2a87215 --- /dev/null +++ b/migrations/20231024102451_add_company_id_to_person_field/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - A unique constraint covering the columns `[company_id]` on the table `people` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "companies" DROP CONSTRAINT "companies_person_id_fkey"; + +-- AlterTable +ALTER TABLE "people" ADD COLUMN "company_id" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "people_company_id_key" ON "people"("company_id"); + +-- AddForeignKey +ALTER TABLE "people" ADD CONSTRAINT "people_company_id_fkey" FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20231024142056_affiliate_make_company_id_not_null/migration.sql b/migrations/20231024142056_affiliate_make_company_id_not_null/migration.sql new file mode 100644 index 000000000..aec1f9ec3 --- /dev/null +++ b/migrations/20231024142056_affiliate_make_company_id_not_null/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `company_id` on table `affiliates` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "affiliates" DROP CONSTRAINT "affiliates_company_id_fkey"; + +-- AlterTable +ALTER TABLE "affiliates" ALTER COLUMN "company_id" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "affiliates" ADD CONSTRAINT "affiliates_company_id_fkey" FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/migrations/20231026144115_add_donation_metadata/migration.sql b/migrations/20231026144115_add_donation_metadata/migration.sql new file mode 100644 index 000000000..a43ccc29f --- /dev/null +++ b/migrations/20231026144115_add_donation_metadata/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "DonationMetadata" ( + "donation_id" UUID NOT NULL, + "name" VARCHAR, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "extra_data" JSONB, + + CONSTRAINT "DonationMetadata_pkey" PRIMARY KEY ("donation_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DonationMetadata_donation_id_key" ON "DonationMetadata"("donation_id"); + +-- AddForeignKey +ALTER TABLE "DonationMetadata" ADD CONSTRAINT "DonationMetadata_donation_id_fkey" FOREIGN KEY ("donation_id") REFERENCES "donations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/migrations/20231027074921_map_donation_metadata/migration.sql b/migrations/20231027074921_map_donation_metadata/migration.sql new file mode 100644 index 000000000..e20fdc67a --- /dev/null +++ b/migrations/20231027074921_map_donation_metadata/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - You are about to drop the `DonationMetadata` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "DonationMetadata" DROP CONSTRAINT "DonationMetadata_donation_id_fkey"; + +-- DropTable +DROP TABLE "DonationMetadata"; + +-- CreateTable +CREATE TABLE "donation_metadata" ( + "donation_id" UUID NOT NULL, + "name" VARCHAR, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "extra_data" JSONB, + + CONSTRAINT "donation_metadata_pkey" PRIMARY KEY ("donation_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "donation_metadata_donation_id_key" ON "donation_metadata"("donation_id"); + +-- AddForeignKey +ALTER TABLE "donation_metadata" ADD CONSTRAINT "donation_metadata_donation_id_fkey" FOREIGN KEY ("donation_id") REFERENCES "donations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/migrations/20231031091416_add_corporate_to_donation_type/migration.sql b/migrations/20231031091416_add_corporate_to_donation_type/migration.sql new file mode 100644 index 000000000..52769b15a --- /dev/null +++ b/migrations/20231031091416_add_corporate_to_donation_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "donation_type" ADD VALUE 'corporate'; diff --git a/migrations/20231106124859_add_profile_enabled_to_person/migration.sql b/migrations/20231106124859_add_profile_enabled_to_person/migration.sql new file mode 100644 index 000000000..a067c4e67 --- /dev/null +++ b/migrations/20231106124859_add_profile_enabled_to_person/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "people" ADD COLUMN "profile_enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/migrations/20231107131150_add_datetime_fields_to_affiliate/migration.sql b/migrations/20231107131150_add_datetime_fields_to_affiliate/migration.sql new file mode 100644 index 000000000..0450927e6 --- /dev/null +++ b/migrations/20231107131150_add_datetime_fields_to_affiliate/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "affiliates" ADD COLUMN "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updated_at" TIMESTAMPTZ(6); diff --git a/podkrepi.dbml b/podkrepi.dbml index 89e170192..7c6c8b54c 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -13,7 +13,6 @@ Table people { lastName String [not null] email String [unique] phone String - company String createdAt DateTime [default: `now()`, not null] updatedAt DateTime newsletter Boolean [default: false] @@ -22,9 +21,11 @@ Table people { emailConfirmed Boolean [default: false] personalNumber String [unique, note: 'Uniform Civil Number (NCN, EGN) https://en.wikipedia.org/wiki/National_identification_number#Bulgaria'] + companyId String [unique] keycloakId String [unique] stripeCustomerId String [unique] picture String + profileEnabled Boolean [not null, default: true] benefactors benefactors [not null] beneficiaries beneficiaries [not null] campaignFiles campaign_files [not null] @@ -45,6 +46,7 @@ https://en.wikipedia.org/wiki/National_identification_number#Bulgaria'] withdrawals withdrawals [not null] publishedNews campaign_news [not null] newsFiles campaign_news_files [not null] + company companies Note: 'Generic person object' } @@ -57,10 +59,24 @@ https://psc.egov.bg/en/psc-starting-a-business-bulstat'] legalPersonName String countryCode String cityId String + personId String [unique] createdAt DateTime [default: `now()`, not null] updatedAt DateTime beneficiaries beneficiaries [not null] Campaign campaigns [not null] + person people + affiliate affiliates +} + +Table affiliates { + id String [pk] + status AffiliateStatus [not null, default: 'pending'] + affiliateCode String [unique] + companyId String [unique, not null] + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime + company companies [not null] + donations donations [not null] } Table organizers { @@ -366,13 +382,24 @@ Table donations { updatedAt DateTime amount Int [not null, default: 0] currency Currency [not null, default: 'BGN'] + affiliateId String personId String billingEmail String billingName String chargedAmount Int [not null, default: 0] person people targetVault vaults [not null] + affiliate affiliates DonationWish donation_wishes + metadata donation_metadata +} + +Table donation_metadata { + donationId String [pk] + name String + createdAt DateTime [default: `now()`, not null] + extraData Json + donation donations [not null] } Table donation_wishes { @@ -630,6 +657,7 @@ Enum DocumentType { Enum DonationType { donation + corporate } Enum DonationStatus { @@ -639,6 +667,7 @@ Enum DonationStatus { declined waiting cancelled + guaranteed succeeded deleted refund @@ -694,6 +723,7 @@ Enum BankTransactionType { Enum BankDonationStatus { unrecognized imported + incomplete reImported importFailed } @@ -712,6 +742,13 @@ Enum CampaignTypeCategory { others } +Enum AffiliateStatus { + active + pending + cancelled + rejected +} + Enum CampaignFileRole { background coordinator @@ -764,6 +801,10 @@ Enum EmailType { raised100 } +Ref: people.companyId - companies.id + +Ref: affiliates.companyId - companies.id + Ref: organizers.personId - people.id Ref: coordinators.personId - people.id @@ -830,6 +871,10 @@ Ref: donations.personId > people.id Ref: donations.targetVaultId > vaults.id +Ref: donations.affiliateId > affiliates.id + +Ref: donation_metadata.donationId - donations.id + Ref: donation_wishes.campaignId > campaigns.id Ref: donation_wishes.personId > people.id diff --git a/schema.prisma b/schema.prisma index a2868c97e..1c3d805d6 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1,6 +1,6 @@ generator client { - provider = "prisma-client-js" - binaryTargets = ["native"] + provider = "prisma-client-js" + binaryTargets = ["native"] } generator dbml { @@ -36,7 +36,6 @@ model Person { lastName String @map("last_name") @db.VarChar(100) email String? @unique @db.Citext phone String? @db.VarChar(50) - company String? @db.VarChar(50) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) // Receive marketing notifications @@ -47,9 +46,11 @@ model Person { /// Uniform Civil Number (NCN, EGN) /// https://en.wikipedia.org/wiki/National_identification_number#Bulgaria personalNumber String? @unique @map("personal_number") + companyId String? @unique @map("company_id") @db.Uuid keycloakId String? @unique @map("keycloak_id") @db.Uuid stripeCustomerId String? @unique @map("stripe_customer_id") picture String? @db.VarChar(250) + profileEnabled Boolean @default(true) @map("profile_enabled") // Used to verify some emails sent to the user benefactors Benefactor[] beneficiaries Beneficiary[] @@ -71,6 +72,7 @@ model Person { withdrawals Withdrawal[] publishedNews CampaignNews[] newsFiles CampaignNewsFile[] + company Company? @relation(fields: [companyId], references: [id]) @@index([keycloakId], map: "keycloak_id_idx") @@index([stripeCustomerId], map: "stripe_customer_id_idx") @@ -86,14 +88,30 @@ model Company { legalPersonName String? @map("legal_person_name") countryCode String? @map("country_code") @db.Citext cityId String? @map("city_id") @db.Uuid + personId String? @unique @map("person_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) beneficiaries Beneficiary[] Campaign Campaign[] + person Person? + affiliate Affiliate? @@map("companies") } +model Affiliate { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + status AffiliateStatus @default(pending) + affiliateCode String? @unique @map("affiliate_code") + companyId String @unique @map("company_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + company Company @relation(fields: [companyId], references: [id]) + donations Donation[] + + @@map("affiliates") +} + /// Organizer is the person who manages the campaign on behalf of the Beneficiary model Organizer { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid @@ -259,9 +277,9 @@ model MarketingTemplates { // Stores marketing notifications consents for non-registered emails model UnregisteredNotificationConsent { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - email String @unique @db.Citext - consent Boolean @default(false) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + email String @unique @db.Citext + consent Boolean @default(false) @@map("unregistered_notification_consent") } @@ -431,32 +449,45 @@ model Vault { } model Donation { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid type DonationType - status DonationStatus @default(initial) - provider PaymentProvider @default(none) + status DonationStatus @default(initial) + provider PaymentProvider @default(none) /// Vault where the funds are going - targetVaultId String @map("target_vault_id") @db.Uuid + targetVaultId String @map("target_vault_id") @db.Uuid /// Payment provider attributes - extCustomerId String @map("ext_customer_id") @db.VarChar(50) - extPaymentIntentId String @unique @map("ext_payment_intent_id") - extPaymentMethodId String @map("ext_payment_method_id") + extCustomerId String @map("ext_customer_id") @db.VarChar(50) + extPaymentIntentId String @unique @map("ext_payment_intent_id") + extPaymentMethodId String @map("ext_payment_method_id") /// - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - amount Int @default(0) - currency Currency @default(BGN) - personId String? @map("person_id") @db.Uuid - billingEmail String? @map("billing_email") @db.VarChar - billingName String? @map("billing_name") @db.VarChar - chargedAmount Int @default(0) - person Person? @relation(fields: [personId], references: [id]) - targetVault Vault @relation(fields: [targetVaultId], references: [id]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + amount Int @default(0) + currency Currency @default(BGN) + affiliateId String? @map("affiliate_id") @db.Uuid + personId String? @map("person_id") @db.Uuid + billingEmail String? @map("billing_email") @db.VarChar + billingName String? @map("billing_name") @db.VarChar + chargedAmount Int @default(0) + person Person? @relation(fields: [personId], references: [id]) + targetVault Vault @relation(fields: [targetVaultId], references: [id]) + affiliate Affiliate? @relation(fields: [affiliateId], references: [id]) DonationWish DonationWish? + metadata DonationMetadata? @@map("donations") } +model DonationMetadata { + donationId String @id @unique @map("donation_id") @db.Uuid + name String? @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + extraData Json? @map("extra_data") + donation Donation @relation(fields: [donationId], references: [id]) + + @@map("donation_metadata") +} + model DonationWish { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid message String @@ -765,6 +796,7 @@ enum DocumentType { enum DonationType { donation + corporate @@map("donation_type") } @@ -776,6 +808,7 @@ enum DonationStatus { declined waiting cancelled + guaranteed succeeded deleted refund @@ -857,6 +890,7 @@ enum BankTransactionType { enum BankDonationStatus { unrecognized imported + incomplete reImported @map("re_imported") importFailed @map("import_failed") @@ -879,6 +913,13 @@ enum CampaignTypeCategory { @@map("campaign_type_category") } +enum AffiliateStatus { + active + pending + cancelled + rejected +} + enum CampaignFileRole { background coordinator