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

GEWIS / sudosos-backend / 27075058909

06 Jun 2026 10:00PM UTC coverage: 92.002% (+0.05%) from 91.956%
27075058909

Pull #946

github

web-flow
Merge d4e012aee into a15b7feab
Pull Request #946: Release: update `main` from `develop`

4195 of 4793 branches covered (87.52%)

Branch coverage included in aggregate %.

552 of 570 new or added lines in 45 files covered. (96.84%)

2 existing lines in 1 file now uncovered.

21585 of 23228 relevant lines covered (92.93%)

847.65 hits per line

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

77.64
/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
 * @module payout-requests
23
 */
1✔
24

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

42
/**
1✔
43
 * Controller for the `/payoutrequests` endpoints in the
44
 * {@link payout-requests | payout-requests} module. Creation is open to members for their
45
 * own balance; status transitions go through
46
 * {@link PayoutRequestService.canUpdateStatus | canUpdateStatus} so that only the
47
 * treasurer can move a request to a terminal state and the
48
 * {@link transfers!Transfer | Transfer} is created on `APPROVED`.
49
 */
1✔
50
export default class PayoutRequestController extends BaseController {
1✔
51
  private logger: Logger = log4js.getLogger('PayoutRequestController');
1✔
52

53
  public constructor(options: BaseControllerOptions) {
1✔
54
    super(options);
2✔
55
    this.configureLogger(this.logger);
2✔
56
  }
2✔
57

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

91
  static async getRelation(req: RequestWithToken): Promise<string> {
1✔
92
    if (req.body.forId != null) {
16✔
93
      if (req.body.forId == req.token.user.id) {
4✔
94
        return 'own';
1✔
95
      } else {
4✔
96
        return 'all';
3✔
97
      }
3✔
98
    }
4✔
99

100
    const { id } = req.params;
12✔
101
    const payoutRequest = await PayoutRequest.findOne({ where: { id: parseInt(id, 10) }, relations: {
12✔
102
      requestedBy: true,
12✔
103
    } });
12✔
104
    return (payoutRequest != null && payoutRequest.requestedBy.id === req.token.user.id) ? 'own' : 'all';
16✔
105
  }
16✔
106

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

129
    let filters;
17✔
130
    let pagination;
17✔
131
    try {
17✔
132
      filters = parseGetPayoutRequestsFilters(req);
17✔
133
      pagination = parseRequestPagination(req);
17✔
134
    } catch (e) {
17✔
135
      res.status(400).send(e.message);
5✔
136
      return;
5✔
137
    }
5✔
138

139
    try {
12✔
140
      const [data, count] = await PayoutRequestService.getPayoutRequests(filters, pagination);
12✔
141
      const records = data.map((o) => PayoutRequestService.asBasePayoutRequestResponse(o));
12✔
142
      res.status(200).json(toResponse(records, count, pagination));
12✔
143
    } catch (e) {
17!
144
      res.status(500).send('Internal server error.');
×
145
      this.logger.error(e);
×
146
    }
×
147
  }
17✔
148

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

163
    let payoutRequest;
3✔
164
    try {
3✔
165
      payoutRequest = await PayoutRequestService
3✔
166
        .getSinglePayoutRequest(parseInt(parameters.id, 10));
3✔
167
    } catch (e) {
3!
168
      res.status(500).send();
×
169
      this.logger.error(e);
×
170
      return;
×
171
    }
×
172

173
    if (payoutRequest === undefined) {
3✔
174
      res.status(404).json('Unknown payout request ID.');
1✔
175
      return;
1✔
176
    }
1✔
177

178
    res.status(200).json(PayoutRequestService.asPayoutRequestResponse(payoutRequest));
2✔
179
  }
2✔
180

181
  /**
1✔
182
   * POST /payoutrequests
183
   * @summary Create a new payout request
184
   * @operationId createPayoutRequest
185
   * @tags payoutRequests - Operations of the payout request controller
186
   * @param {PayoutRequestRequest} request.body.required - New payout request
187
   * @security JWT
188
   * @return {PayoutRequestResponse} 200 - The created payout request.
189
   * @return {string} 400 - Validation error
190
   * @return {string} 400 - User does not exist
191
   */
1✔
192
  public async createPayoutRequest(req: RequestWithToken, res: Response): Promise<void> {
1✔
193
    const body = req.body as PayoutRequestRequest;
3✔
194
    this.logger.trace('Create payout request by user', req.token.user);
3✔
195

196
    try {
3✔
197
      const user = await User.findOne({ where: { id: body.forId } });
3✔
198
      if (!user) {
3✔
199
        res.status(400).json('Unknown user ID.');
1✔
200
        return;
1✔
201
      }
1✔
202

203
      const balance = await new BalanceService().getBalance(user.id);
2✔
204
      if (balance.amount.amount < body.amount.amount) {
3!
205
        res.status(400).json('Insufficient balance.');
×
206
        return;
×
207
      }
✔
208

209
      const payoutRequest = await PayoutRequestService.createPayoutRequest(body, user);
2✔
210
      res.status(200).json(PayoutRequestService.asPayoutRequestResponse(payoutRequest));
2✔
211
    } catch (e) {
3!
212
      res.status(500).send();
×
213
      this.logger.error(e);
×
214
    }
×
215
  }
3✔
216

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

234
    const id = parseInt(parameters.id, 10);
5✔
235

236
    // Check if payout request exists
5✔
237
    let payoutRequest;
5✔
238
    try {
5✔
239
      payoutRequest = await PayoutRequestService.getSinglePayoutRequest(id);
5✔
240
    } catch (e) {
5!
241
      res.status(500).send();
×
242
      this.logger.error(e);
×
243
      return;
×
244
    }
×
245

246
    if (payoutRequest === undefined) {
5✔
247
      res.status(404).json('Unknown payout request ID.');
1✔
248
      return;
1✔
249
    }
1✔
250

251
    // Everyone can cancel their own payout requests, but only admins can update to other states.
4✔
252
    if (body.state !== PayoutRequestState.CANCELLED) {
5✔
253
      if (!this.roleManager.can(req.token.roles, 'update', 'all', 'PayoutRequest', ['*'])) {
3!
254
        res.status(403).send('You can only cancel your own payout requests.');
×
255
        return;
×
256
      }
×
257
    } else if (payoutRequest.requestedBy.id !== req.token.user.id) {
5✔
258
      res.status(403).send('You can only cancel your own payout requests.');
1✔
259
      return;
1✔
260
    }
1✔
261

262
    if (body.state === PayoutRequestState.APPROVED) {
5✔
263
      const balance = await new BalanceService().getBalance(payoutRequest.requestedBy.id);
2✔
264
      if (balance.amount.amount < payoutRequest.amount.getAmount()) {
2✔
265
        res.status(400).json('Insufficient balance.');
1✔
266
        return;
1✔
267
      }
1✔
268
    }
2✔
269

270
    // Verify validity of new status
2✔
271
    try {
2✔
272
      await PayoutRequestService.canUpdateStatus(id, body.state);
2✔
273
    } catch (e) {
1✔
274
      res.status(400).json(e);
1✔
275
      return;
1✔
276
    }
1✔
277

278
    // Execute
1✔
279
    try {
1✔
280
      const updatedPayoutRequest = await PayoutRequestService.updateStatus(id, body.state, req.token.user);
1✔
281
      res.status(200).json(PayoutRequestService.asPayoutRequestResponse(updatedPayoutRequest));
1✔
282
    } catch (e) {
5!
283
      res.status(500).send();
×
284
      this.logger.error(e);
×
285
    }
×
286
  }
5✔
287

288

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

307
    try {
×
308
      const force = !!asBoolean(req.query.force);
×
NEW
309
      const payoutRequest = await PayoutRequest.findOne({ where: { id: payoutRequestId }, relations: {
×
NEW
310
        requestedBy: true,
×
NEW
311
        approvedBy: true,
×
NEW
312
        payoutRequestStatus: true,
×
NEW
313
      } });
×
314
      if (!payoutRequest) {
×
315
        res.status(404).json('Unknown payout request ID.');
×
316
        return;
×
317
      }
×
318

319
      const pdf = await payoutRequest.getOrCreatePdf(force);
×
320

321
      res.status(200).json({ pdf: pdf.downloadName } as PdfUrlResponse);
×
322
    } catch (error) {
×
323
      this.logger.error('Could get payout request PDF:', error);
×
324
      if (error instanceof PdfError) {
×
325
        res.status(502).json('PDF Generator service failed.');
×
326
        return;
×
327
      }
×
328
      res.status(500).json('Internal server error.');
×
329
    }
×
330
  }
×
331
}
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