@stamhoofd/backend 2.9.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,160 @@
1
- import { AutoEncoder, DateDecoder, EnumDecoder, field, IntegerDecoder, StringDecoder } from "@simonbackx/simple-encoding";
2
- import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
- import { Organization, Payment } from "@stamhoofd/models";
4
- import { PaymentGeneral, PaymentMethod, PaymentProvider, PaymentStatus } from "@stamhoofd/structures";
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
+ import { Decoder } from '@simonbackx/simple-encoding';
3
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
+ import { SimpleError } from '@simonbackx/simple-errors';
5
+ import { Payment } from '@stamhoofd/models';
6
+ import { SQLJsonUnquote, SQL, SQLConcat, SQLFilterDefinitions, SQLOrderBy, SQLOrderByDirection, SQLScalar, SQLSortDefinitions, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, createSQLFilterNamespace, createSQLRelationFilterCompiler, SQLCast } from "@stamhoofd/sql";
7
+ import { CountFilteredRequest, LimitedFilteredRequest, PaginatedResponse, PaymentGeneral, StamhoofdFilter, getSortFilter } from '@stamhoofd/structures';
8
+ import { Formatter } from '@stamhoofd/utility';
5
9
 
6
- import { StringArrayDecoder } from "../../../../decoders/StringArrayDecoder";
7
- import { AuthenticatedStructures } from "../../../../helpers/AuthenticatedStructures";
8
- import { Context } from "../../../../helpers/Context";
9
- import { StringNullableDecoder } from "../../../../decoders/StringNullableDecoder";
10
+ import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
11
+ import { Context } from '../../../../helpers/Context';
10
12
 
11
13
  type Params = Record<string, never>;
12
- type Body = undefined
13
- type ResponseBody = PaymentGeneral[]
14
+ type Query = LimitedFilteredRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>
14
17
 
18
+ const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
19
+ ...baseSQLFilterCompilers,
20
+ "id": createSQLColumnFilterCompiler(SQL.column('balance_item_payments', 'id')),
21
+ "price": createSQLColumnFilterCompiler(SQL.column('balance_item_payments', 'price')),
22
+
23
+ "balanceItem": createSQLFilterNamespace({
24
+ ...baseSQLFilterCompilers,
25
+ id: createSQLColumnFilterCompiler(SQL.column('balance_items', 'id')),
26
+ description: createSQLColumnFilterCompiler(SQL.column('balance_items', 'description')),
27
+ })
28
+ }
15
29
 
16
- class Query extends AutoEncoder {
17
- /**
18
- * Usage in combination with paidSince is special!
19
- */
20
- @field({ decoder: StringDecoder, optional: true })
21
- afterId?: string
22
-
23
- /**
24
- * Return all payments that were paid after (and including) this date.
25
- * Only returns orders **equal** to this date if afterId is not provided or if the id of those payments is also higher.
26
- */
27
- @field({ decoder: DateDecoder, optional: true })
28
- paidSince?: Date
29
-
30
- @field({ decoder: DateDecoder, optional: true })
31
- paidBefore?: Date
32
-
33
- @field({ decoder: IntegerDecoder, optional: true })
34
- limit?: number
35
-
36
- @field({ decoder: new StringArrayDecoder(new EnumDecoder(PaymentMethod)), optional: true })
37
- methods?: PaymentMethod[]
30
+ const filterCompilers: SQLFilterDefinitions = {
31
+ ...baseSQLFilterCompilers,
32
+ id: createSQLColumnFilterCompiler('id'),
33
+ method: createSQLColumnFilterCompiler('method'),
34
+ status: createSQLColumnFilterCompiler('status'),
35
+ organizationId: createSQLColumnFilterCompiler('organizationId'),
36
+ createdAt: createSQLColumnFilterCompiler('createdAt'),
37
+ paidAt: createSQLColumnFilterCompiler('paidAt', {nullable: true}),
38
+ price: createSQLColumnFilterCompiler('price'),
39
+ provider: createSQLColumnFilterCompiler('provider', {nullable: true}),
40
+ customer: createSQLFilterNamespace({
41
+ ...baseSQLFilterCompilers,
42
+ email: createSQLExpressionFilterCompiler(
43
+ SQL.jsonValue(SQL.column('customer'), '$.value.email'),
44
+ {isJSONValue: true}
45
+ ),
46
+ firstName: createSQLExpressionFilterCompiler(
47
+ SQL.jsonValue(SQL.column('customer'), '$.value.firstName'),
48
+ {isJSONValue: true}
49
+ ),
50
+ lastName: createSQLExpressionFilterCompiler(
51
+ SQL.jsonValue(SQL.column('customer'), '$.value.lastName'),
52
+ {isJSONValue: true}
53
+ ),
54
+ name: createSQLExpressionFilterCompiler(
55
+ new SQLCast(
56
+ new SQLConcat(
57
+ new SQLJsonUnquote(SQL.jsonValue(SQL.column('customer'), '$.value.firstName')),
58
+ new SQLScalar(' '),
59
+ new SQLJsonUnquote(SQL.jsonValue(SQL.column('customer'), '$.value.lastName')),
60
+ ),
61
+ 'CHAR'
62
+ )
63
+ ),
64
+ company: createSQLFilterNamespace({
65
+ name: createSQLExpressionFilterCompiler(
66
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.name'),
67
+ {isJSONValue: true}
68
+ ),
69
+ VATNumber: createSQLExpressionFilterCompiler(
70
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.VATNumber'),
71
+ {isJSONValue: true}
72
+ ),
73
+ companyNumber: createSQLExpressionFilterCompiler(
74
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.companyNumber'),
75
+ {isJSONValue: true}
76
+ ),
77
+ administrationEmail: createSQLExpressionFilterCompiler(
78
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.administrationEmail'),
79
+ {isJSONValue: true}
80
+ ),
81
+ })
82
+ }),
83
+ balanceItemPayments: createSQLRelationFilterCompiler(
84
+ SQL.select()
85
+ .from(
86
+ SQL.table('balance_item_payments')
87
+ ).join(
88
+ SQL.join(
89
+ SQL.table('balance_items')
90
+ ).where(
91
+ SQL.column('balance_items', 'id'),
92
+ SQL.column('balance_item_payments', 'balanceItemId')
93
+ )
94
+ ).where(
95
+ SQL.column('paymentId'),
96
+ SQL.column('payments', 'id')
97
+ ),
98
+ balanceItemPaymentsCompilers
99
+ ),
100
+ }
38
101
 
39
- @field({ decoder: new StringArrayDecoder(new StringNullableDecoder(new EnumDecoder(PaymentProvider))), optional: true })
40
- providers?: (PaymentProvider|null)[]
102
+ const sorters: SQLSortDefinitions<Payment> = {
103
+ 'id': {
104
+ getValue(a) {
105
+ return a.id
106
+ },
107
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
108
+ return new SQLOrderBy({
109
+ column: SQL.column('id'),
110
+ direction
111
+ })
112
+ }
113
+ },
114
+ 'createdAt': {
115
+ getValue(a) {
116
+ return Formatter.dateTimeIso(a.createdAt, 'UTC')
117
+ },
118
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
119
+ return new SQLOrderBy({
120
+ column: SQL.column('createdAt'),
121
+ direction
122
+ })
123
+ }
124
+ },
125
+ 'paidAt': {
126
+ getValue(a) {
127
+ return a.paidAt !== null ? Formatter.dateTimeIso(a.paidAt, 'UTC') : null
128
+ },
129
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
130
+ return new SQLOrderBy({
131
+ column: SQL.column('paidAt'),
132
+ direction
133
+ })
134
+ }
135
+ },
136
+ 'price': {
137
+ getValue(a) {
138
+ return a.price
139
+ },
140
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
141
+ return new SQLOrderBy({
142
+ column: SQL.column('price'),
143
+ direction
144
+ })
145
+ }
146
+ }
41
147
  }
42
148
 
43
149
  export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
44
- protected queryDecoder = Query;
150
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>
45
151
 
46
152
  protected doesMatch(request: Request): [true, Params] | [false] {
47
153
  if (request.method != "GET") {
48
154
  return [false];
49
155
  }
50
156
 
51
- const params = Endpoint.parseParameters(request.url, "/organization/payments", {});
157
+ const params = Endpoint.parseParameters(request.url, "/payments", {});
52
158
 
53
159
  if (params) {
54
160
  return [true, params as Params];
@@ -56,115 +162,168 @@ export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseB
56
162
  return [false];
57
163
  }
58
164
 
59
- async handle(request: DecodedRequest<Params, Query, Body>) {
60
- const organization = await Context.setOrganizationScope();
61
- await Context.authenticate()
165
+ static async buildQuery(q: CountFilteredRequest|LimitedFilteredRequest) {
166
+ const organization = Context.organization
167
+ let scopeFilter: StamhoofdFilter|undefined = undefined;
168
+
169
+ if (!organization) {
170
+ throw Context.auth.error()
171
+ }
62
172
 
63
173
  if (!await Context.auth.canManagePayments(organization.id)) {
64
174
  throw Context.auth.error()
65
- }
175
+ }
66
176
 
67
- return new Response(
68
- (await this.getPayments(organization, request.query))
69
- );
70
- }
177
+ scopeFilter = {
178
+ organizationId: organization.id
179
+ };
180
+
181
+ const query = SQL
182
+ .select()
183
+ .from(
184
+ SQL.table('payments')
185
+ );
186
+
187
+ if (scopeFilter) {
188
+ query.where(compileToSQLFilter(scopeFilter, filterCompilers))
189
+ }
190
+
191
+ if (q.filter) {
192
+ query.where(compileToSQLFilter(q.filter, filterCompilers))
193
+ }
194
+
195
+ if (q.search) {
196
+ // todo
71
197
 
72
- async getPayments(organization: Organization, query: Query) {
73
- const paidSince = query.paidSince ?? new Date(Date.now() - (24 * 60 * 60 * 1000 * 7 ))
74
- paidSince.setMilliseconds(0)
75
- const payments: Payment[] = []
76
-
77
- if (query.afterId) {
78
- // First return all payments with id > afterId and paidAt == paidSince
79
- payments.push(...await Payment.where({
80
- organizationId: organization.id,
81
- paidAt: {
82
- sign: '=',
83
- value: paidSince
84
- },
85
- id: {
86
- sign: '>',
87
- value: query.afterId ?? ""
88
- },
89
- method: {
90
- sign: 'IN',
91
- value: query.methods ?? [PaymentMethod.Transfer]
92
- },
93
- provider: {
94
- sign: 'IN',
95
- value: query.providers ?? [null]
198
+ let searchFilter: StamhoofdFilter|null = null
199
+ searchFilter = {
200
+ $or: [
201
+ {
202
+ customer: {
203
+ name: {
204
+ $contains: q.search
205
+ }
206
+ }
207
+ },
208
+ {
209
+ customer: {
210
+ company: {
211
+ name: {
212
+ $contains: q.search
213
+ }
214
+ }
215
+ }
216
+ },
217
+ {
218
+ balanceItemPayments: {
219
+ $elemMatch: {
220
+ balanceItem: {
221
+ description: {
222
+ $contains: q.search
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+ ]
229
+ }
230
+
231
+ if (q.search.includes('@')) {
232
+ searchFilter = {
233
+ $or: [
234
+ {
235
+ customer: {
236
+ email: {
237
+ $contains: q.search
238
+ }
239
+ }
240
+ },
241
+ {
242
+ customer: {
243
+ company: {
244
+ administrationEmail: {
245
+ $contains: q.search
246
+ }
247
+ }
248
+ }
249
+ },
250
+ ]
96
251
  }
97
- }, {
98
- limit: query.limit ?? undefined,
99
- sort: [{
100
- column: "id",
101
- direction: "ASC"
102
- }]
103
- }));
252
+ }
253
+
254
+ if (searchFilter) {
255
+ query.where(compileToSQLFilter(searchFilter, filterCompilers))
256
+ }
104
257
  }
105
258
 
106
- payments.push(...await Payment.where({
107
- organizationId: organization.id,
108
- paidAt: query.paidBefore ? [{
109
- sign: query.afterId ? '>' : '>=',
110
- value: paidSince
111
- }, {
112
- sign: '<=',
113
- value: query.paidBefore
114
- }] : {
115
- sign: query.afterId ? '>' : '>=',
116
- value: paidSince
117
- },
118
- method: {
119
- sign: 'IN',
120
- value: query.methods ?? [PaymentMethod.Transfer]
121
- },
122
- provider: {
123
- sign: 'IN',
124
- value: query.providers ?? [null]
259
+ if (q instanceof LimitedFilteredRequest) {
260
+ if (q.pageFilter) {
261
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers))
125
262
  }
126
- }, {
127
- limit: query.limit ? (query.limit - payments.length) : undefined,
128
- sort: [{
129
- column: "paidAt",
130
- direction: "ASC"
131
- },
132
- {
133
- column: "id",
134
- direction: "ASC"
135
- }]
136
- }));
137
-
138
-
139
- if (!query.paidSince && !query.methods && !query.providers) {
140
- // Default behaviour is to return all not-paid transfer payments that are not yet paid
141
-
142
- payments.push(...
143
- await Payment.where({
144
- organizationId: organization.id,
145
- paidAt: null,
146
- method: PaymentMethod.Transfer,
147
- status: {
148
- sign: '!=',
149
- value: PaymentStatus.Failed
150
- }
151
- })
152
- );
153
263
 
154
- payments.push(...
155
- await Payment.where({
156
- organizationId: organization.id,
157
- paidAt: null,
158
- updatedAt: {
159
- sign: '>',
160
- value: new Date(Date.now() - (24 * 60 * 60 * 1000 * 7 ))
161
- },
162
- method: PaymentMethod.Transfer,
163
- status: PaymentStatus.Failed
164
- })
165
- );
264
+ query.orderBy(compileToSQLSorter(q.sort, sorters))
265
+ query.limit(q.limit)
166
266
  }
267
+
268
+ return query
269
+ }
270
+
271
+ static async buildData(requestQuery: LimitedFilteredRequest) {
272
+ const query = await this.buildQuery(requestQuery)
273
+ const data = await query.fetch()
274
+
275
+ const payments = Payment.fromRows(data, 'payments')
276
+
277
+ let next: LimitedFilteredRequest|undefined;
167
278
 
168
- return await AuthenticatedStructures.paymentsGeneral(payments, true)
279
+ if (payments.length >= requestQuery.limit) {
280
+ const lastObject = payments[payments.length - 1];
281
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
282
+
283
+ next = new LimitedFilteredRequest({
284
+ filter: requestQuery.filter,
285
+ pageFilter: nextFilter,
286
+ sort: requestQuery.sort,
287
+ limit: requestQuery.limit,
288
+ search: requestQuery.search
289
+ })
290
+
291
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
292
+ console.error('Found infinite loading loop for', requestQuery);
293
+ next = undefined;
294
+ }
295
+ }
296
+
297
+ return new PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>({
298
+ results: await AuthenticatedStructures.paymentsGeneral(payments, false),
299
+ next
300
+ });
301
+ }
302
+
303
+ async handle(request: DecodedRequest<Params, Query, Body>) {
304
+ await Context.setOrganizationScope();
305
+ await Context.authenticate()
306
+
307
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
308
+
309
+ if (request.query.limit > maxLimit) {
310
+ throw new SimpleError({
311
+ code: 'invalid_field',
312
+ field: 'limit',
313
+ message: 'Limit can not be more than ' + maxLimit
314
+ })
315
+ }
316
+
317
+ if (request.query.limit < 1) {
318
+ throw new SimpleError({
319
+ code: 'invalid_field',
320
+ field: 'limit',
321
+ message: 'Limit can not be less than 1'
322
+ })
323
+ }
324
+
325
+ return new Response(
326
+ await GetPaymentsEndpoint.buildData(request.query)
327
+ );
169
328
  }
170
329
  }
@@ -0,0 +1,170 @@
1
+ import { AutoEncoder, DateDecoder, EnumDecoder, field, IntegerDecoder, StringDecoder } from "@simonbackx/simple-encoding";
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { Organization, Payment } from "@stamhoofd/models";
4
+ import { PaymentGeneral, PaymentMethod, PaymentProvider, PaymentStatus } from "@stamhoofd/structures";
5
+
6
+ import { StringArrayDecoder } from "../../../../../decoders/StringArrayDecoder";
7
+ import { AuthenticatedStructures } from "../../../../../helpers/AuthenticatedStructures";
8
+ import { Context } from "../../../../../helpers/Context";
9
+ import { StringNullableDecoder } from "../../../../../decoders/StringNullableDecoder";
10
+
11
+ type Params = Record<string, never>;
12
+ type Body = undefined
13
+ type ResponseBody = PaymentGeneral[]
14
+
15
+
16
+ class Query extends AutoEncoder {
17
+ /**
18
+ * Usage in combination with paidSince is special!
19
+ */
20
+ @field({ decoder: StringDecoder, optional: true })
21
+ afterId?: string
22
+
23
+ /**
24
+ * Return all payments that were paid after (and including) this date.
25
+ * Only returns orders **equal** to this date if afterId is not provided or if the id of those payments is also higher.
26
+ */
27
+ @field({ decoder: DateDecoder, optional: true })
28
+ paidSince?: Date
29
+
30
+ @field({ decoder: DateDecoder, optional: true })
31
+ paidBefore?: Date
32
+
33
+ @field({ decoder: IntegerDecoder, optional: true })
34
+ limit?: number
35
+
36
+ @field({ decoder: new StringArrayDecoder(new EnumDecoder(PaymentMethod)), optional: true })
37
+ methods?: PaymentMethod[]
38
+
39
+ @field({ decoder: new StringArrayDecoder(new StringNullableDecoder(new EnumDecoder(PaymentProvider))), optional: true })
40
+ providers?: (PaymentProvider|null)[]
41
+ }
42
+
43
+ export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
44
+ protected queryDecoder = Query;
45
+
46
+ protected doesMatch(request: Request): [true, Params] | [false] {
47
+ if (request.method != "GET") {
48
+ return [false];
49
+ }
50
+
51
+ const params = Endpoint.parseParameters(request.url, "/organization/payments", {});
52
+
53
+ if (params) {
54
+ return [true, params as Params];
55
+ }
56
+ return [false];
57
+ }
58
+
59
+ async handle(request: DecodedRequest<Params, Query, Body>) {
60
+ const organization = await Context.setOrganizationScope();
61
+ await Context.authenticate()
62
+
63
+ if (!await Context.auth.canManagePayments(organization.id)) {
64
+ throw Context.auth.error()
65
+ }
66
+
67
+ return new Response(
68
+ (await this.getPayments(organization, request.query))
69
+ );
70
+ }
71
+
72
+ async getPayments(organization: Organization, query: Query) {
73
+ const paidSince = query.paidSince ?? new Date(Date.now() - (24 * 60 * 60 * 1000 * 7 ))
74
+ paidSince.setMilliseconds(0)
75
+ const payments: Payment[] = []
76
+
77
+ if (query.afterId) {
78
+ // First return all payments with id > afterId and paidAt == paidSince
79
+ payments.push(...await Payment.where({
80
+ organizationId: organization.id,
81
+ paidAt: {
82
+ sign: '=',
83
+ value: paidSince
84
+ },
85
+ id: {
86
+ sign: '>',
87
+ value: query.afterId ?? ""
88
+ },
89
+ method: {
90
+ sign: 'IN',
91
+ value: query.methods ?? [PaymentMethod.Transfer]
92
+ },
93
+ provider: {
94
+ sign: 'IN',
95
+ value: query.providers ?? [null]
96
+ }
97
+ }, {
98
+ limit: query.limit ?? undefined,
99
+ sort: [{
100
+ column: "id",
101
+ direction: "ASC"
102
+ }]
103
+ }));
104
+ }
105
+
106
+ payments.push(...await Payment.where({
107
+ organizationId: organization.id,
108
+ paidAt: query.paidBefore ? [{
109
+ sign: query.afterId ? '>' : '>=',
110
+ value: paidSince
111
+ }, {
112
+ sign: '<=',
113
+ value: query.paidBefore
114
+ }] : {
115
+ sign: query.afterId ? '>' : '>=',
116
+ value: paidSince
117
+ },
118
+ method: {
119
+ sign: 'IN',
120
+ value: query.methods ?? [PaymentMethod.Transfer]
121
+ },
122
+ provider: {
123
+ sign: 'IN',
124
+ value: query.providers ?? [null]
125
+ }
126
+ }, {
127
+ limit: query.limit ? (query.limit - payments.length) : undefined,
128
+ sort: [{
129
+ column: "paidAt",
130
+ direction: "ASC"
131
+ },
132
+ {
133
+ column: "id",
134
+ direction: "ASC"
135
+ }]
136
+ }));
137
+
138
+
139
+ if (!query.paidSince && !query.methods && !query.providers) {
140
+ // Default behaviour is to return all not-paid transfer payments that are not yet paid
141
+
142
+ payments.push(...
143
+ await Payment.where({
144
+ organizationId: organization.id,
145
+ paidAt: null,
146
+ method: PaymentMethod.Transfer,
147
+ status: {
148
+ sign: '!=',
149
+ value: PaymentStatus.Failed
150
+ }
151
+ })
152
+ );
153
+
154
+ payments.push(...
155
+ await Payment.where({
156
+ organizationId: organization.id,
157
+ paidAt: null,
158
+ updatedAt: {
159
+ sign: '>',
160
+ value: new Date(Date.now() - (24 * 60 * 60 * 1000 * 7 ))
161
+ },
162
+ method: PaymentMethod.Transfer,
163
+ status: PaymentStatus.Failed
164
+ })
165
+ );
166
+ }
167
+
168
+ return await AuthenticatedStructures.paymentsGeneral(payments, true)
169
+ }
170
+ }