Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2/2] Affiliate program Integration v1 #564

Merged
merged 80 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from 66 commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
48bbbac
auth: Allow for corporate registrations
sashko9807 Oct 9, 2023
e1c0be3
auth: Don't create new corporate profile if company exists
sashko9807 Oct 9, 2023
310e19c
Affiliate Program: Initial changes
sashko9807 Oct 10, 2023
254cca6
Run prisma migrate
sashko9807 Oct 10, 2023
5002426
schema.prisma: Add donation relation to Affiliate model
sashko9807 Oct 12, 2023
a0e658d
schema.prisma: Add guaranteed status for donations
sashko9807 Oct 13, 2023
d00ff81
Add affiliateId to testing and seeding scripts
sashko9807 Oct 16, 2023
f8e7252
validation: Set exposeDefaultValues to true globally
sashko9807 Oct 16, 2023
fc847c3
src/affiliate: Implement endpoints for creating/cancelling donations
sashko9807 Oct 17, 2023
7c7ea94
src/campaign: Include guaranteedAmount in campaign's summary
sashko9807 Oct 17, 2023
8782e23
tasks/bank-import: Handle affiliate donations
sashko9807 Oct 18, 2023
a8337a3
bank-transactions: Add endpoint to simulate IRIS transactions.
sashko9807 Oct 19, 2023
39b7f7a
src/affiliate: Change affiliateCode structure
sashko9807 Oct 19, 2023
ed56cfc
affiliate: Make affiliateCode available only for active affiliates
sashko9807 Oct 19, 2023
3f48f56
jest: Fix test breakage
sashko9807 Oct 19, 2023
764731d
src/donations: Include guaranteed donations in listPublic
sashko9807 Oct 20, 2023
b187047
eslint: Address errors and warnings
sashko9807 Oct 20, 2023
76c8129
src/donations: Include companyName in listPublic endpoint
sashko9807 Oct 20, 2023
1dc7243
corporateUser: Add space between first and last name for legalpersonname
sashko9807 Oct 20, 2023
505a210
src/affiliate: Generate new code whenever affiliate is set to active
sashko9807 Oct 20, 2023
9afc6e9
Run nx format
sashko9807 Oct 21, 2023
fc1907c
src/donations:c Add affiliateId to donation mock
sashko9807 Oct 21, 2023
4ac27a3
src/affiliates: Add tests
sashko9807 Oct 23, 2023
f8a8bce
bank-transaction: Return unprocessed if no guaranteed donations are …
sashko9807 Oct 23, 2023
b51522d
tasks/bank-import: Set transaction to importFailed if no guaranteed d…
sashko9807 Oct 23, 2023
ca85225
tasks/bank-import: Include test for affiliate donations
sashko9807 Oct 23, 2023
b4f0824
src/affiliate: Fix redundant space on ForbiddenException
sashko9807 Oct 23, 2023
df7d3ed
affiliate.controller.spec: Rename test to AffiliateController
sashko9807 Oct 24, 2023
1571039
schema.prisma: Add companyId to person model
sashko9807 Oct 24, 2023
e5f1065
auth.service: Add companyId in createPerson method
sashko9807 Oct 24, 2023
9c5f796
src/register.controller: Use single endpoint for registration
sashko9807 Oct 24, 2023
fa1a0c1
Consider bank transaction as successfull if no guaranteed donations a…
sashko9807 Oct 24, 2023
7cbe6f7
affiliate: Rename endpoints
sashko9807 Oct 24, 2023
80711dd
bank-transaction: Update bankDonationStatus on duplicate record
sashko9807 Oct 24, 2023
4de21df
affiliate: Create endpoint to get donations with pagination
sashko9807 Oct 24, 2023
480524a
schema.prisma:Affiliate: Make companyId not null
sashko9807 Oct 24, 2023
7245c78
affiliateCodeGenerator: Get the first 8 characters
sashko9807 Oct 24, 2023
1c09553
eslint: Address error and warnings
sashko9807 Oct 24, 2023
b7c2716
tasks/bank-import: Change bankstatus only if donation is affiliate
sashko9807 Oct 24, 2023
8d389a5
jest: Fix tests
sashko9807 Oct 24, 2023
4295987
swagger: Provide schema for iris-transaction-test endpoint
sashko9807 Oct 25, 2023
724ac2d
bank-import: Initialize variable with affiliate code prefix
sashko9807 Oct 26, 2023
d79c1dd
donation: Add metadata field to donation Model
sashko9807 Oct 26, 2023
00b31d1
schema.prisma: Map DonationMetadata model
sashko9807 Oct 27, 2023
7055e1d
src/affiliate: Include metadata in affiliateDonationDto in jest
sashko9807 Oct 30, 2023
52d67ee
src/auth: Allow for corporate fields to be either string or undefined
sashko9807 Oct 30, 2023
734963f
src/affiliate: Improve semantics
sashko9807 Oct 30, 2023
4daeba8
src/affiliate: Fix test
sashko9807 Oct 30, 2023
50238df
src/affiliate: Include JWT_SECRET_KEY when generating affiliate code
sashko9807 Oct 30, 2023
7ce8939
src/affiliate: Create new endpoint to refresh affiliate code
sashko9807 Oct 30, 2023
a7d1df3
src/donations: Fix error handling if no vault has been found
sashko9807 Oct 30, 2023
12ceefe
src/affiliate: Check whether campaign accepts more donations before m…
sashko9807 Oct 30, 2023
2aacb5d
src/donations: Bring back count query to listPublic service endpoint
sashko9807 Oct 31, 2023
a5e4e0b
src/donations: Handle corporate donations
sashko9807 Oct 31, 2023
ca25104
src/donations: Don't update updatedAt property on imported donation
sashko9807 Nov 1, 2023
3acf963
src/donation-wish: Extend selector to include companyName and donatio…
sashko9807 Nov 1, 2023
d5754e8
src/affiliate: Include donation wish in query
sashko9807 Nov 1, 2023
87df4e2
updateAffiliateBankPayment: Use value of donation's updatedAt field
sashko9807 Nov 1, 2023
aea9be9
getUserDonationById: Include companyName inside the query
sashko9807 Nov 2, 2023
82eb4a4
userDonationById(): Get first and last name from keycloak token
sashko9807 Nov 2, 2023
c143e6b
src/affiliate: Add new endpoint to get affiliate's data by userid
sashko9807 Nov 4, 2023
4929a4f
src/affiliate: cleanup console.log
sashko9807 Nov 4, 2023
ec25464
schema.prisma: Add profileEnabled to person table
sashko9807 Nov 6, 2023
e112223
src/account: Make endpoint for manual activation/deactivation of profile
sashko9807 Nov 6, 2023
7cd1a24
src/person: Fix ordering by profile type not working
sashko9807 Nov 6, 2023
62cfe03
src/auth: Prevent deactivation of profile if it is in podkrepi-admin …
sashko9807 Nov 6, 2023
f2be94f
src/affiliate: Solve ts errors on controller.spec
sashko9807 Nov 7, 2023
4227d25
src/affiliate: Add new endpoint to list all affiliates
sashko9807 Nov 7, 2023
6fa38f0
src/affiliate: Include person's email in findAll query result
sashko9807 Nov 7, 2023
c42e7b3
src/auth: Fix test breakage
sashko9807 Nov 7, 2023
5a68fc4
schema.prisma: Add createdAt and updatedAt fields to Affiliate model
sashko9807 Nov 7, 2023
26837ce
src/donations: Improve validation of metadata fields if not undefined
sashko9807 Nov 8, 2023
bd4a86a
src/donations: Fix ts errors
sashko9807 Nov 8, 2023
b34c67a
bank-transaction: iris-simulation should be done only by admin
sashko9807 Nov 8, 2023
e113148
dto/donation-metadata: Fix extraData field being empty on valid Objec…
sashko9807 Nov 9, 2023
8079147
src/auth: Change admin roles lookup
sashko9807 Nov 12, 2023
753d220
Merge branch 'master' into affiliate-program2
sashko9807 Nov 13, 2023
92044eb
events/stripe: Resolve errors after merge of master
sashko9807 Nov 13, 2023
421df97
ESLint: Remove unused imports
sashko9807 Nov 13, 2023
94b97bc
auth.service.spec: Bring back issueToken testcase
sashko9807 Nov 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, Query } from '@nestjs/common'

Check warning on line 2 in apps/api/src/account/account.controller.ts

View workflow job for this annotation

GitHub Actions / Run API tests

'Query' is defined but never used
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 @@
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)
}
}
349 changes: 349 additions & 0 deletions apps/api/src/affiliate/affiliate.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
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,
}

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',
}
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 = {
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
Loading