Skip to content

Commit

Permalink
[2/2] Affiliate program Integration v1 (podkrepi-bg#564)
Browse files Browse the repository at this point in the history
Affiliate Program integration

All changes below:
* auth: Allow for corporate registrations
The corporate registrations will be manually verified, and activated by podkrepi.bg administrator.

* auth: Don't create new corporate profile if company exists

* Affiliate Program: Initial changes
-Added Affiliate model
-Removed company field from Person model. That information will now be received from the company relation
-Added endpoints to handle adding new affiliates, and updating their status

* Run prisma migrate

* schema.prisma: Add donation relation to Affiliate model

* schema.prisma: Add guaranteed status for donations

* Add affiliateId to testing and seeding scripts

* validation: Set exposeDefaultValues to true globally
Would allow us to set default values to fields directly in the dto, which might result in cleaner code.

* src/affiliate: Implement endpoints for creating/cancelling donations
Affiliate donations will be marked as guaranteed by default

* src/campaign: Include guaranteedAmount in campaign's summary

* tasks/bank-import: Handle affiliate donations

* bank-transactions: Add endpoint to simulate IRIS transactions.
Available only on development/staging enviroment

* src/affiliate: Change affiliateCode structure

* affiliate: Make affiliateCode available only for active affiliates

* jest: Fix test breakage

* src/donations: Include guaranteed donations in listPublic
Removed unnecessary count query. Replaced with the length of the findMany query

* eslint: Address errors and warnings

* src/donations: Include companyName in listPublic endpoint

* corporateUser: Add space between first and last name for legalpersonname

* src/affiliate: Generate new code whenever affiliate is set to active

* Run nx format

* src/donations:c Add affiliateId to donation mock

* src/affiliates: Add tests

* bank-transaction: Return unprocessed if no  guaranteed donations are found for affiliate

* tasks/bank-import: Set transaction to importFailed if no guaranteed donations are found for affiliate

* tasks/bank-import: Include test for affiliate donations

* src/affiliate: Fix redundant space on ForbiddenException

* affiliate.controller.spec: Rename test to AffiliateController

* schema.prisma: Add companyId to person model

* auth.service: Add companyId in createPerson method

* src/register.controller: Use single endpoint for registration
- Removed CompanyRegisterDto, and moved companyNumber and companyName to RegisterDto.
- Added type to RegisterDto, to differentiate individual/corporate profiles
- Made it so companyName and companyNumber fields will be validated only if profile's type is corporate

* Consider bank transaction as successfull if no guaranteed donations are found

* affiliate: Rename endpoints

* bank-transaction: Update bankDonationStatus on duplicate record

* affiliate: Create endpoint to get donations with pagination

* schema.prisma:Affiliate: Make companyId not null

* affiliateCodeGenerator: Get the first 8 characters

* eslint: Address error and warnings

* tasks/bank-import: Change bankstatus only if donation is affiliate

* jest: Fix tests

* swagger: Provide schema for iris-transaction-test endpoint

* bank-import: Initialize variable with affiliate code prefix

* donation: Add metadata field to donation Model
Similar to how metadata field works in Stripe, this field will include additional data, for the donation, such as the name of the person who donated through the affiliate API, and additional field to store necessary for the affiliate data.

* schema.prisma: Map DonationMetadata model

* src/affiliate: Include metadata in affiliateDonationDto in jest

* src/auth: Allow for corporate fields to be either string or undefined

* src/affiliate: Improve semantics
CreateAffiliateDonationDto: Removed validator decorators from affiliateId, personId, billingName and billigEmail, as those fields shouldn't be part of the request body itself.
createAffiliateDonation(): Use chain conditioning to find whether affiliate exists or not.

* src/affiliate: Fix test

* src/affiliate: Include JWT_SECRET_KEY when generating affiliate code
When generating affiliate code using only affiliateId, the affiliate code could easily be leaked, if some API response returns the affiliateId.
This change ensures that this doesn't happen

* src/affiliate: Create new endpoint to refresh affiliate code
Would be useful in case of a leak

* src/donations: Fix error handling if no vault has been found
vaultService.findByCampaignId returns an array, thus we need to also check if the length of that array is 0

* src/affiliate: Check whether campaign accepts more donations before making one

* src/donations: Bring back count query to listPublic service endpoint
The result of donation's query is paginated, thus its length doesnt represent the total amount of donations, and thus breaks the pagination on the front-end

* src/donations: Handle corporate donations

* src/donations: Don't update updatedAt property on imported donation
When we show the donations on frontend they are ordered by the updatedAt property in descending order, thus when bank transaction is imported, those affiliate donations will be shown again on top of the list, which might cause  confusion

* src/donation-wish: Extend selector to include companyName and donation type

* src/affiliate: Include donation wish in query

* updateAffiliateBankPayment: Use value of donation's updatedAt field
When passing undefined to the query, prisma still updates the updatedAt field, as it uses default property

* getUserDonationById: Include companyName inside the query
Needed in order to show the corporation's name inside the certificate

* userDonationById(): Get first and last name from keycloak token
Needed in order to show the name of the donor on the certificate, if the donation is anonymous

* src/affiliate: Add new endpoint to get affiliate's data by userid

* src/affiliate: cleanup console.log

* schema.prisma: Add profileEnabled to person table
Makes it easier to track whether a corporate profile is enabled or not

* src/account: Make endpoint for manual activation/deactivation of profile

* src/person: Fix ordering by profile type not working

* src/auth: Prevent deactivation of profile if it is in podkrepi-admin group

* src/affiliate: Solve ts errors on controller.spec

* src/affiliate: Add new endpoint to list all affiliates
Needed for the admin panel

* src/affiliate: Include person's email in findAll query result

* src/auth: Fix test breakage

* schema.prisma: Add createdAt and updatedAt fields to Affiliate model

* src/donations: Improve validation of metadata fields if not undefined
-if metadata is included in the request body
    -it should not be empty
    -should contain either name or extraData
    -extraData should be an object

* src/donations: Fix ts errors

* bank-transaction: iris-simulation should be done only by admin

* dto/donation-metadata: Fix extraData field being empty on valid Object being sent
Apparently the latest changes broke the extraData field.
 - Removed the unecessary dto extension, as it is enough to validated whether the extraData is Object
 - Used more specific type of the expected request body of extraData

* src/auth: Change admin roles lookup
In keycloak admin access is granted through team-support role - which is compose of view-supporters, view-contact-requests.

* events/stripe: Resolve errors after merge of master

* ESLint: Remove unused imports

* auth.service.spec: Bring  back issueToken testcase
Not quite sure why it was removed
  • Loading branch information
sashko9807 authored Nov 14, 2023
1 parent 0a978a7 commit 22ffb13
Show file tree
Hide file tree
Showing 91 changed files with 1,967 additions and 177 deletions.
17 changes: 16 additions & 1 deletion apps/api/src/account/account.controller.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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,
)
}
}
6 changes: 5 additions & 1 deletion apps/api/src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
353 changes: 353 additions & 0 deletions apps/api/src/affiliate/affiliate.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AffiliateController)
service = module.get<AffiliateService>(AffiliateService)
donationService = module.get<DonationsService>(DonationsService)
})

const affiliateUpdateDto: AffiliateStatusUpdateDto = {
newStatus: 'active',
}

const mockIndividualProfile: PersonWithPayload = {
id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd',
firstName: 'John',
lastName: 'Doe',
companyId: null,
keycloakId: '123',
email: '[email protected]',
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: '[email protected]',
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: '[email protected]',
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()
})
})
})
Loading

0 comments on commit 22ffb13

Please sign in to comment.