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

GEWIS / sudosos-backend / 26819060307

02 Jun 2026 12:15PM UTC coverage: 91.954% (-0.005%) from 91.959%
26819060307

push

github

web-flow
feat: add PaymentRequest HTTP API (#897)

* feat: add PaymentRequest controllers and DTOs

Authenticated controller (/payment-requests) and unauthenticated
share-link controller (/payment-requests-public). Both use the existing
service layer; the public surface is mounted before token middleware in
src/index.ts (StripeWebhookController pattern).

- PaymentRequestController: create, list (with get-own scoping), single
  fetch with request-scoped caching, cancel, start, mark-fulfilled.
- PaymentRequestPublicController: trimmed lookup + start endpoints. Uses
  PaymentRequestService.getPublicPaymentRequest for a lightweight load
  that skips audit-only relations.
- Request DTOs (CreatePaymentRequestRequest, MarkFulfilledExternallyRequest).
- UserController gains the GET /users/{id}/payment-requests endpoint that
  delegates to the service listing with forId scoped to the path user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: add PaymentRequest permissions to default roles

Regular users (User role) get get-own / create-own / update-own
on PaymentRequest so they can list, fetch, create, start, and
cancel their own payment-link rows. Super admin gets full access
via the standard `admin` permission bundle, which unlocks
mark-fulfilled-externally and cross-user listings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: add PaymentRequest controller tests

Covers PaymentRequestController (authenticated) and
PaymentRequestPublicController (unauthenticated share link):

- list/filter/pagination with RBAC own vs all,
- single-request fetch incl. cross-user 403,
- create (self vs other, admin escape hatch, 404 on unknown
  beneficiary, 400 on invalid expiresAt),
- cancel state machine (PENDING → CANCELLED, 409 from other
  states, 404 on unknown id),
- mark-fulfilled (admin-only via update:all policy, 400 on
  empty audit reason, 409 from PAID).

Public controller tests assert:
- trimmed response shape (no createdBy/cancelled... (continued)

4183 of 4774 branches covered (87.62%)

Branch coverage included in aggregate %.

298 of 379 new or added lines in 6 files covered. (78.63%)

2 existing lines in 1 file now uncovered.

21245 of 22879 relevant lines covered (92.86%)

859.36 hits per line

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

78.76
/src/controller/payment-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 the payment-request-controller.
23
 *
24
 * Authenticated endpoints for managing {@link stripe/payment-request!PaymentRequest | PaymentRequest}
25
 * rows. The unauthenticated share-link surface lives in
26
 * {@link PaymentRequestPublicController}.
27
 *
28
 * @module stripe/payment-request
29
 */
1✔
30

31
import { Response } from 'express';
32
import log4js, { Logger } from 'log4js';
33
import Dinero from 'dinero.js';
34
import BaseController, { BaseControllerOptions } from './base-controller';
35
import Policy from './policy';
36
import { RequestWithToken } from '../middleware/token-middleware';
37
import { parseRequestPagination, toResponse } from '../helpers/pagination';
38
import PaymentRequestService, {
39
  IllegalPaymentRequestTransitionError,
40
  InvalidPaymentRequestBeneficiaryError,
41
  parseGetPaymentRequestsFilters,
42
} from '../service/payment-request-service';
43
import PaymentRequestCheckoutService from '../service/payment-request-checkout-service';
44
import PaymentRequest from '../entity/payment-request/payment-request';
45
import User from '../entity/user/user';
46
import { PaymentRequestStartResponse } from './response/payment-request-response';
47
import {
48
  CreatePaymentRequestRequest,
49
  MarkFulfilledExternallyRequest,
50
} from './request/payment-request-request';
51

52
export default class PaymentRequestController extends BaseController {
1✔
53
  private logger: Logger = log4js.getLogger('PaymentRequestController');
1✔
54

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

60
  /**
1✔
61
   * @inheritDoc
62
   */
1✔
63
  public getPolicy(): Policy {
1✔
64
    return {
1✔
65
      '/': {
1✔
66
        GET: {
1✔
67
          policy: async (req) => this.roleManager.can(
1✔
68
            req.token.roles, 'get', 'all', 'PaymentRequest', ['*'],
5✔
69
          ),
70
          handler: this.returnAllPaymentRequests.bind(this),
1✔
71
        },
1✔
72
        POST: {
1✔
73
          policy: async (req) => this.roleManager.can(
1✔
74
            req.token.roles, 'create',
5✔
75
            await PaymentRequestController.getRelation(req),
5✔
76
            'PaymentRequest', ['*'],
5✔
77
          ),
78
          handler: this.createPaymentRequest.bind(this),
1✔
79
          body: { modelName: 'CreatePaymentRequestRequest' },
1✔
80
        },
1✔
81
      },
1✔
82
      '/:id': {
1✔
83
        GET: {
1✔
84
          policy: async (req) => this.roleManager.can(
1✔
85
            req.token.roles, 'get',
4✔
86
            await PaymentRequestController.getRelation(req),
4✔
87
            'PaymentRequest', ['*'],
4✔
88
          ),
89
          handler: this.returnSinglePaymentRequest.bind(this),
1✔
90
        },
1✔
91
      },
1✔
92
      '/:id/cancel': {
1✔
93
        POST: {
1✔
94
          policy: async (req) => this.roleManager.can(
1✔
95
            req.token.roles, 'update',
4✔
96
            await PaymentRequestController.getRelation(req),
4✔
97
            'PaymentRequest', ['*'],
4✔
98
          ),
99
          handler: this.cancelPaymentRequest.bind(this),
1✔
100
        },
1✔
101
      },
1✔
102
      '/:id/start': {
1✔
103
        POST: {
1✔
104
          policy: async (req) => this.roleManager.can(
1✔
NEW
105
            req.token.roles, 'update',
×
NEW
106
            await PaymentRequestController.getRelation(req),
×
NEW
107
            'PaymentRequest', ['*'],
×
108
          ),
109
          handler: this.startPaymentAuthenticated.bind(this),
1✔
110
        },
1✔
111
      },
1✔
112
      '/:id/mark-fulfilled': {
1✔
113
        POST: {
1✔
114
          policy: async (req) => this.roleManager.can(
1✔
115
            req.token.roles, 'update', 'all', 'PaymentRequest', ['*'],
4✔
116
          ),
117
          handler: this.markFulfilledExternally.bind(this),
1✔
118
          body: { modelName: 'MarkFulfilledExternallyRequest' },
1✔
119
        },
1✔
120
      },
1✔
121
    };
1✔
122
  }
1✔
123

124
  /**
1✔
125
   * Load the PaymentRequest for the current `/:id` request, caching on `req`
126
   * so the policy + handler together only hit the DB once.
127
   *
128
   * The full relation set (`for`, `createdBy`, `cancelledBy`, `fulfilledBy`)
129
   * is loaded so that handlers can serialize the response directly from
130
   * the cached entity without re-querying.
131
   *
132
   * Returns `null` when there is no `:id` param or the request doesn't exist.
133
   */
1✔
134
  public static async loadPaymentRequest(
1✔
135
    req: RequestWithToken & { paymentRequest?: PaymentRequest | null },
17✔
136
  ): Promise<PaymentRequest | null> {
17✔
137
    if (req.paymentRequest !== undefined) {
17✔
138
      return req.paymentRequest;
7✔
139
    }
7✔
140
    const { id } = req.params;
10✔
141
    if (!id) {
17!
NEW
142
      req.paymentRequest = null;
×
NEW
143
      return null;
×
NEW
144
    }
✔
145
    req.paymentRequest = await new PaymentRequestService().getPaymentRequest(id);
10✔
146
    return req.paymentRequest;
10✔
147
  }
10✔
148

149
  /**
1✔
150
   * Determine "own" vs "all" for RBAC. Creation takes the `forId` from the
151
   * body; state transitions look up the request by id and compare against
152
   * the caller. The lookup is cached on `req` via
153
   * {@link loadPaymentRequest} so the subsequent handler does not re-query.
154
   */
1✔
155
  public static async getRelation(req: RequestWithToken): Promise<string> {
1✔
156
    const body = req.body as Partial<CreatePaymentRequestRequest> | undefined;
13✔
157
    if (body && body.forId != null) {
13✔
158
      return body.forId === req.token.user.id ? 'own' : 'all';
5✔
159
    }
5✔
160

161
    const request = await PaymentRequestController.loadPaymentRequest(
8✔
162
      req as RequestWithToken & { paymentRequest?: PaymentRequest | null },
8✔
163
    );
164
    return (request != null && request.for.id === req.token.user.id) ? 'own' : 'all';
13✔
165
  }
13✔
166

167
  /**
1✔
168
   * GET /payment-requests
169
   * @summary List PaymentRequests (paginated, with filtering by beneficiary, creator, and status)
170
   * @operationId getAllPaymentRequests
171
   * @tags paymentRequests - Operations of the payment-request controller
172
   * @security JWT
173
   * @param {integer} forId.query - Filter by beneficiary user id.
174
   * @param {integer} createdById.query - Filter by creator user id.
175
   * @param {string} status.query - enum:PENDING,PAID,EXPIRED,CANCELLED - Comma-separated list of derived statuses.
176
   * @param {string} fromDate.query - Filter requests created on or after this ISO date (inclusive).
177
   * @param {string} tillDate.query - Filter requests created strictly before this ISO date (exclusive).
178
   * @param {integer} take.query - How many rows the endpoint should return
179
   * @param {integer} skip.query - How many rows to skip (for pagination)
180
   * @return {PaginatedBasePaymentRequestResponse} 200 - All existing payment requests
181
   * @return {string} 400 - Validation error
182
   * @return {string} 500 - Internal server error
183
   */
1✔
184
  public async returnAllPaymentRequests(req: RequestWithToken, res: Response): Promise<void> {
1✔
185
    this.logger.trace('Get all payment requests by user', req.token.user);
4✔
186

187
    let filters;
4✔
188
    let pagination;
4✔
189
    try {
4✔
190
      filters = parseGetPaymentRequestsFilters(req);
4✔
191
      pagination = parseRequestPagination(req);
4✔
192
    } catch (e) {
4✔
193
      res.status(400).send(e instanceof Error ? e.message : String(e));
1!
194
      return;
1✔
195
    }
1✔
196

197
    try {
3✔
198
      const service = new PaymentRequestService();
3✔
199
      const [rows, count] = await service.getPaymentRequests(filters, pagination);
3✔
200
      const records = rows.map((r) => PaymentRequestService.asBasePaymentRequestResponse(r));
3✔
201
      res.status(200).json(toResponse(records, count, pagination));
3✔
202
    } catch (e) {
4!
NEW
203
      this.logger.error('Could not list payment requests:', e);
×
NEW
204
      res.status(500).send('Internal server error.');
×
NEW
205
    }
×
206
  }
4✔
207

208
  /**
1✔
209
   * GET /payment-requests/{id}
210
   * @summary Fetch a single PaymentRequest by id.
211
   * @operationId getSinglePaymentRequest
212
   * @tags paymentRequests - Operations of the payment-request controller
213
   * @param {string} id.path.required - UUID v4 of the payment request.
214
   * @security JWT
215
   * @return {BasePaymentRequestResponse} 200 - Single payment request
216
   * @return {string} 404 - Unknown id
217
   * @return {string} 500 - Internal server error
218
   */
1✔
219
  public async returnSinglePaymentRequest(req: RequestWithToken, res: Response): Promise<void> {
1✔
220
    this.logger.trace('Get single payment request by user', req.token.user, 'id', req.params.id);
3✔
221

222
    try {
3✔
223
      const request = await PaymentRequestController.loadPaymentRequest(
3✔
224
        req as RequestWithToken & { paymentRequest?: PaymentRequest | null },
3✔
225
      );
226
      if (!request) {
3✔
227
        res.status(404).send();
1✔
228
        return;
1✔
229
      }
1✔
230
      res.status(200).json(PaymentRequestService.asBasePaymentRequestResponse(request));
2✔
231
    } catch (e) {
3!
NEW
232
      this.logger.error('Could not get payment request:', e);
×
NEW
233
      res.status(500).send('Internal server error.');
×
NEW
234
    }
×
235
  }
3✔
236

237
  /**
1✔
238
   * POST /payment-requests
239
   * @summary Create a new PaymentRequest.
240
   * @operationId createPaymentRequest
241
   * @tags paymentRequests - Operations of the payment-request controller
242
   * @param {CreatePaymentRequestRequest} request.body.required - The request to create
243
   * @security JWT
244
   * @return {BasePaymentRequestResponse} 200 - The created payment request
245
   * @return {string} 400 - Validation error
246
   * @return {string} 404 - Beneficiary user not found
247
   * @return {string} 500 - Internal server error
248
   */
1✔
249
  public async createPaymentRequest(req: RequestWithToken, res: Response): Promise<void> {
1✔
250
    this.logger.trace('Create payment request by user', req.token.user, 'body', req.body);
4✔
251
    const body = req.body as CreatePaymentRequestRequest;
4✔
252

253
    const forUser = await User.findOne({ where: { id: body.forId, deleted: false } });
4✔
254
    if (!forUser) {
4✔
255
      res.status(404).send('Beneficiary user not found.');
1✔
256
      return;
1✔
257
    }
1✔
258

259
    let expiresAt: Date;
3✔
260
    try {
3✔
261
      expiresAt = new Date(body.expiresAt);
3✔
262
      if (Number.isNaN(expiresAt.getTime())) throw new Error('Invalid expiresAt');
4✔
263
    } catch {
4✔
264
      res.status(400).send('Invalid expiresAt; must be a valid ISO-8601 timestamp.');
1✔
265
      return;
1✔
266
    }
1✔
267

268
    try {
2✔
269
      const service = new PaymentRequestService();
2✔
270
      const request = await service.createPaymentRequest({
2✔
271
        for: forUser,
2✔
272
        createdBy: req.token.user,
2✔
273
        amount: Dinero(body.amount),
2✔
274
        expiresAt,
2✔
275
        description: body.description ?? null,
4✔
276
      });
4✔
277
      res.status(200).json(PaymentRequestService.asBasePaymentRequestResponse(request));
2✔
278
    } catch (e) {
4!
NEW
279
      if (e instanceof InvalidPaymentRequestBeneficiaryError) {
×
NEW
280
        res.status(400).send(e.message);
×
NEW
281
        return;
×
NEW
282
      }
×
NEW
283
      this.logger.error('Could not create payment request:', e);
×
NEW
284
      res.status(500).send('Internal server error.');
×
NEW
285
    }
×
286
  }
4✔
287

288
  /**
1✔
289
   * POST /payment-requests/{id}/cancel
290
   * @summary Cancel a PENDING PaymentRequest.
291
   * @operationId cancelPaymentRequest
292
   * @tags paymentRequests - Operations of the payment-request controller
293
   * @param {string} id.path.required - UUID v4 of the payment request.
294
   * @security JWT
295
   * @return {BasePaymentRequestResponse} 200 - The cancelled payment request
296
   * @return {string} 404 - Unknown id
297
   * @return {string} 409 - Request is not in PENDING state
298
   * @return {string} 500 - Internal server error
299
   */
1✔
300
  public async cancelPaymentRequest(req: RequestWithToken, res: Response): Promise<void> {
1✔
301
    this.logger.trace('Cancel payment request by user', req.token.user, 'id', req.params.id);
4✔
302

303
    try {
4✔
304
      const request = await PaymentRequestController.loadPaymentRequest(
4✔
305
        req as RequestWithToken & { paymentRequest?: PaymentRequest | null },
4✔
306
      );
307
      if (!request) {
4✔
308
        res.status(404).send();
1✔
309
        return;
1✔
310
      }
1✔
311
      const service = new PaymentRequestService();
3✔
312
      const cancelled = await service.cancelPaymentRequest(request, req.token.user);
3✔
313
      res.status(200).json(PaymentRequestService.asBasePaymentRequestResponse(cancelled));
1✔
314
    } catch (e) {
4✔
315
      if (e instanceof IllegalPaymentRequestTransitionError) {
2✔
316
        res.status(409).send(e.message);
2✔
317
        return;
2✔
318
      }
2!
NEW
319
      this.logger.error('Could not cancel payment request:', e);
×
NEW
320
      res.status(500).send('Internal server error.');
×
NEW
321
    }
×
322
  }
4✔
323

324
  /**
1✔
325
   * POST /payment-requests/{id}/start
326
   * @summary Start a Stripe payment session for the given PaymentRequest while authenticated.
327
   * @operationId startPaymentRequestAuthenticated
328
   * @tags paymentRequests - Operations of the payment-request controller
329
   * @param {string} id.path.required - UUID v4 of the payment request.
330
   * @security JWT
331
   * @return {PaymentRequestStartResponse} 200 - Stripe client secret
332
   * @return {string} 400 - Invalid beneficiary
333
   * @return {string} 404 - Unknown id
334
   * @return {string} 409 - Request is not in PENDING state
335
   * @return {string} 500 - Internal server error
336
   */
1✔
337
  public async startPaymentAuthenticated(req: RequestWithToken, res: Response): Promise<void> {
1✔
NEW
338
    this.logger.trace('Start payment (authenticated) by user', req.token.user, 'id', req.params.id);
×
339

NEW
340
    try {
×
NEW
341
      const request = await PaymentRequestController.loadPaymentRequest(
×
NEW
342
        req as RequestWithToken & { paymentRequest?: PaymentRequest | null },
×
343
      );
NEW
344
      if (!request) {
×
NEW
345
        res.status(404).send();
×
NEW
346
        return;
×
NEW
347
      }
×
NEW
348
      const checkout = new PaymentRequestCheckoutService();
×
NEW
349
      const { deposit, clientSecret } = await checkout.startPayment(request);
×
NEW
350
      const response: PaymentRequestStartResponse = {
×
NEW
351
        paymentRequestId: request.id,
×
NEW
352
        stripeId: deposit.stripePaymentIntent.stripeId,
×
NEW
353
        clientSecret,
×
NEW
354
      };
×
NEW
355
      res.status(200).json(response);
×
NEW
356
    } catch (e) {
×
NEW
357
      if (e instanceof IllegalPaymentRequestTransitionError) {
×
NEW
358
        res.status(409).send(e.message);
×
NEW
359
        return;
×
NEW
360
      }
×
NEW
361
      if (e instanceof InvalidPaymentRequestBeneficiaryError) {
×
NEW
362
        res.status(400).send(e.message);
×
NEW
363
        return;
×
NEW
364
      }
×
NEW
365
      this.logger.error('Could not start payment for payment request:', e);
×
NEW
366
      res.status(500).send('Internal server error.');
×
NEW
367
    }
×
NEW
368
  }
×
369

370
  /**
1✔
371
   * POST /payment-requests/{id}/mark-fulfilled
372
   * @summary Admin escape hatch: mark a PaymentRequest paid out-of-band (e.g. bank transfer).
373
   *   Creates a void->user credit Transfer manually.
374
   * @operationId markPaymentRequestFulfilledExternally
375
   * @tags paymentRequests - Operations of the payment-request controller
376
   * @param {string} id.path.required - UUID v4 of the payment request.
377
   * @param {MarkFulfilledExternallyRequest} request.body.required - The audit reason
378
   * @security JWT
379
   * @return {BasePaymentRequestResponse} 200 - The marked-paid payment request
380
   * @return {string} 400 - Validation error
381
   * @return {string} 404 - Unknown id
382
   * @return {string} 409 - Request is not in PENDING state
383
   * @return {string} 500 - Internal server error
384
   */
1✔
385
  public async markFulfilledExternally(req: RequestWithToken, res: Response): Promise<void> {
1✔
386
    this.logger.trace('Mark fulfilled externally by user', req.token.user, 'id', req.params.id);
3✔
387
    const body = req.body as MarkFulfilledExternallyRequest;
3✔
388

389
    if (!body?.reason || body.reason.trim().length === 0) {
3✔
390
      res.status(400).send('reason is required.');
1✔
391
      return;
1✔
392
    }
1✔
393

394
    try {
2✔
395
      const request = await PaymentRequestController.loadPaymentRequest(
2✔
396
        req as RequestWithToken & { paymentRequest?: PaymentRequest | null },
2✔
397
      );
398
      if (!request) {
3!
NEW
399
        res.status(404).send();
×
NEW
400
        return;
×
NEW
401
      }
✔
402
      const service = new PaymentRequestService();
2✔
403
      const updated = await service.markFulfilledExternally(request, body.reason, req.token.user);
2✔
404
      res.status(200).json(PaymentRequestService.asBasePaymentRequestResponse(updated));
1✔
405
    } catch (e) {
1✔
406
      if (e instanceof IllegalPaymentRequestTransitionError) {
1✔
407
        res.status(409).send(e.message);
1✔
408
        return;
1✔
409
      }
1!
NEW
410
      this.logger.error('Could not mark payment request fulfilled:', e);
×
NEW
411
      res.status(500).send('Internal server error.');
×
NEW
412
    }
×
413
  }
3✔
414
}
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