@stamhoofd/backend 2.9.0 → 2.14.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,161 @@
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 { SQL, SQLCast, SQLConcat, SQLFilterDefinitions, SQLJsonUnquote, SQLOrderBy, SQLOrderByDirection, SQLScalar, SQLSortDefinitions, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, createSQLFilterNamespace, createSQLRelationFilterCompiler } 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
+ updatedAt: createSQLColumnFilterCompiler('updatedAt'),
38
+ paidAt: createSQLColumnFilterCompiler('paidAt', {nullable: true}),
39
+ price: createSQLColumnFilterCompiler('price'),
40
+ provider: createSQLColumnFilterCompiler('provider', {nullable: true}),
41
+ customer: createSQLFilterNamespace({
42
+ ...baseSQLFilterCompilers,
43
+ email: createSQLExpressionFilterCompiler(
44
+ SQL.jsonValue(SQL.column('customer'), '$.value.email'),
45
+ {isJSONValue: true}
46
+ ),
47
+ firstName: createSQLExpressionFilterCompiler(
48
+ SQL.jsonValue(SQL.column('customer'), '$.value.firstName'),
49
+ {isJSONValue: true}
50
+ ),
51
+ lastName: createSQLExpressionFilterCompiler(
52
+ SQL.jsonValue(SQL.column('customer'), '$.value.lastName'),
53
+ {isJSONValue: true}
54
+ ),
55
+ name: createSQLExpressionFilterCompiler(
56
+ new SQLCast(
57
+ new SQLConcat(
58
+ new SQLJsonUnquote(SQL.jsonValue(SQL.column('customer'), '$.value.firstName')),
59
+ new SQLScalar(' '),
60
+ new SQLJsonUnquote(SQL.jsonValue(SQL.column('customer'), '$.value.lastName')),
61
+ ),
62
+ 'CHAR'
63
+ )
64
+ ),
65
+ company: createSQLFilterNamespace({
66
+ name: createSQLExpressionFilterCompiler(
67
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.name'),
68
+ {isJSONValue: true}
69
+ ),
70
+ VATNumber: createSQLExpressionFilterCompiler(
71
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.VATNumber'),
72
+ {isJSONValue: true}
73
+ ),
74
+ companyNumber: createSQLExpressionFilterCompiler(
75
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.companyNumber'),
76
+ {isJSONValue: true}
77
+ ),
78
+ administrationEmail: createSQLExpressionFilterCompiler(
79
+ SQL.jsonValue(SQL.column('customer'), '$.value.company.administrationEmail'),
80
+ {isJSONValue: true}
81
+ ),
82
+ })
83
+ }),
84
+ balanceItemPayments: createSQLRelationFilterCompiler(
85
+ SQL.select()
86
+ .from(
87
+ SQL.table('balance_item_payments')
88
+ ).join(
89
+ SQL.join(
90
+ SQL.table('balance_items')
91
+ ).where(
92
+ SQL.column('balance_items', 'id'),
93
+ SQL.column('balance_item_payments', 'balanceItemId')
94
+ )
95
+ ).where(
96
+ SQL.column('paymentId'),
97
+ SQL.column('payments', 'id')
98
+ ),
99
+ balanceItemPaymentsCompilers
100
+ ),
101
+ }
38
102
 
39
- @field({ decoder: new StringArrayDecoder(new StringNullableDecoder(new EnumDecoder(PaymentProvider))), optional: true })
40
- providers?: (PaymentProvider|null)[]
103
+ const sorters: SQLSortDefinitions<Payment> = {
104
+ 'id': {
105
+ getValue(a) {
106
+ return a.id
107
+ },
108
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
109
+ return new SQLOrderBy({
110
+ column: SQL.column('id'),
111
+ direction
112
+ })
113
+ }
114
+ },
115
+ 'createdAt': {
116
+ getValue(a) {
117
+ return Formatter.dateTimeIso(a.createdAt, 'UTC')
118
+ },
119
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
120
+ return new SQLOrderBy({
121
+ column: SQL.column('createdAt'),
122
+ direction
123
+ })
124
+ }
125
+ },
126
+ 'paidAt': {
127
+ getValue(a) {
128
+ return a.paidAt !== null ? Formatter.dateTimeIso(a.paidAt, 'UTC') : null
129
+ },
130
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
131
+ return new SQLOrderBy({
132
+ column: SQL.column('paidAt'),
133
+ direction
134
+ })
135
+ }
136
+ },
137
+ 'price': {
138
+ getValue(a) {
139
+ return a.price
140
+ },
141
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
142
+ return new SQLOrderBy({
143
+ column: SQL.column('price'),
144
+ direction
145
+ })
146
+ }
147
+ }
41
148
  }
42
149
 
43
150
  export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
44
- protected queryDecoder = Query;
151
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>
45
152
 
46
153
  protected doesMatch(request: Request): [true, Params] | [false] {
47
154
  if (request.method != "GET") {
48
155
  return [false];
49
156
  }
50
157
 
51
- const params = Endpoint.parseParameters(request.url, "/organization/payments", {});
158
+ const params = Endpoint.parseParameters(request.url, "/payments", {});
52
159
 
53
160
  if (params) {
54
161
  return [true, params as Params];
@@ -56,115 +163,168 @@ export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseB
56
163
  return [false];
57
164
  }
58
165
 
59
- async handle(request: DecodedRequest<Params, Query, Body>) {
60
- const organization = await Context.setOrganizationScope();
61
- await Context.authenticate()
166
+ static async buildQuery(q: CountFilteredRequest|LimitedFilteredRequest) {
167
+ const organization = Context.organization
168
+ let scopeFilter: StamhoofdFilter|undefined = undefined;
169
+
170
+ if (!organization) {
171
+ throw Context.auth.error()
172
+ }
62
173
 
63
174
  if (!await Context.auth.canManagePayments(organization.id)) {
64
175
  throw Context.auth.error()
65
- }
176
+ }
66
177
 
67
- return new Response(
68
- (await this.getPayments(organization, request.query))
69
- );
70
- }
178
+ scopeFilter = {
179
+ organizationId: organization.id
180
+ };
181
+
182
+ const query = SQL
183
+ .select()
184
+ .from(
185
+ SQL.table('payments')
186
+ );
187
+
188
+ if (scopeFilter) {
189
+ query.where(compileToSQLFilter(scopeFilter, filterCompilers))
190
+ }
191
+
192
+ if (q.filter) {
193
+ query.where(compileToSQLFilter(q.filter, filterCompilers))
194
+ }
195
+
196
+ if (q.search) {
197
+ // todo
71
198
 
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]
199
+ let searchFilter: StamhoofdFilter|null = null
200
+ searchFilter = {
201
+ $or: [
202
+ {
203
+ customer: {
204
+ name: {
205
+ $contains: q.search
206
+ }
207
+ }
208
+ },
209
+ {
210
+ customer: {
211
+ company: {
212
+ name: {
213
+ $contains: q.search
214
+ }
215
+ }
216
+ }
217
+ },
218
+ {
219
+ balanceItemPayments: {
220
+ $elemMatch: {
221
+ balanceItem: {
222
+ description: {
223
+ $contains: q.search
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ ]
230
+ }
231
+
232
+ if (q.search.includes('@')) {
233
+ searchFilter = {
234
+ $or: [
235
+ {
236
+ customer: {
237
+ email: {
238
+ $contains: q.search
239
+ }
240
+ }
241
+ },
242
+ {
243
+ customer: {
244
+ company: {
245
+ administrationEmail: {
246
+ $contains: q.search
247
+ }
248
+ }
249
+ }
250
+ },
251
+ ]
96
252
  }
97
- }, {
98
- limit: query.limit ?? undefined,
99
- sort: [{
100
- column: "id",
101
- direction: "ASC"
102
- }]
103
- }));
253
+ }
254
+
255
+ if (searchFilter) {
256
+ query.where(compileToSQLFilter(searchFilter, filterCompilers))
257
+ }
104
258
  }
105
259
 
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]
260
+ if (q instanceof LimitedFilteredRequest) {
261
+ if (q.pageFilter) {
262
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers))
125
263
  }
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
264
 
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
- );
265
+ query.orderBy(compileToSQLSorter(q.sort, sorters))
266
+ query.limit(q.limit)
166
267
  }
268
+
269
+ return query
270
+ }
271
+
272
+ static async buildData(requestQuery: LimitedFilteredRequest) {
273
+ const query = await this.buildQuery(requestQuery)
274
+ const data = await query.fetch()
275
+
276
+ const payments = Payment.fromRows(data, 'payments')
277
+
278
+ let next: LimitedFilteredRequest|undefined;
167
279
 
168
- return await AuthenticatedStructures.paymentsGeneral(payments, true)
280
+ if (payments.length >= requestQuery.limit) {
281
+ const lastObject = payments[payments.length - 1];
282
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
283
+
284
+ next = new LimitedFilteredRequest({
285
+ filter: requestQuery.filter,
286
+ pageFilter: nextFilter,
287
+ sort: requestQuery.sort,
288
+ limit: requestQuery.limit,
289
+ search: requestQuery.search
290
+ })
291
+
292
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
293
+ console.error('Found infinite loading loop for', requestQuery);
294
+ next = undefined;
295
+ }
296
+ }
297
+
298
+ return new PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>({
299
+ results: await AuthenticatedStructures.paymentsGeneral(payments, false),
300
+ next
301
+ });
302
+ }
303
+
304
+ async handle(request: DecodedRequest<Params, Query, Body>) {
305
+ await Context.setOrganizationScope();
306
+ await Context.authenticate()
307
+
308
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
309
+
310
+ if (request.query.limit > maxLimit) {
311
+ throw new SimpleError({
312
+ code: 'invalid_field',
313
+ field: 'limit',
314
+ message: 'Limit can not be more than ' + maxLimit
315
+ })
316
+ }
317
+
318
+ if (request.query.limit < 1) {
319
+ throw new SimpleError({
320
+ code: 'invalid_field',
321
+ field: 'limit',
322
+ message: 'Limit can not be less than 1'
323
+ })
324
+ }
325
+
326
+ return new Response(
327
+ await GetPaymentsEndpoint.buildData(request.query)
328
+ );
169
329
  }
170
330
  }
@@ -169,7 +169,7 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
169
169
  })
170
170
  }
171
171
 
172
- if (patch.method || patch.paidAt !== undefined) {
172
+ if (patch.method || patch.paidAt !== undefined || patch.status !== undefined) {
173
173
  if (payment.method && ![PaymentMethod.Unknown, PaymentMethod.Transfer, PaymentMethod.PointOfSale].includes(payment.method)) {
174
174
  throw new SimpleError({
175
175
  code: "invalid_field",
@@ -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
+ }