• 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

90.39
/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
export default class TransactionController extends BaseController {
1✔
46
  private logger: Logger = log4js.getLogger('TransactionController');
1✔
47

48

49
  /**
1✔
50
   * Creates a new transaction controller instance.
51
   * @param options - The options passed to the base controller.
52
   */
1✔
53
  public constructor(options: BaseControllerOptions) {
1✔
54
    super(options);
5✔
55
    this.configureLogger(this.logger);
5✔
56
  }
5✔
57

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

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

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

149
    try {
19✔
150
      const [records, count] = await new TransactionService().getTransactions(filters, { take, skip });
19✔
151
      res.status(200).json(toResponse(records, count, { take, skip }));
19✔
152
    } catch (e) {
30!
153
      res.status(500).send();
×
154
      this.logger.error(e);
×
155
    }
×
156
  }
30✔
157

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

175
    // handle request
19✔
176
    try {
19✔
177
      // Verify POS token for lesser tokens
19✔
178
      if (req.token.posId) {
19✔
179
        if (!await POSTokenVerifier.verify(req, body.pointOfSale.id)) {
5✔
180
          res.status(403).end('Invalid POS token.');
1✔
181
          return;
1✔
182
        }
1✔
183
      }
5✔
184

185
      const transactionService = new TransactionService();
18✔
186
      
187
      // Verify transaction and get context with loaded entities
18✔
188
      const verification = await transactionService.verifyTransaction(body);
18✔
189
      
190
      if (!verification.valid || !verification.context) {
19✔
191
        res.status(400).json('Invalid transaction.');
1✔
192
        return;
1✔
193
      }
1✔
194

195
      const { context } = verification;
17✔
196
      const fromUser = context.users.get(body.from)!;
17✔
197

198
      // verify balance if user cannot have negative balance, using cached total cost
17✔
199
      if (!fromUser.canGoIntoDebt && !await transactionService.verifyBalance(body, context.totalCost)) {
19✔
200
        res.status(403).json('Insufficient balance.');
1✔
201
        return;
1✔
202
      }
1✔
203

204
      // create the transaction using context
16✔
205
      const transaction = await transactionService.createTransaction(body, context);
16✔
206

207
      res.json(await transactionService.asTransactionResponse(transaction));
16✔
208
    } catch (error) {
19!
209
      this.logger.error('Could not create transaction:', error);
×
210
      res.status(500).json('Internal server error.');
×
211
    }
×
212
  }
19✔
213

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

228
    let transaction;
6✔
229
    try {
6✔
230
      const transactionService = new TransactionService();
6✔
231
      transaction = await transactionService.getSingleTransaction(parseInt(parameters.id, 10));
6✔
232

233
      // If the transaction is undefined, there does not exist a transaction with the given ID
6✔
234
      if (transaction === undefined) {
6!
235
        res.status(404).json('Unknown transaction ID.');
×
236
        return;
×
237
      }
×
238

239
      res.status(200).json(await transactionService.asTransactionResponse(transaction));
6✔
240
    } catch (e) {
6!
241
      res.status(500).send();
×
242
      this.logger.error(e);
×
243
    }
×
244
  }
6✔
245

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

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

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

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

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

337
    try {
2✔
338
      const transaction = await Transaction.findOne({ where: { id: transactionId } });
2✔
339
      if (!transaction) {
2✔
340
        res.status(404).json('Transaction not found.');
1✔
341
        return;
1✔
342
      }
1✔
343

344
      const invoices = await new InvoiceService().getTransactionInvoices(transactionId);
1✔
345
      res.status(200).json(InvoiceService.toArrayWithoutEntriesResponse(invoices));
1✔
346
    } catch (error) {
2!
347
      this.logger.error('Could not return transaction invoices:', error);
×
348
      res.status(500).json('Internal server error.');
×
349
    }
×
350
  }
2✔
351

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

369
    try {
4✔
370
      // Verify POS token for lesser tokens
4✔
371
      if (req.token.posId) {
4✔
372
        if (!await POSTokenVerifier.verify(req, body.pointOfSale.id)) {
2✔
373
          res.status(403).end('Invalid POS token.');
1✔
374
          return;
1✔
375
        }
1✔
376
      }
2✔
377

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

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

407
    try {
4✔
408
      const transaction = await Transaction.findOne({ where: { id: transactionId }, relations: ['from', 'createdBy']  });
4✔
409
      if (!transaction) {
4✔
410
        res.status(404).json('Transaction not found.');
1✔
411
        return;
1✔
412
      }
1✔
413

414
      const pdf = await transaction.createPdf();
3✔
415
      const fileName = `transaction-${transaction.id}.pdf`;
1✔
416
      res.setHeader('Content-Type', 'application/pdf');
1✔
417
      res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
1✔
418
      res.status(200).send(pdf);
1✔
419
    } catch (error: any) {
4✔
420
      if (error instanceof PdfError) {
2✔
421
        res.status(400).json(error.message);
1✔
422
        return;
1✔
423
      }
1✔
424
      this.logger.error('Could not return transaction PDF:', error);
1✔
425
      res.status(500).json('Internal server error.');
1✔
426
    }
1✔
427
  }
4✔
428

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

445
      // Check direct involvement
34✔
446
      if (fromId === userId || toId === userId || createdById === userId) {
34✔
447
        return 'own';
5✔
448
      }
5✔
449

450
      // Check organ relation
21✔
451
      if (
21✔
452
        (fromId && await UserService.areInSameOrgan(userId, fromId)) ||
34✔
453
          (toId && await UserService.areInSameOrgan(userId, toId)) ||
21✔
454
          (createdById && await UserService.areInSameOrgan(userId, createdById))
21✔
455
      ) {
34!
456
        return 'organ';
×
457
      }
✔
458

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

485
  /**
1✔
486
   * Function to determine which credentials are needed to get transactions
487
   *    all if user is not connected to transaction
488
   *    organ if user is not connected to transaction via organ
489
   *    own if user is connected to transaction
490
   * @param req - Request with transaction id as param
491
   * @return whether transaction is connected to user token
492
   */
1✔
493
  static async getRelation(req: RequestWithToken): Promise<string> {
1✔
494
    const transaction = await Transaction.findOne({
15✔
495
      where: { id: asNumber(req.params.id) },
15✔
496
      relations: ['from', 'createdBy', 'pointOfSale', 'pointOfSale.pointOfSale', 'pointOfSale.pointOfSale.owner'],
15✔
497
    });
15✔
498
    if (!transaction) return 'all';
15✔
499
    if (userTokenInOrgan(req, transaction.from.id)
12✔
500
        || userTokenInOrgan(req, transaction.createdBy.id)
11✔
501
        || userTokenInOrgan(req, transaction.pointOfSale.pointOfSale.owner.id)) return 'organ';
15✔
502
    if (transaction.from.id === req.token.user.id || transaction.createdBy.id === req.token.user.id) return 'own';
15✔
503
    return 'all';
3✔
504
  }
3✔
505
}
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