• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GEWIS / sudosos-backend / 25753937432

12 May 2026 09:17AM UTC coverage: 88.117% (-1.0%) from 89.089%
25753937432

push

github

web-flow
chore(deps): fix missing dependencies for running docs:dev (#911)

3925 of 4574 branches covered (85.81%)

Branch coverage included in aggregate %.

20093 of 22683 relevant lines covered (88.58%)

1125.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.99
/src/controller/debtor-controller.ts
1
/**
1✔
2
 *  SudoSOS back-end API service.
3
 *  Copyright (C) 2026 Study association GEWIS
4
 *
5
 *  This program is free software: you can redistribute it and/or modify
6
 *  it under the terms of the GNU Affero General Public License as published
7
 *  by the Free Software Foundation, either version 3 of the License, or
8
 *  (at your option) any later version.
9
 *
10
 *  This program is distributed in the hope that it will be useful,
11
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 *  GNU Affero General Public License for more details.
14
 *
15
 *  You should have received a copy of the GNU Affero General Public License
16
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
17
 *
18
 *  @license
19
 */
1✔
20

21
/**
1✔
22
 * This is the module page of debtor-controller.
23
 *
24
 * @module debtors
25
 */
1✔
26

27
import BaseController, { BaseControllerOptions } from './base-controller';
28
import { Response } from 'express';
29
import log4js, { Logger } from 'log4js';
30
import Policy from './policy';
31
import { RequestWithToken } from '../middleware/token-middleware';
32
import { parseRequestPagination, toResponse } from '../helpers/pagination';
33
import DebtorService from '../service/debtor-service';
34
import User from '../entity/user/user';
35
import { asArrayOfDates, asArrayOfUserTypes, asDate, asFromAndTillDate, asReturnFileType } from '../helpers/validators';
36
import { In } from 'typeorm';
37
import { HandoutFinesRequest } from './request/debtor-request';
38
import Fine from '../entity/fine/fine';
39
import { ReturnFileType } from 'pdf-generator-client';
40
import { PdfError } from '../errors';
41
import FineHandoutEvent from '../entity/fine/fineHandoutEvent';
42

43
export default class DebtorController extends BaseController {
1✔
44
  private logger: Logger = log4js.getLogger(' DebtorController');
1✔
45

46
  public constructor(options: BaseControllerOptions) {
1✔
47
    super(options);
2✔
48
    this.configureLogger(this.logger);
2✔
49
  }
2✔
50

51
  public getPolicy(): Policy {
1✔
52
    return {
2✔
53
      '/': {
2✔
54
        GET: {
2✔
55
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
56
          handler: this.returnAllFineHandoutEvents.bind(this),
2✔
57
        },
2✔
58
      },
2✔
59
      '/:id(\\d+)': {
2✔
60
        GET: {
2✔
61
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
62
          handler: this.returnSingleFineHandoutEvent.bind(this),
2✔
63
        },
2✔
64
      },
2✔
65
      '/single/:id(\\d+)': {
2✔
66
        DELETE: {
2✔
67
          policy: async (req) => this.roleManager.can(req.token.roles, 'delete', 'all', 'Fine', ['*']),
2✔
68
          handler: this.deleteFine.bind(this),
2✔
69
        },
2✔
70
      },
2✔
71
      '/eligible': {
2✔
72
        GET: {
2✔
73
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
74
          handler: this.calculateFines.bind(this),
2✔
75
        },
2✔
76
      },
2✔
77
      '/handout': {
2✔
78
        POST: {
2✔
79
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', 'all', 'Fine', ['*']),
2✔
80
          handler: this.handoutFines.bind(this),
2✔
81
          body: { modelName: 'HandoutFinesRequest' },
2✔
82
        },
2✔
83
      },
2✔
84
      '/handout/:id(\\d+)': {
2✔
85
        DELETE: {
2✔
86
          policy: async (req) => this.roleManager.can(req.token.roles, 'delete', 'all', 'Fine', ['*']),
2✔
87
          handler: this.deleteFineHandout.bind(this),
2✔
88
        },
2✔
89
      },
2✔
90
      '/notify': {
2✔
91
        POST: {
2✔
92
          policy: async (req) => this.roleManager.can(req.token.roles, 'notify', 'all', 'Fine', ['*']),
2✔
93
          handler: this.notifyAboutFutureFines.bind(this),
2✔
94
          body: { modelName: 'HandoutFinesRequest' },
2✔
95
        },
2✔
96
      },
2✔
97
      '/report': {
2✔
98
        GET: {
2✔
99
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
100
          handler: this.getFineReport.bind(this),
2✔
101
        },
2✔
102
      },
2✔
103
      '/report/pdf': {
2✔
104
        GET: {
2✔
105
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
106
          handler: this.getFineReportPdf.bind(this),
2✔
107
        },
2✔
108
      },
2✔
109
    };
2✔
110
  }
2✔
111

112
  /**
1✔
113
   * GET /fines
114
   * @summary Get all fine handout events
115
   * @tags debtors - Operations of the debtor controller
116
   * @operationId returnAllFineHandoutEvents
117
   * @security JWT
118
   * @param {integer} take.query - How many entries the endpoint should return
119
   * @param {integer} skip.query - How many entries should be skipped (for pagination)
120
   * @return {PaginatedFineHandoutEventResponse} 200 - All existing fine handout events
121
   * @return {string} 400 - Validation error
122
   * @return {string} 500 - Internal server error
123
   */
1✔
124
  public async returnAllFineHandoutEvents(req: RequestWithToken, res: Response): Promise<void> {
1✔
125
    this.logger.trace('Get all fine handout events by ', req.token.user);
2✔
126

127
    let take;
2✔
128
    let skip;
2✔
129
    try {
2✔
130
      const pagination = parseRequestPagination(req);
2✔
131
      take = pagination.take;
2✔
132
      skip = pagination.skip;
2✔
133
    } catch (e) {
2!
134
      res.status(400).json(e.message);
×
135
      return;
×
136
    }
×
137

138
    try {
2✔
139
      const [events, count] = await new DebtorService().getFineHandoutEvents({ take, skip });
2✔
140
      const records = events.map((e) => DebtorService.asBaseFineHandoutEventResponse(e));
2✔
141
      res.json(toResponse(records, count, { take, skip }));
2✔
142
    } catch (error) {
2!
143
      this.logger.error('Could not return all fine handout event:', error);
×
144
      res.status(500).json('Internal server error.');
×
145
    }
×
146
  }
2✔
147

148
  /**
1✔
149
   * GET /fines/{id}
150
   * @summary Get all fine handout events
151
   * @tags debtors - Operations of the debtor controller
152
   * @operationId returnSingleFineHandoutEvent
153
   * @security JWT
154
   * @param {integer} id.path.required - The id of the fine handout event which should be returned
155
   * @return {FineHandoutEventResponse} 200 - Requested fine handout event with corresponding fines
156
   * @return {string} 400 - Validation error
157
   * @return {string} 500 - Internal server error
158
   */
1✔
159
  public async returnSingleFineHandoutEvent(req: RequestWithToken, res: Response): Promise<void> {
1✔
160
    const { id } = req.params;
2✔
161
    this.logger.trace('Get fine handout event', id, 'by', req.token.user);
2✔
162

163
    try {
2✔
164
      const event = await new DebtorService().getSingleFineHandoutEvent(Number.parseInt(id, 10));
2✔
165
      if (!event) {
2!
166
        res.status(404).json('Fine handout event not found.');
×
167
        return;
×
168
      }
×
169
      res.json(DebtorService.asFineHandoutEventResponse(event));
2✔
170
    } catch (error) {
2!
171
      this.logger.error('Could not return fine handout event:', error);
×
172
      res.status(500).json('Internal server error.');
×
173
    }
×
174
  }
2✔
175

176
  /**
1✔
177
   * DELETE /fines/single/{id}
178
   * @summary Delete a fine
179
   * @tags debtors - Operations of the debtor controller
180
   * @operationId deleteFine
181
   * @security JWT
182
   * @param {integer} id.path.required - The id of the fine which should be deleted
183
   * @return 204 - Success
184
   * @return {string} 400 - Validation error
185
   * @return {string} 500 - Internal server error
186
   */
1✔
187
  public async deleteFine(req: RequestWithToken, res: Response): Promise<void> {
1✔
188
    const { id } = req.params;
2✔
189
    this.logger.trace('Delete fine', id, 'by', req.token.user);
2✔
190

191
    try {
2✔
192
      const parsedId = Number.parseInt(id, 10);
2✔
193
      const fine = await Fine.findOne({ where: { id: parsedId } });
2✔
194
      if (fine == null) {
2✔
195
        res.status(404).send();
1✔
196
        return;
1✔
197
      }
1✔
198

199
      await new DebtorService().deleteFine(parsedId);
1✔
200
      res.status(204).send();
1✔
201
    } catch (error) {
2!
202
      this.logger.error('Could not return fine handout event:', error);
×
203
      res.status(500).json('Internal server error.');
×
204
    }
×
205
  }
2✔
206

207
  /**
1✔
208
   * GET /fines/eligible
209
   * @summary Return all users that had at most -5 euros balance both now and on the reference date.
210
   *    For all these users, also return their fine based on the reference date.
211
   * @tags debtors - Operations of the debtor controller
212
   * @operationId calculateFines
213
   * @security JWT
214
   * @param {Array<string>} userTypes.query - List of all user types fines should be calculated for (MEMBER, ORGAN, VOUCHER, LOCAL_USER, LOCAL_ADMIN, INVOICE, AUTOMATIC_INVOICE).
215
   * @param {Array<string>} referenceDates.query.required - Dates to base the fines on. Every returned user has at
216
   *    least five euros debt on every reference date. The height of the fine is based on the first date in the array.
217
   * @return {Array<UserToFineResponse>} 200 - List of eligible fines
218
   * @return {string} 400 - Validation error
219
   * @return {string} 500 - Internal server error
220
   */
1✔
221
  public async calculateFines(req: RequestWithToken, res: Response): Promise<void> {
1✔
222
    this.logger.trace('Get all possible fines by ', req.token.user);
6✔
223

224
    let params;
6✔
225
    try {
6✔
226
      if (req.query.referenceDates === undefined) throw new Error('referenceDates is required');
6✔
227
      const referenceDates = asArrayOfDates(req.query.referenceDates);
3✔
228
      if (referenceDates === undefined) throw new Error('referenceDates is not a valid array');
6!
229
      params = {
3✔
230
        userTypes: asArrayOfUserTypes(req.query.userTypes),
3✔
231
        referenceDates,
3✔
232
      };
3✔
233
      if (params.userTypes === undefined && req.query.userTypes !== undefined) throw new Error('userTypes is not a valid array of UserTypes');
6!
234
    } catch (e) {
6✔
235
      res.status(400).json(e.message);
3✔
236
      return;
3✔
237
    }
3✔
238

239
    try {
3✔
240
      res.json(await new DebtorService().calculateFinesOnDate(params));
3✔
241
    } catch (error) {
6!
242
      this.logger.error('Could not calculate fines:', error);
×
243
      res.status(500).json('Internal server error.');
×
244
    }
×
245
  }
6✔
246

247
  /**
1✔
248
   * POST /fines/handout
249
   * @summary Handout fines to all given users. Fines will be handed out "now" to prevent rewriting history.
250
   * @tags debtors - Operations of the debtor controller
251
   * @operationId handoutFines
252
   * @security JWT
253
   * @param {HandoutFinesRequest} request.body.required
254
   * @return {FineHandoutEventResponse} 200 - Created fine handout event with corresponding fines
255
   * @return {string} 400 - Validation error
256
   * @return {string} 500 - Internal server error
257
   */
1✔
258
  public async handoutFines(req: RequestWithToken, res: Response): Promise<void> {
1✔
259
    const body = req.body as HandoutFinesRequest;
4✔
260
    this.logger.trace('Handout fines', body, 'by user', req.token.user);
4✔
261

262
    let referenceDate: Date;
4✔
263
    try {
4✔
264
      // Todo: write code-consistent validator (either /src/controller/request/validators or custom validator.js function)
4✔
265
      if (!Array.isArray(body.userIds)) throw new Error('userIds is not an array');
4!
266
      const users = await User.find({ where: { id: In(body.userIds) } });
4✔
267
      if (users.length !== body.userIds.length) throw new Error('userIds is not a valid array of user IDs');
4!
268

269
      if (body.referenceDate !== undefined) {
4✔
270
        referenceDate = asDate(body.referenceDate);
4✔
271
      }
4✔
272
    } catch (e) {
4✔
273
      res.status(400).json(e.message);
1✔
274
      return;
1✔
275
    }
1✔
276

277
    try {
3✔
278
      const event = await new DebtorService().handOutFines({ referenceDate, userIds: body.userIds }, req.token.user);
3✔
279
      res.json(DebtorService.asFineHandoutEventResponse(event));
3✔
280
    } catch (error) {
4!
281
      this.logger.error('Could not handout fines:', error);
×
282
      res.status(500).json('Internal server error.');
×
283
    }
×
284
  }
4✔
285

286
  /**
1✔
287
   * DELETE /fines/handout/{id}
288
   * @summary Delete a fine handout event
289
   * @tags debtors - Operations of the debtor controller
290
   * @operationId deleteFineHandout
291
   * @security JWT
292
   * @param {integer} id.path.required - The id of the fine handout event which should be deleted
293
   * @return 204 - Success
294
   * @return {string} 400 - Validation error
295
   * @return {string} 404 - Not found error
296
   * @return {string} 500 - Internal server error
297
   */
1✔
298
  public async deleteFineHandout(req: RequestWithToken, res: Response): Promise<void> {
1✔
299
    const { id } = req.params;
2✔
300
    this.logger.trace('Delete fine handout', id, 'by', req.token.user);
2✔
301

302
    try {
2✔
303
      const parsedId = Number.parseInt(id, 10);
2✔
304
      const event = await FineHandoutEvent.findOne({ where: { id: parsedId }, relations: ['fines'] });
2✔
305
      if (event == null) {
2✔
306
        res.status(404).send();
1✔
307
        return;
1✔
308
      }
1✔
309

310
      await new DebtorService().deleteFineHandout(event);
1✔
311
      res.status(204).send();
1✔
312
    } catch (error) {
2!
313
      this.logger.error('Could not delete fine handout:', error);
×
314
      res.status(500).json('Internal server error.');
×
315
    }
×
316
  }
2✔
317

318
  /**
1✔
319
   * POST /fines/notify
320
   * @summary Send an email to all given users about their possible future fine.
321
   * @tags debtors - Operations of the debtor controller
322
   * @operationId notifyAboutFutureFines
323
   * @security JWT
324
   * @param {HandoutFinesRequest} request.body.required
325
   * @return 204 - Success
326
   * @return {string} 400 - Validation error
327
   * @return {string} 500 - Internal server error
328
   */
1✔
329
  public async notifyAboutFutureFines(req: RequestWithToken, res: Response): Promise<void> {
1✔
330
    const body = req.body as HandoutFinesRequest;
2✔
331
    this.logger.trace('Send future fine notification emails', body, 'by user', req.token.user);
2✔
332

333
    let referenceDate: Date;
2✔
334
    try {
2✔
335
      // Todo: write code-consistent validator (either /src/controller/request/validators or custom validator.js function)
2✔
336
      if (!Array.isArray(body.userIds)) throw new Error('userIds is not an array');
2!
337
      const users = await User.find({ where: { id: In(body.userIds) } });
2✔
338
      if (users.length !== body.userIds.length) throw new Error('userIds is not a valid array of user IDs');
2!
339

340
      if (body.referenceDate !== undefined) {
2✔
341
        referenceDate = asDate(body.referenceDate);
2✔
342
      }
2✔
343
    } catch (e) {
2✔
344
      res.status(400).json(e.message);
1✔
345
      return;
1✔
346
    }
1✔
347

348
    try {
1✔
349
      await new DebtorService().sendFineWarnings({ referenceDate, userIds: body.userIds });
1✔
350
      res.status(204).send();
1✔
351
    } catch (error) {
2!
352
      this.logger.error('Could not send future fine notification emails:', error);
×
353
      res.status(500).json('Internal server error.');
×
354
    }
×
355
  }
2✔
356

357
  /**
1✔
358
   * GET /fines/report
359
   * @summary Get a report of all fines
360
   * @tags debtors - Operations of the debtor controller
361
   * @operationId getFineReport
362
   * @security JWT
363
   * @param {string} fromDate.query - The start date of the report, inclusive
364
   * @param {string} toDate.query - The end date of the report, exclusive
365
   * @return {FineReportResponse} 200 - The requested report
366
   * @return {string} 400 - Validation error
367
   * @return {string} 500 - Internal server error
368
   */
1✔
369
  public async getFineReport(req: RequestWithToken, res: Response): Promise<void> {
1✔
370
    this.logger.trace('Get fine report by ', req.token.user);
5✔
371

372
    let fromDate, toDate;
5✔
373
    try {
5✔
374
      const filters = asFromAndTillDate(req.query.fromDate, req.query.toDate);
5✔
375
      fromDate = filters.fromDate;
5✔
376
      toDate = filters.tillDate;
5✔
377
    } catch (e) {
5✔
378
      res.status(400).json(e.message);
3✔
379
      return;
3✔
380
    }
3✔
381

382
    try {
2✔
383
      const report = await new DebtorService().getFineReport(fromDate, toDate);
2✔
384
      res.json(report.toResponse());
2✔
385
    } catch (error) {
5!
386
      this.logger.error('Could not get fine report:', error);
×
387
      res.status(500).json('Internal server error.');
×
388
    }
×
389
  }
5✔
390

391
  /**
1✔
392
   * GET /fines/report/pdf
393
   * @summary Get a report of all fines in pdf format
394
   * @tags debtors - Operations of the debtor controller
395
   * @operationId getFineReportPdf
396
   * @security JWT
397
   * @param {string} fromDate.query.required - The start date of the report, inclusive
398
   * @param {string} toDate.query.required - The end date of the report, exclusive
399
   * @param {string} fileType.query.required - enum:PDF,TEX - The file type of the report
400
   * @returns {string} 200 - The requested report - application/pdf
401
   * @return {string} 400 - Validation error
402
   * @return {string} 500 - Internal server error
403
   */
1✔
404
  public async getFineReportPdf(req: RequestWithToken, res: Response): Promise<void> {
1✔
405
    this.logger.trace('Get fine report by ', req.token.user);
5✔
406

407
    let fromDate, toDate;
5✔
408
    let fileType: ReturnFileType;
5✔
409
    try {
5✔
410
      const filters = asFromAndTillDate(req.query.fromDate, req.query.toDate);
5✔
411
      fromDate = filters.fromDate;
5✔
412
      toDate = filters.tillDate;
5✔
413
      fileType = asReturnFileType(req.query.fileType);
5✔
414
    } catch (e) {
5✔
415
      res.status(400).json(e.message);
3✔
416
      return;
3✔
417
    }
3✔
418

419
    try {
2✔
420
      const report = await new DebtorService().getFineReport(fromDate, toDate);
2✔
421

422
      const buffer = fileType === 'PDF' ? await report.createPdf() : await report.createRaw();
5!
423
      const from = `${fromDate.getFullYear()}${fromDate.getMonth() + 1}${fromDate.getDate()}`;
×
424
      const to = `${toDate.getFullYear()}${toDate.getMonth() + 1}${toDate.getDate()}`;
×
425
      const fileName = `fine-report-${from}-${to}.${fileType}`;
×
426

427
      res.setHeader('Content-Type', 'application/pdf+tex');
×
428
      res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
×
429
      res.send(buffer);
×
430
    } catch (error) {
5✔
431
      this.logger.error('Could not get fine report pdf:', error);
1✔
432
      if (error instanceof PdfError) {
1✔
433
        res.status(502).json('PDF Generator service failed.');
1✔
434
        return;
1✔
435
      }
1!
436
      res.status(500).json('Internal server error.');
×
437
    }
×
438
  }
5✔
439

440
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc