Skip to content

Commit

Permalink
Backend support for the campaigns' expenses (#453)
Browse files Browse the repository at this point in the history
* Add support for expenses report per campain.
Allow the user to upload files for each expense.

* Allow for expense files to be downloaded and delete by the coordinator of the campaign or by the admin.

* Add an API to fetch the approved expenses list from the DB in the campaign page.

* Disable some of the expenses unit tests. They seem obsolete, and we don't need blocked amount logic currently.

* Review comments mostly:
1. remove can-edit endoint - we can do this in the frontent with the current endpoints.
2. move list expenses to the campaign controller.
3. Fetch the uploader it from the logged user. It used to be hardcoded.

* Add security validations when we create expenses. Make sure it is either the organizer or an admin.

* Fix a bug in the credentials of the expenses list if the user is an actually admin.

---------
  • Loading branch information
slavcho authored Mar 29, 2023
1 parent e60c482 commit f499978
Show file tree
Hide file tree
Showing 24 changed files with 452 additions and 71 deletions.
22 changes: 22 additions & 0 deletions apps/api/src/campaign/campaign.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,26 @@ export class CampaignController {
async remove(@Param('id') id: string) {
return this.campaignService.removeCampaign(id)
}

@Get(':slug/expenses')
async listCampaignExpenses(
@Param('slug') slug: string,
@AuthenticatedUser() user: KeycloakTokenParsed,
) {
const campaign = await this.campaignService.getCampaignBySlug(slug)
if (!campaign) {
throw new NotFoundException('Campaign not found')
}
if (!isAdmin(user)) {
await this.campaignService.checkCampaignOwner(user.sub, campaign.id)
}

return this.campaignService.listExpenses(slug)
}

@Get(':slug/expenses/approved')
@Public()
async listCampaignExpensesApproved(@Param('slug') slug: string) {
return this.campaignService.listExpensesApproved(slug)
}
}
40 changes: 40 additions & 0 deletions apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
donationNotificationSelect,
} from '../sockets/notifications/notification.service'
import { DonationMetadata } from '../donations/dontation-metadata.interface'
import { Expense } from '@prisma/client'

@Injectable()
export class CampaignService {
Expand Down Expand Up @@ -137,6 +138,21 @@ export class CampaignService {
return campaign
}

async isUserCampaign(keycloakId: string, slug: string): Promise<boolean> {
const campaign = await this.prisma.campaign.findFirst({
where: {
slug,
OR: [
{ beneficiary: { person: { keycloakId } } },
{ coordinator: { person: { keycloakId } } },
{ organizer: { person: { keycloakId } } },
],
},
})

return !!campaign
}

async getUserCampaigns(keycloakId: string): Promise<Campaign[]> {
const campaigns = await this.prisma.campaign.findMany({
where: {
Expand Down Expand Up @@ -302,6 +318,12 @@ export class CampaignService {
const campaignSums = await this.getCampaignSums([campaign.id])

campaign['summary'] = this.getVaultAndDonationSummaries(campaign.id, campaignSums)

const vault = await this.getCampaignVault(campaign.id)
if (vault) {
campaign['defaultVault'] = vault?.id
}

return campaign
}

Expand Down Expand Up @@ -709,6 +731,24 @@ export class CampaignService {
}
}

async listExpenses(slug: string): Promise<Expense[]> {
return this.prisma.expense.findMany({
where: { vault: { campaign: { slug: slug } }, deleted: false },
include: {
expenseFiles: true,
},
})
}

async listExpensesApproved(slug: string): Promise<Expense[]> {
return this.prisma.expense.findMany({
where: { vault: { campaign: { slug: slug } }, deleted: false, approvedById: { not: null } },
include: {
expenseFiles: true,
},
})
}

private getVaultAndDonationSummaries(campaignId: string, campaignSums: CampaignSummaryDto[]) {
const csum = campaignSums.find((e) => e.id === campaignId)
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ExpenseType, Currency, ExpenseStatus } from '@prisma/client'
import { Person } from '../../person/entities/person.entity'
import { Document } from '../../document/entities/document.entity'
import { Vault } from '../../vault/entities/vault.entity'
import { ExpenseFile } from '../../expenseFile/entities/expenseFile.entity'

export class Expense {
id: string
Expand All @@ -17,4 +18,6 @@ export class Expense {
approvedBy?: Person | null
document?: Document | null
vault?: Vault
spentAt: Date
expenseFiles?: ExpenseFile[]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class ConnectExpenseFileDto {
id: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class CreateExpenseFileDto {
filename: string
mimetype: string
}
3 changes: 3 additions & 0 deletions apps/api/src/domain/generated/expenseFile/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './connect-expenseFile.dto'
export * from './create-expenseFile.dto'
export * from './update-expenseFile.dto'
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class UpdateExpenseFileDto {
filename?: string
mimetype?: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Expense } from '../../expense/entities/expense.entity'
import { Person } from '../../person/entities/person.entity'

export class ExpenseFile {
id: string
filename: string
mimetype: string
expenseId: string
uploaderId: string
expense?: Expense
uploadedBy?: Person
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './expenseFile.entity'
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Expense } from '../../expense/entities/expense.entity'
import { InfoRequest } from '../../infoRequest/entities/infoRequest.entity'
import { Irregularity } from '../../irregularity/entities/irregularity.entity'
import { IrregularityFile } from '../../irregularityFile/entities/irregularityFile.entity'
import { ExpenseFile } from '../../expenseFile/entities/expenseFile.entity'
import { Organizer } from '../../organizer/entities/organizer.entity'
import { RecurringDonation } from '../../recurringDonation/entities/recurringDonation.entity'
import { Supporter } from '../../supporter/entities/supporter.entity'
Expand Down Expand Up @@ -45,6 +46,7 @@ export class Person {
infoRequests?: InfoRequest[]
irregularities?: Irregularity[]
irregularityFiles?: IrregularityFile[]
expenseFiles?: ExpenseFile[]
organizer?: Organizer | null
recurringDonations?: RecurringDonation[]
supporters?: Supporter[]
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/expenses/dto/create-expense-file.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class CreateExpenseFileDto {
filename: string
mimetype: string
expenseId: string
uploaderId: string
}
5 changes: 5 additions & 0 deletions apps/api/src/expenses/dto/create-expense.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,9 @@ export class CreateExpenseDto {
@IsUUID()
@IsOptional()
approvedById?: string

@ApiProperty()
@Expose()
@IsOptional()
spentAt?: Date
}
93 changes: 81 additions & 12 deletions apps/api/src/expenses/expenses.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { ExpenseStatus, ExpenseType, Currency } from '@prisma/client'
import { mockReset } from 'jest-mock-extended'
import { CreateExpenseDto } from './dto/create-expense.dto'
import { UpdateExpenseDto } from './dto/update-expense.dto'
import { S3Service } from '../s3/s3.service'
import { S3 } from 'aws-sdk'
import { UnauthorizedException } from '@nestjs/common'

const mockData = [
{
Expand All @@ -22,6 +25,7 @@ const mockData = [
approvedById: '00000000-0000-0000-0000-000000000012',
createdAt: new Date('2022-04-2T09:12:13.511Z'),
updatedAt: new Date('2022-04-2T09:12:13.511Z'),
spentAt: new Date('2022-06-02T09:00:00.511Z'),
},
]

Expand All @@ -33,7 +37,7 @@ describe('ExpensesController', () => {

const module: TestingModule = await Test.createTestingModule({
controllers: [ExpensesController],
providers: [MockPrismaService, ExpensesService],
providers: [MockPrismaService, ExpensesService, S3Service],
}).compile()

controller = module.get<ExpensesController>(ExpensesController)
Expand Down Expand Up @@ -61,21 +65,25 @@ describe('ExpensesController', () => {
amount: 200,
blockedAmount: 0,
}

const person = { id: '00000000-0000-0000-0000-000000000013' }

const campaign = {}
const user = { sub: '00000000-0000-0000-0000-000000000013' }

prismaMock.person.findFirst.mockResolvedValue(person)
prismaMock.campaign.findFirst.mockResolvedValue(campaign)
prismaMock.expense.create.mockResolvedValue(expense)
prismaMock.vault.update.mockResolvedValue(vault)
prismaMock.vault.findFirst.mockResolvedValue(vault)
prismaMock.$transaction.mockResolvedValue([expense, vault])

const createDto: CreateExpenseDto = { ...expense }

const result = await controller.create(createDto)
const result = await controller.create(user, createDto, [])

expect(result).toEqual(expense)
expect(prismaMock.expense.create).toHaveBeenCalledWith({ data: createDto })
expect(prismaMock.vault.update).toHaveBeenCalledWith({
where: { id: '00000000-0000-0000-0000-000000000016' },
data: { blockedAmount: { increment: 150 } },
})
})

it('should not create an expense with insufficient balance', async () => {
Expand All @@ -97,7 +105,9 @@ describe('ExpensesController', () => {

const createDto: CreateExpenseDto = { ...expense }

await expect(controller.create(createDto)).rejects.toThrow()
// TODO: currently we don't have such logic
// in the future if we need to validate the balance then we need to add this to the test
// await expect(controller.create(createDto, [])).rejects.toThrow()
expect(prismaMock.expense.create).not.toHaveBeenCalled()
expect(prismaMock.vault.update).not.toHaveBeenCalled()
})
Expand All @@ -115,16 +125,32 @@ describe('ExpensesController', () => {
amount: 1000,
blockedAmount: 350,
}
const user = {
sub: '00000000-0000-0000-0000-000000000012',
}

const person = {
id: '00000000-0000-0000-0000-000000000013',
}

const campaign = {}

prismaMock.person.findFirst.mockResolvedValue(person)
prismaMock.campaign.findFirst.mockResolvedValue(campaign)
prismaMock.vault.findFirst.mockResolvedValue(vault)
prismaMock.expense.findFirst.mockResolvedValue(expense)
prismaMock.vault.update.mockResolvedValue(vault)
prismaMock.expense.update.mockResolvedValue(expense)
prismaMock.$transaction.mockResolvedValue([expense, vault])

const updateDto: UpdateExpenseDto = { ...expense, status: ExpenseStatus.approved }
const updateDto: UpdateExpenseDto = {
...expense,
status: ExpenseStatus.approved,
vaultId: vault.id,
}

// act
const result = await controller.update(expense.id, updateDto)
const result = await controller.update(user, expense.id, updateDto)

// assert
expect(result).toEqual(expense)
Expand All @@ -141,6 +167,40 @@ describe('ExpensesController', () => {
})
})

it('should raise an exception, since the user is not authorized', async () => {
const expense = mockData[0]

const vault = {
id: '00000000-0000-0000-0000-000000000016',
name: 'vault1',
currency: Currency.BGN,
campaignId: '00000000-0000-0000-0000-000000000015',
createdAt: new Date(),
updatedAt: new Date(),
amount: 1000,
blockedAmount: 350,
}
const user = {
sub: '00000000-0000-0000-0000-000000000012',
}

prismaMock.vault.findFirst.mockResolvedValue(vault)
prismaMock.expense.findFirst.mockResolvedValue(expense)
prismaMock.vault.update.mockResolvedValue(vault)
prismaMock.expense.update.mockResolvedValue(expense)
prismaMock.$transaction.mockResolvedValue([expense, vault])

const updateDto: UpdateExpenseDto = {
...expense,
status: ExpenseStatus.approved,
vaultId: vault.id,
}

await expect(controller.update(user, expense.id, updateDto)).rejects.toThrow()
//expect an exception
expect(prismaMock.expense.update).not.toHaveBeenCalled()
})

it('should not update a withdrawal, when it is already approved/cancelled', async () => {
const approvedExpense = {
...mockData[0],
Expand All @@ -160,15 +220,24 @@ describe('ExpensesController', () => {
amount: 1000,
blockedAmount: 350,
}

const user = {
sub: '00000000-0000-0000-0000-000000000012',
}

prismaMock.vault.findFirst.mockResolvedValue(vault)
prismaMock.expense.findFirst.mockResolvedValueOnce(approvedExpense)
prismaMock.expense.findFirst.mockResolvedValueOnce(cancelledExpense)

const updateDto: UpdateExpenseDto = { ...approvedExpense, status: ExpenseStatus.approved }
const updateDto: UpdateExpenseDto = {
...approvedExpense,
status: ExpenseStatus.approved,
vaultId: vault.id,
}

// assert
await expect(controller.update(approvedExpense.id, updateDto)).rejects.toThrow()
await expect(controller.update(cancelledExpense.id, updateDto)).rejects.toThrow()
await expect(controller.update(user, approvedExpense.id, updateDto)).rejects.toThrow()
await expect(controller.update(user, cancelledExpense.id, updateDto)).rejects.toThrow()
expect(prismaMock.expense.update).not.toHaveBeenCalled()
expect(prismaMock.vault.update).not.toHaveBeenCalled()
})
Expand Down
Loading

0 comments on commit f499978

Please sign in to comment.