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

GEWIS / sudosos-backend / 27283082186

10 Jun 2026 02:13PM UTC coverage: 91.996% (+0.04%) from 91.956%
27283082186

push

github

web-flow
chore(deps): bump bullmq from 5.77.6 to 5.78.0 (#949)

Bumps [bullmq](https://github.com/taskforcesh/bullmq) from 5.77.6 to 5.78.0.
- [Release notes](https://github.com/taskforcesh/bullmq/releases)
- [Commits](https://github.com/taskforcesh/bullmq/compare/v5.77.6...v5.78.0)

---
updated-dependencies:
- dependency-name: bullmq
  dependency-version: 5.78.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

4199 of 4800 branches covered (87.48%)

Branch coverage included in aggregate %.

21593 of 23236 relevant lines covered (92.93%)

847.01 hits per line

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

90.69
/src/controller/transaction-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 the transaction-controller.
23
 *
24
 * @module transactions
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 TransactionService, {
33
  parseGetTransactionsFilters,
34
} from '../service/transaction-service';
35
import { parseRequestPagination, toResponse } from '../helpers/pagination';
36
import { TransactionRequest } from './request/transaction-request';
37
import Transaction from '../entity/transactions/transaction';
38
import { asNumber } from '../helpers/validators';
39
import userTokenInOrgan from '../helpers/token-helper';
40
import UserService from '../service/user-service';
41
import InvoiceService from '../service/invoice-service';
42
import POSTokenVerifier from '../helpers/pos-token-verifier';
43
import { PdfError } from '../errors';
44

45
/**
1✔
46
 * Controller for the `transactions` module. Exposes the buyer-facing CRUD for transactions,
47
 * a validate-before-create endpoint, the invoices-touching-this-transaction lookup, and a
48
 * PDF receipt. See the {@link transactions | module page} for how a transaction relates to
49
 * its sub-transactions.
50
 */
1✔
51
export default class TransactionController extends BaseController {
1✔
52
  private logger: Logger = log4js.getLogger('TransactionController');
1✔
53

54

55
  /**
1✔
56
   * Creates a new transaction controller instance.
57
   * @param options - The options passed to the base controller.
58
   */
1✔
59
  public constructor(options: BaseControllerOptions) {
1✔
60
    super(options);
5✔
61
    this.configureLogger(this.logger);
5✔
62
  }
5✔
63

64
  /**
1✔
65
   * @inheritDoc
66
   */
1✔
67
  public getPolicy(): Policy {
1✔
68
    return {
5✔
69
      '/': {
5✔
70
        GET: {
5✔
71
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', await TransactionController.filterRelation(req), 'Transaction', ['*']),
5✔
72
          handler: this.getAllTransactions.bind(this),
5✔
73
        },
5✔
74
        POST: {
5✔
75
          body: { modelName: 'TransactionRequest' },
5✔
76
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', await TransactionController.postRelation(req), 'Transaction', ['*']),
5✔
77
          handler: this.createTransaction.bind(this),
5✔
78
        },
5✔
79
      },
5✔
80
      '/:id(\\d+)': {
5✔
81
        GET: {
5✔
82
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', await TransactionController.getRelation(req), 'Transaction', ['*']),
5✔
83
          handler: this.getTransaction.bind(this),
5✔
84
        },
5✔
85
        PATCH: {
5✔
86
          body: { modelName: 'TransactionRequest' },
5✔
87
          policy: async (req) => this.roleManager.can(req.token.roles, 'update', await TransactionController.postRelation(req), 'Transaction', ['*']),
5✔
88
          handler: this.updateTransaction.bind(this),
5✔
89
          restrictions: { lesser: false },
5✔
90
        },
5✔
91
        DELETE: {
5✔
92
          policy: async (req) => this.roleManager.can(req.token.roles, 'delete', await TransactionController.getRelation(req), 'Transaction', ['*']),
5✔
93
          handler: this.deleteTransaction.bind(this),
5✔
94
        },
5✔
95
      },
5✔
96
      '/:id(\\d+)/pdf': {
5✔
97
        GET: {
5✔
98
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', await TransactionController.getRelation(req), 'Transaction', ['*']),
5✔
99
          handler: this.getTransactionPdf.bind(this),
5✔
100
        },
5✔
101
      },
5✔
102
      '/:id(\\d+)/invoices': {
5✔
103
        GET: {
5✔
104
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Invoice', ['*']),
5✔
105
          handler: this.getTransactionInvoices.bind(this),
5✔
106
        },
5✔
107
      },
5✔
108
      '/:validate': {
5✔
109
        POST: {
5✔
110
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', await TransactionController.postRelation(req), 'Transaction', ['*']),
5✔
111
          handler: this.validateTransaction.bind(this),
5✔
112
        },
5✔
113
      },
5✔
114
    };
5✔
115
  }
5✔
116

117
  /**
1✔
118
   * GET /transactions
119
   * @summary Get a list of all transactions
120
   * @operationId getAllTransactions
121
   * @tags transactions - Operations of the transaction controller
122
   * @security JWT
123
   * @param {integer} fromId.query - From-user for selected transactions
124
   * @param {integer} createdById.query - User that created selected transaction
125
   * @param {integer} toId.query - To-user for selected transactions
126
   * @param {integer} excludeById.query - Created by user to exclude from transactions
127
   * @param {integer} excludeFromId.query - From user to exclude from transactions
128
   * @param {integer} pointOfSaleId.query - Point of sale ID for selected transactions
129
   * @param {integer} productId.query - Product ID for selected transactions
130
   * @param {integer} productRevision.query - Product Revision for selected transactions. Requires ProductID
131
   * @param {string} fromDate.query - Start date for selected transactions (inclusive)
132
   * @param {string} tillDate.query - End date for selected transactions (exclusive)
133
   * @param {integer} take.query - How many transactions the endpoint should return
134
   * @param {integer} skip.query - How many transactions should be skipped (for pagination)
135
   * @return {PaginatedBaseTransactionResponse} 200 - A list of all transactions
136
   */
1✔
137
  public async getAllTransactions(req: RequestWithToken, res: Response): Promise<void> {
1✔
138
    this.logger.trace('Get all transactions by user', req.token.user);
30✔
139

140
    // Parse the filters given in the query parameters. If there are any issues,
30✔
141
    // the parse method will throw an exception. We will then return a 400 error.
30✔
142
    let filters;
30✔
143
    let take;
30✔
144
    let skip;
30✔
145
    try {
30✔
146
      filters = parseGetTransactionsFilters(req);
30✔
147
      const pagination = parseRequestPagination(req);
30✔
148
      take = pagination.take;
30✔
149
      skip = pagination.skip;
30✔
150
    } catch (e) {
30✔
151
      res.status(400).json(e.message);
11✔
152
      return;
11✔
153
    }
11✔
154

155
    try {
19✔
156
      const [records, count] = await new TransactionService().getTransactions(filters, { take, skip });
19✔
157
      res.status(200).json(toResponse(records, count, { take, skip }));
19✔
158
    } catch (e) {
30!
159
      res.status(500).send();
×
160
      this.logger.error(e);
×
161
    }
×
162
  }
30✔
163

164
  /**
1✔
165
   * POST /transactions
166
   * @summary Creates a new transaction
167
   * @operationId createTransaction
168
   * @tags transactions - Operations of the transaction controller
169
   * @param {TransactionRequest} request.body.required -
170
   * The transaction which should be created
171
   * @security JWT
172
   * @return {TransactionResponse} 200 - The created transaction entity
173
   * @return {string} 400 - Validation error
174
   * @return {string} 403 - Insufficient balance error or invalid POS token
175
   * @return {string} 500 - Internal server error
176
   */
1✔
177
  public async createTransaction(req: RequestWithToken, res: Response): Promise<void> {
1✔
178
    const body = req.body as TransactionRequest;
19✔
179
    this.logger.trace('Create transaction', body, 'by user', req.token.user);
19✔
180

181
    // handle request
19✔
182
    try {
19✔
183
      // Verify POS token for lesser tokens
19✔
184
      if (req.token.posId) {
19✔
185
        if (!(await POSTokenVerifier.verify(req, body.pointOfSale.id))) {
5✔
186
          res.status(403).end('Invalid POS token.');
1✔
187
          return;
1✔
188
        }
1✔
189
      }
5✔
190

191
      const transactionService = new TransactionService();
18✔
192
      
193
      // Verify transaction and get context with loaded entities
18✔
194
      const verification = await transactionService.verifyTransaction(body);
18✔
195
      
196
      if (!verification.valid || !verification.context) {
19✔
197
        res.status(400).json('Invalid transaction.');
1✔
198
        return;
1✔
199
      }
1✔
200

201
      const { context } = verification;
17✔
202
      const fromUser = context.users.get(body.from)!;
17✔
203

204
      // verify balance if user cannot have negative balance, using cached total cost
17✔
205
      if (!fromUser.canGoIntoDebt && !(await transactionService.verifyBalance(body, context.totalCost))) {
19✔
206
        res.status(403).json('Insufficient balance.');
1✔
207
        return;
1✔
208
      }
1✔
209

210
      // create the transaction using context
16✔
211
      const transaction = await transactionService.createTransaction(body, context);
16✔
212

213
      res.json(await transactionService.asTransactionResponse(transaction));
16✔
214
    } catch (error) {
19!
215
      this.logger.error('Could not create transaction:', error);
×
216
      res.status(500).json('Internal server error.');
×
217
    }
×
218
  }
19✔
219

220
  /**
1✔
221
   * GET /transactions/{id}
222
   * @summary Get a single transaction
223
   * @operationId getSingleTransaction
224
   * @tags transactions - Operations of the transaction controller
225
   * @param {integer} id.path.required - The id of the transaction which should be returned
226
   * @security JWT
227
   * @return {TransactionResponse} 200 - Single transaction with given id
228
   * @return {string} 404 - Nonexistent transaction id
229
   */
1✔
230
  public async getTransaction(req: RequestWithToken, res: Response): Promise<void> {
1✔
231
    const parameters = req.params;
6✔
232
    this.logger.trace('Get single transaction', parameters, 'by user', req.token.user);
6✔
233

234
    let transaction;
6✔
235
    try {
6✔
236
      const transactionService = new TransactionService();
6✔
237
      transaction = await transactionService.getSingleTransaction(parseInt(parameters.id, 10));
6✔
238

239
      // If the transaction is undefined, there does not exist a transaction with the given ID
6✔
240
      if (transaction === undefined) {
6!
241
        res.status(404).json('Unknown transaction ID.');
×
242
        return;
×
243
      }
×
244

245
      res.status(200).json(await transactionService.asTransactionResponse(transaction));
6✔
246
    } catch (e) {
6!
247
      res.status(500).send();
×
248
      this.logger.error(e);
×
249
    }
×
250
  }
6✔
251

252
  /**
1✔
253
   * PATCH /transactions/{id}
254
   * @summary Updates the requested transaction
255
   * @operationId updateTransaction
256
   * @tags transactions - Operations of transaction controller
257
   * @param {integer} id.path.required - The id of the transaction which should be updated
258
   * @param {TransactionRequest} request.body.required -
259
   * The updated transaction
260
   * @security JWT
261
   * @return {TransactionResponse} 200 - The requested transaction entity
262
   * @return {string} 400 - Validation error
263
   * @return {string} 403 - Lesser tokens cannot update transactions
264
   * @return {string} 404 - Not found error
265
   * @return {string} 500 - Internal server error
266
   */
1✔
267
  public async updateTransaction(req: RequestWithToken, res: Response): Promise<void> {
1✔
268
    const body = req.body as TransactionRequest;
4✔
269
    const { id } = req.params;
4✔
270
    this.logger.trace('Update Transaction', id, 'by user', req.token.user);
4✔
271

272
    // handle request
4✔
273
    try {
4✔
274
      if (await Transaction.findOne({ where: { id: parseInt(id, 10) } })) {
4✔
275
        const transactionService = new TransactionService();
3✔
276
        const verification = await transactionService.verifyTransaction(body, true);
3✔
277
        if (!verification.valid || !verification.context) {
3✔
278
          res.status(400).json('Invalid transaction.');
1✔
279
          return;
1✔
280
        }
1✔
281
        const transaction = await transactionService.updateTransaction(
2✔
282
          parseInt(id, 10), body,
2✔
283
        );
284
        if (!transaction) {
3!
285
          res.status(400).json('Could not update transaction.');
×
286
          return;
×
287
        }
✔
288
        res.status(200).json(await transactionService.asTransactionResponse(transaction));
2✔
289
      } else {
4✔
290
        res.status(404).json('Transaction not found.');
1✔
291
      }
1✔
292
    } catch (error) {
4!
293
      this.logger.error('Could not update transaction:', error);
×
294
      res.status(500).json('Internal server error.');
×
295
    }
×
296
  }
4✔
297

298
  /**
1✔
299
   * DELETE /transactions/{id}
300
   * @summary Deletes a transaction
301
   * @operationId deleteTransaction
302
   * @tags transactions - Operations of the transaction controller
303
   * @param {integer} id.path.required - The id of the transaction which should be deleted
304
   * @security JWT
305
   * @return 204 - No content
306
   * @return {string} 404 - Nonexistent transaction id
307
   */
1✔
308
  // eslint-disable-next-line class-methods-use-this
1✔
309
  public async deleteTransaction(req: RequestWithToken, res: Response): Promise<void> {
1✔
310
    const { id } = req.params;
2✔
311
    this.logger.trace('Delete transaction', id, 'by user', req.token.user);
2✔
312

313
    // handle request
2✔
314
    try {
2✔
315
      if (await Transaction.findOne({ where: { id: parseInt(id, 10) } })) {
2✔
316
        await new TransactionService().deleteTransaction(parseInt(id, 10));
1✔
317
        res.status(204).json();
1✔
318
      } else {
1✔
319
        res.status(404).json('Transaction not found.');
1✔
320
      }
1✔
321
    } catch (error) {
2!
322
      this.logger.error('Could not delete transaction:', error);
×
323
      res.status(500).json('Internal server error.');
×
324
    }
×
325
  }
2✔
326

327
  /**
1✔
328
   * GET /transactions/{id}/invoices
329
   * @summary Get all invoices containing subtransaction rows from this transaction
330
   * @operationId getTransactionInvoices
331
   * @tags transactions - Operations of the transaction controller
332
   * @param {integer} id.path.required - The transaction ID
333
   * @security JWT
334
   * @return {Array<BaseInvoiceResponse>} 200 - List of invoices
335
   * @return {string} 404 - Transaction not found
336
   * @return {string} 500 - Internal server error
337
   */
1✔
338
  public async getTransactionInvoices(req: RequestWithToken, res: Response): Promise<void> {
1✔
339
    const { id } = req.params;
2✔
340
    const transactionId = parseInt(id, 10);
2✔
341
    this.logger.trace('Get transaction invoices', id, 'by user', req.token.user);
2✔
342

343
    try {
2✔
344
      const transaction = await Transaction.findOne({ where: { id: transactionId } });
2✔
345
      if (!transaction) {
2✔
346
        res.status(404).json('Transaction not found.');
1✔
347
        return;
1✔
348
      }
1✔
349

350
      const invoices = await new InvoiceService().getTransactionInvoices(transactionId);
1✔
351
      res.status(200).json(InvoiceService.toArrayWithoutEntriesResponse(invoices));
1✔
352
    } catch (error) {
2!
353
      this.logger.error('Could not return transaction invoices:', error);
×
354
      res.status(500).json('Internal server error.');
×
355
    }
×
356
  }
2✔
357

358
  /**
1✔
359
   * POST /transactions/validate
360
   * @summary Function to validate the transaction before creating it
361
   * @operationId validateTransaction
362
   * @tags transactions - Operations of the transaction controller
363
   * @param {TransactionRequest} request.body.required -
364
   * The transaction which should be validated
365
   * @return {boolean} 200 - Transaction validated
366
   * @security JWT
367
   * @return {string} 400 - Validation error
368
   * @return {string} 403 - Invalid POS token
369
   * @return {string} 500 - Internal server error
370
   */
1✔
371
  public async validateTransaction(req: RequestWithToken, res: Response): Promise<void> {
1✔
372
    const body = req.body as TransactionRequest;
4✔
373
    this.logger.trace('Validate transaction', body, 'by user', req.token.user);
4✔
374

375
    try {
4✔
376
      // Verify POS token for lesser tokens
4✔
377
      if (req.token.posId) {
4✔
378
        if (!(await POSTokenVerifier.verify(req, body.pointOfSale.id))) {
2✔
379
          res.status(403).end('Invalid POS token.');
1✔
380
          return;
1✔
381
        }
1✔
382
      }
2✔
383

384
      const verification = await new TransactionService().verifyTransaction(body);
3✔
385
      if (!verification.valid) {
4✔
386
        res.status(400).json('Transaction is invalid');
1✔
387
        return;
1✔
388
      }
1✔
389
      res.status(200).json(true);
2✔
390
    } catch (error) {
4!
391
      this.logger.error('Could not validate transaction:', error);
×
392
      res.status(500).json('Internal server error');
×
393
    }
×
394
  }
4✔
395

396
  /**
1✔
397
   * GET /transactions/{id}/pdf
398
   * @summary Get the PDF of the transaction
399
   * @operationId getTransactionPdf
400
   * @tags transactions - Operations of the transaction controller
401
   * @param {integer} id.path.required - The transaction ID
402
   * @security JWT
403
   * @returns {string} 200 - The requested pdf of the transaction - application/pdf
404
   * @return {string} 404 - Transaction not found
405
   * @return {string} 400 - PDF generation failed
406
   * @return {string} 500 - Internal server error
407
   */
1✔
408
  public async getTransactionPdf(req: RequestWithToken, res: Response): Promise<void> {
1✔
409
    const { id } = req.params;
4✔
410
    const transactionId = parseInt(id, 10);
4✔
411
    this.logger.trace('Get transaction PDF', id, 'by user', req.token.user);
4✔
412

413
    try {
4✔
414
      const transaction = await Transaction.findOne({ where: { id: transactionId }, relations: {
4✔
415
        from: true,
4✔
416
        createdBy: true,
4✔
417
      }  });
4✔
418
      if (!transaction) {
4✔
419
        res.status(404).json('Transaction not found.');
1✔
420
        return;
1✔
421
      }
1✔
422

423
      const pdf = await transaction.createPdf();
3✔
424
      const fileName = `transaction-${transaction.id}.pdf`;
1✔
425
      res.setHeader('Content-Type', 'application/pdf');
1✔
426
      res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
1✔
427
      res.status(200).send(pdf);
1✔
428
    } catch (error: any) {
4✔
429
      if (error instanceof PdfError) {
2✔
430
        res.status(400).json(error.message);
1✔
431
        return;
1✔
432
      }
1✔
433
      this.logger.error('Could not return transaction PDF:', error);
1✔
434
      res.status(500).json('Internal server error.');
1✔
435
    }
1✔
436
  }
4✔
437

438
  /**
1✔
439
   * Determines the relation between the user and the transaction (by filters in the request).
440
   * - Returns 'own' if user is from, to, or createdBy.
441
   * - Returns 'organ' if user shares an organ with any of those users.
442
   * - Returns 'all' otherwise.
443
   *
444
   * @param req - Express request with user token and filters in query params.
445
   * @returns 'own' | 'organ' | 'all'
446
   */
1✔
447
  static async filterRelation(
1✔
448
    req: RequestWithToken,
34✔
449
  ): Promise<'own' | 'organ' | 'all'> {
34✔
450
    try {
34✔
451
      const userId = req.token.user.id;
34✔
452
      const { fromId, toId, createdById } = parseGetTransactionsFilters(req);
34✔
453

454
      // Check direct involvement
34✔
455
      if (fromId === userId || toId === userId || createdById === userId) {
34✔
456
        return 'own';
5✔
457
      }
5✔
458

459
      // Check organ relation
21✔
460
      if (
21✔
461
        (fromId && (await UserService.areInSameOrgan(userId, fromId))) ||
34✔
462
          (toId && (await UserService.areInSameOrgan(userId, toId))) ||
21✔
463
          (createdById && (await UserService.areInSameOrgan(userId, createdById)))
21✔
464
      ) {
34!
465
        return 'organ';
×
466
      }
✔
467

468
      return 'all';
21✔
469
    } catch (error) {
34✔
470
      return 'all';
8✔
471
    }
8✔
472
  }
34✔
473
  
474
  /**
1✔
475
   * Function to determine which credentials are needed to post transaction
476
   *    all if user is not connected to transaction
477
   *    other if transaction createdby is and linked via organ
478
   *    own if user is connected to transaction
479
   * @param req - Request with TransactionRequest in the body
480
   * @return whether transaction is connected to user token
481
   */
1✔
482
  static async postRelation(req: RequestWithToken): Promise<string> {
1✔
483
    const request = req.body as TransactionRequest;
30✔
484
    if (request.createdBy !== req.token.user.id) {
30✔
485
      if (await UserService.areInSameOrgan(request.createdBy, req.token.user.id)) {
6✔
486
        return 'organ';
1✔
487
      }
1✔
488
      return 'all';
5✔
489
    }
5✔
490
    if (request.from === req.token.user.id) return 'own';
29✔
491
    return 'all';
4✔
492
  }
4✔
493

494
  /**
1✔
495
   * Function to determine which credentials are needed to get transactions
496
   *    all if user is not connected to transaction
497
   *    organ if user is not connected to transaction via organ
498
   *    own if user is connected to transaction
499
   * @param req - Request with transaction id as param
500
   * @return whether transaction is connected to user token
501
   */
1✔
502
  static async getRelation(req: RequestWithToken): Promise<string> {
1✔
503
    const transaction = await Transaction.findOne({
15✔
504
      where: { id: asNumber(req.params.id) },
15✔
505
      relations: {
15✔
506
        from: true,
15✔
507
        createdBy: true,
15✔
508

509
        pointOfSale: {
15✔
510
          pointOfSale: {
15✔
511
            owner: true,
15✔
512
          },
15✔
513
        },
15✔
514
      },
15✔
515
    });
15✔
516
    if (!transaction) return 'all';
15✔
517
    if (userTokenInOrgan(req, transaction.from.id)
12✔
518
        || userTokenInOrgan(req, transaction.createdBy.id)
11✔
519
        || userTokenInOrgan(req, transaction.pointOfSale.pointOfSale.owner.id)) return 'organ';
15✔
520
    if (transaction.from.id === req.token.user.id || transaction.createdBy.id === req.token.user.id) return 'own';
15✔
521
    return 'all';
3✔
522
  }
3✔
523
}
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