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

GEWIS / sudosos-backend / 25162857296

30 Apr 2026 11:28AM UTC coverage: 88.101% (-1.0%) from 89.114%
25162857296

push

github

web-flow
chore: migrate test runner from Mocha to Vitest (#884)

3922 of 4572 branches covered (85.78%)

Branch coverage included in aggregate %.

16 of 16 new or added lines in 12 files covered. (100.0%)

793 existing lines in 111 files now uncovered.

20090 of 22683 relevant lines covered (88.57%)

1125.84 hits per line

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

76.69
/src/controller/payout-request-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 payout-request-controller.
23
 *
24
 * @module payout-requests
25
 */
1✔
26

27
import { Response } from 'express';
28
import log4js, { Logger } from 'log4js';
29
import BaseController, { BaseControllerOptions } from './base-controller';
30
import Policy from './policy';
31
import { RequestWithToken } from '../middleware/token-middleware';
32
import { parseRequestPagination, toResponse } from '../helpers/pagination';
33
import PayoutRequestService, { parseGetPayoutRequestsFilters } from '../service/payout-request-service';
34
import { PayoutRequestStatusRequest } from './request/payout-request-status-request';
35
import PayoutRequest from '../entity/transactions/payout/payout-request';
36
import { PayoutRequestState } from '../entity/transactions/payout/payout-request-status';
37
import PayoutRequestRequest from './request/payout-request-request';
38
import User from '../entity/user/user';
39
import BalanceService from '../service/balance-service';
40
import { PdfUrlResponse } from './response/simple-file-response';
41
import { PdfError } from '../errors';
42
import { asBoolean } from '../helpers/validators';
43

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

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

52
  public getPolicy(): Policy {
1✔
53
    return {
2✔
54
      '/': {
2✔
55
        GET: {
2✔
56
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'PayoutRequest', ['*']),
2✔
57
          handler: this.returnAllPayoutRequests.bind(this),
2✔
58
        },
2✔
59
        POST: {
2✔
60
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', await PayoutRequestController.getRelation(req), 'PayoutRequest', ['*']),
2✔
61
          handler: this.createPayoutRequest.bind(this),
2✔
62
        },
2✔
63
      },
2✔
64
      '/:id(\\d+)': {
2✔
65
        GET: {
2✔
66
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', await PayoutRequestController.getRelation(req), 'PayoutRequest', ['*']),
2✔
67
          handler: this.returnSinglePayoutRequest.bind(this),
2✔
68
        },
2✔
69
      },
2✔
70
      '/:id(\\d+)/pdf': {
2✔
71
        GET: {
2✔
72
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', await PayoutRequestController.getRelation(req), 'PayoutRequest', ['*']),
2✔
73
          handler: this.getPayoutRequestPdf.bind(this),
2✔
74
        },
2✔
75
      },
2✔
76
      '/:id(\\d+)/status': {
2✔
77
        POST: {
2✔
78
          policy: async (req) => this.roleManager.can(req.token.roles, 'update', await PayoutRequestController.getRelation(req), 'PayoutRequest', ['*']),
2✔
79
          handler: this.updatePayoutRequestStatus.bind(this),
2✔
80
        },
2✔
81
      },
2✔
82
    };
2✔
83
  }
2✔
84

85
  static async getRelation(req: RequestWithToken): Promise<string> {
1✔
86
    if (req.body.forId != null) {
15✔
87
      if (req.body.forId == req.token.user.id) {
3✔
88
        return 'own';
1✔
89
      } else {
3✔
90
        return 'all';
2✔
91
      }
2✔
92
    }
3✔
93

94
    const { id } = req.params;
12✔
95
    const payoutRequest = await PayoutRequest.findOne({ where: { id: parseInt(id, 10) }, relations: ['requestedBy'] });
12✔
96
    return (payoutRequest != null && payoutRequest.requestedBy.id === req.token.user.id) ? 'own' : 'all';
15✔
97
  }
15✔
98

99
  /**
1✔
100
   * GET /payoutrequests
101
   * @summary Returns all payout requests given the filter parameters
102
   * @operationId getAllPayoutRequests
103
   * @tags payoutRequests - Operations of the payout request controller
104
   * @security JWT
105
   * @param {integer | Array<integer>} requestedById.query - ID of user(s) who requested a payout
106
   * @param {integer | Array<integer>} approvedById.query - ID of user(s) who approved a payout
107
   * @param {string} fromDate.query - Start date for selected transactions (inclusive)
108
   * @param {string} tillDate.query - End date for selected transactions (exclusive)
109
   * @param {string} status.query - Status of the payout requests (OR relation)
110
   * @array
111
   * @items.type {string}
112
   * @param {integer} take.query - How many payout requests the endpoint should return
113
   * @param {integer} skip.query - How many payout requests should be skipped (for pagination)
114
   * @return {PaginatedBasePayoutRequestResponse} 200 - All existing payout requests
115
   * @return {string} 400 - Validation error
116
   * @return {string} 500 - Internal server error
117
   */
1✔
118
  public async returnAllPayoutRequests(req: RequestWithToken, res: Response): Promise<void> {
1✔
119
    this.logger.trace('Get all payout requests by user', req.token.user);
17✔
120

121
    let filters;
17✔
122
    let pagination;
17✔
123
    try {
17✔
124
      filters = parseGetPayoutRequestsFilters(req);
17✔
125
      pagination = parseRequestPagination(req);
17✔
126
    } catch (e) {
17✔
127
      res.status(400).send(e.message);
5✔
128
      return;
5✔
129
    }
5✔
130

131
    try {
12✔
132
      const [data, count] = await PayoutRequestService.getPayoutRequests(filters, pagination);
12✔
133
      const records = data.map((o) => PayoutRequestService.asBasePayoutRequestResponse(o));
12✔
134
      res.status(200).json(toResponse(records, count, pagination));
12✔
135
    } catch (e) {
17!
136
      res.status(500).send('Internal server error.');
×
137
      this.logger.error(e);
×
UNCOV
138
    }
×
139
  }
17✔
140

141
  /**
1✔
142
   * GET /payoutrequests/{id}
143
   * @summary Get a single payout request
144
   * @operationId getSinglePayoutRequest
145
   * @tags payoutRequests - Operations of the payout request controller
146
   * @param {integer} id.path.required - The ID of the payout request object that should be returned
147
   * @security JWT
148
   * @return {PayoutRequestResponse} 200 - Single payout request with given id
149
   * @return {string} 404 - Nonexistent payout request id
150
   */
1✔
151
  public async returnSinglePayoutRequest(req: RequestWithToken, res: Response): Promise<void> {
1✔
152
    const parameters = req.params;
3✔
153
    this.logger.trace('Get single payout request', parameters, 'by user', req.token.user);
3✔
154

155
    let payoutRequest;
3✔
156
    try {
3✔
157
      payoutRequest = await PayoutRequestService
3✔
158
        .getSinglePayoutRequest(parseInt(parameters.id, 10));
3✔
159
    } catch (e) {
3!
160
      res.status(500).send();
×
161
      this.logger.error(e);
×
162
      return;
×
UNCOV
163
    }
×
164

165
    if (payoutRequest === undefined) {
3✔
166
      res.status(404).json('Unknown payout request ID.');
1✔
167
      return;
1✔
168
    }
1✔
169

170
    res.status(200).json(PayoutRequestService.asPayoutRequestResponse(payoutRequest));
2✔
171
  }
2✔
172

173
  /**
1✔
174
   * POST /payoutrequests
175
   * @summary Create a new payout request
176
   * @operationId createPayoutRequest
177
   * @tags payoutRequests - Operations of the payout request controller
178
   * @param {PayoutRequestRequest} request.body.required - New payout request
179
   * @security JWT
180
   * @return {PayoutRequestResponse} 200 - The created payout request.
181
   * @return {string} 400 - Validation error
182
   */
1✔
183
  public async createPayoutRequest(req: RequestWithToken, res: Response): Promise<void> {
1✔
184
    const body = req.body as PayoutRequestRequest;
2✔
185
    this.logger.trace('Create payout request by user', req.token.user);
2✔
186

187
    try {
2✔
188
      const user = await User.findOne({ where: { id: body.forId } });
2✔
189
      if (user === undefined) {
2!
190
        res.status(404).json('Unknown user ID.');
×
191
        return;
×
UNCOV
192
      }
×
193

194
      const balance = await new BalanceService().getBalance(user.id);
2✔
195
      if (balance.amount.amount < body.amount.amount) {
2!
196
        res.status(400).json('Insufficient balance.');
×
197
        return;
×
UNCOV
198
      }
×
199

200
      const payoutRequest = await PayoutRequestService.createPayoutRequest(body, user);
2✔
201
      res.status(200).json(PayoutRequestService.asPayoutRequestResponse(payoutRequest));
2✔
202
    } catch (e) {
2!
203
      res.status(500).send();
×
204
      this.logger.error(e);
×
UNCOV
205
    }
×
206
  }
2✔
207

208
  /**
1✔
209
   * POST /payoutrequests/{id}/status
210
   * @summary Create a new status for a payout request
211
   * @operationId setPayoutRequestStatus
212
   * @tags payoutRequests - Operations of the payout request controller
213
   * @param {integer} id.path.required - The ID of the payout request object that should be returned
214
   * @param {PayoutRequestStatusRequest} request.body.required - New state of payout request
215
   * @security JWT
216
   * @return {PayoutRequestResponse} 200 - The updated payout request
217
   * @return {string} 400 - Validation error
218
   * @return {string} 404 - Nonexistent payout request id
219
   */
1✔
220
  public async updatePayoutRequestStatus(req: RequestWithToken, res: Response): Promise<void> {
1✔
221
    const parameters = req.params;
5✔
222
    const body = req.body as PayoutRequestStatusRequest;
5✔
223
    this.logger.trace('Update single payout request status', parameters, 'by user', req.token.user);
5✔
224

225
    const id = parseInt(parameters.id, 10);
5✔
226

227
    // Check if payout request exists
5✔
228
    let payoutRequest;
5✔
229
    try {
5✔
230
      payoutRequest = await PayoutRequestService.getSinglePayoutRequest(id);
5✔
231
    } catch (e) {
5!
232
      res.status(500).send();
×
233
      this.logger.error(e);
×
234
      return;
×
UNCOV
235
    }
×
236

237
    if (payoutRequest === undefined) {
5✔
238
      res.status(404).json('Unknown payout request ID.');
1✔
239
      return;
1✔
240
    }
1✔
241

242
    // Everyone can cancel their own payout requests, but only admins can update to other states.
4✔
243
    if (body.state !== PayoutRequestState.CANCELLED) {
5✔
244
      if (!this.roleManager.can(req.token.roles, 'update', 'all', 'PayoutRequest', ['*'])) {
3!
245
        res.status(403).send('You can only cancel your own payout requests.');
×
246
        return;
×
UNCOV
247
      }
×
248
    } else if (payoutRequest.requestedBy.id !== req.token.user.id) {
5✔
249
      res.status(403).send('You can only cancel your own payout requests.');
1✔
250
      return;
1✔
251
    }
1✔
252

253
    if (body.state === PayoutRequestState.APPROVED) {
5✔
254
      const balance = await new BalanceService().getBalance(payoutRequest.requestedBy.id);
2✔
255
      if (balance.amount.amount < payoutRequest.amount.getAmount()) {
2✔
256
        res.status(400).json('Insufficient balance.');
1✔
257
        return;
1✔
258
      }
1✔
259
    }
2✔
260

261
    // Verify validity of new status
2✔
262
    try {
2✔
263
      await PayoutRequestService.canUpdateStatus(id, body.state);
2✔
264
    } catch (e) {
1✔
265
      res.status(400).json(e);
1✔
266
      return;
1✔
267
    }
1✔
268

269
    // Execute
1✔
270
    try {
1✔
271
      const updatedPayoutRequest = await PayoutRequestService.updateStatus(id, body.state, req.token.user);
1✔
272
      res.status(200).json(PayoutRequestService.asPayoutRequestResponse(updatedPayoutRequest));
1✔
273
    } catch (e) {
5!
274
      res.status(500).send();
×
275
      this.logger.error(e);
×
UNCOV
276
    }
×
277
  }
5✔
278

279

280
  /**
1✔
281
   * GET /payoutrequests/{id}/pdf
282
   * @summary Get a payout request pdf
283
   * @operationId getPayoutRequestPdf
284
   * @tags payoutRequests - Operations of the payout request controller
285
   * @security JWT
286
   * @param {integer} id.path.required - The ID of the payout request object that should be returned
287
   * @param {boolean} force.query - Whether to force regeneration of the pdf
288
   * @return {PdfUrlResponse} 200 - The pdf location information.
289
   * @return {string} 404 - Nonexistent payout request id
290
   * @return {string} 500 - Internal server error
291
   * @return {string} 502 - PDF generation failed
292
   */
1✔
293
  public async getPayoutRequestPdf(req: RequestWithToken, res: Response): Promise<void> {
1✔
294
    const { id } = req.params;
×
295
    const payoutRequestId = parseInt(id, 10);
×
296
    this.logger.trace('Get payout request pdf', id, 'by user', req.token.user);
×
297

298
    try {
×
299
      const force = !!asBoolean(req.query.force);
×
300
      const payoutRequest = await PayoutRequest.findOne({ where: { id: payoutRequestId }, relations: ['requestedBy', 'approvedBy', 'payoutRequestStatus'] });
×
301
      if (!payoutRequest) {
×
302
        res.status(404).json('Unknown payout request ID.');
×
303
        return;
×
UNCOV
304
      }
×
305

306
      const pdf = await payoutRequest.getOrCreatePdf(force);
×
307

308
      res.status(200).json({ pdf: pdf.downloadName } as PdfUrlResponse);
×
UNCOV
309
    } catch (error) {
×
310
      this.logger.error('Could get payout request PDF:', error);
×
311
      if (error instanceof PdfError) {
×
312
        res.status(502).json('PDF Generator service failed.');
×
313
        return;
×
UNCOV
314
      }
×
315
      res.status(500).json('Internal server error.');
×
UNCOV
316
    }
×
UNCOV
317
  }
×
318
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc