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