@stamhoofd/backend 2.107.3 → 2.109.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.
Files changed (44) hide show
  1. package/index.ts +2 -2
  2. package/package.json +20 -20
  3. package/src/crons/disable-auto-update-documents.test.ts +164 -0
  4. package/src/crons/disable-auto-update-documents.ts +82 -0
  5. package/src/endpoints/admin/members/ChargeMembersEndpoint.ts +5 -5
  6. package/src/endpoints/admin/registrations/ChargeRegistrationsEndpoint.ts +87 -0
  7. package/src/endpoints/global/members/GetMembersCountEndpoint.ts +2 -2
  8. package/src/endpoints/global/members/GetMembersEndpoint.ts +5 -5
  9. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +2 -2
  10. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +14 -11
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +9 -8
  12. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesCountEndpoint.ts +48 -0
  13. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +95 -19
  14. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +282 -0
  15. package/src/endpoints/organization/dashboard/documents/{PatchDocumentTemplateEndpoint.ts → PatchDocumentTemplatesEndpoint.ts} +56 -3
  16. package/src/excel-loaders/members.ts +62 -8
  17. package/src/excel-loaders/registrations.ts +180 -9
  18. package/src/helpers/LimitedFilteredRequestHelper.ts +24 -0
  19. package/src/helpers/MemberCharger.ts +16 -4
  20. package/src/helpers/SQLTranslatedString.ts +14 -0
  21. package/src/helpers/TagHelper.test.ts +9 -9
  22. package/src/helpers/fetchToAsyncIterator.ts +1 -1
  23. package/src/helpers/outstandingBalanceJoin.ts +49 -0
  24. package/src/seeds/1765896674-document-update-year.test.ts +179 -0
  25. package/src/seeds/1765896674-document-update-year.ts +75 -0
  26. package/src/seeds/1766150402-document-published-at.test.ts +46 -0
  27. package/src/seeds/1766150402-document-published-at.ts +20 -0
  28. package/src/services/PaymentService.ts +14 -32
  29. package/src/sql-filters/base-registration-filter-compilers.ts +51 -4
  30. package/src/sql-filters/document-templates.ts +45 -0
  31. package/src/sql-filters/documents.ts +1 -1
  32. package/src/sql-filters/events.ts +6 -6
  33. package/src/sql-filters/groups.ts +7 -6
  34. package/src/sql-filters/members.ts +31 -26
  35. package/src/sql-filters/orders.ts +16 -16
  36. package/src/sql-filters/organizations.ts +11 -11
  37. package/src/sql-filters/payments.ts +10 -10
  38. package/src/sql-filters/registrations.ts +14 -6
  39. package/src/sql-sorters/document-templates.ts +79 -0
  40. package/src/sql-sorters/documents.ts +1 -1
  41. package/src/sql-sorters/members.ts +22 -0
  42. package/src/sql-sorters/orders.ts +5 -5
  43. package/src/sql-sorters/organizations.ts +3 -3
  44. package/src/sql-sorters/registrations.ts +186 -15
@@ -1,16 +1,25 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { SimpleError } from '@simonbackx/simple-errors';
3
- import { DocumentTemplate, Token } from '@stamhoofd/models';
4
- import { DocumentTemplatePrivate } from '@stamhoofd/structures';
2
+ import { DocumentTemplate } from '@stamhoofd/models';
3
+ import { assertSort, CountFilteredRequest, DocumentTemplatePrivate, LimitedFilteredRequest, PaginatedResponse, SearchFilterFactory, StamhoofdFilter } from '@stamhoofd/structures';
5
4
 
6
- import { Context } from '../../../../helpers/Context';
5
+ import { Decoder } from '@simonbackx/simple-encoding';
6
+ import { applySQLSorter, compileToSQLFilter, SQL, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
7
+ import { Context } from '../../../../helpers/Context.js';
8
+ import { LimitedFilteredRequestHelper } from '../../../../helpers/LimitedFilteredRequestHelper.js';
9
+ import { documentTemplateFilterCompilers } from '../../../../sql-filters/document-templates.js';
10
+ import { documentTemplateSorters } from '../../../../sql-sorters/document-templates.js';
7
11
 
8
12
  type Params = Record<string, never>;
9
- type Query = undefined;
13
+ type Query = LimitedFilteredRequest;
10
14
  type Body = undefined;
11
- type ResponseBody = DocumentTemplatePrivate[];
15
+ type ResponseBody = PaginatedResponse<DocumentTemplatePrivate[], LimitedFilteredRequest>;
16
+
17
+ const filterCompilers: SQLFilterDefinitions = documentTemplateFilterCompilers;
18
+ const sorters: SQLSortDefinitions<DocumentTemplate> = documentTemplateSorters;
12
19
 
13
20
  export class GetDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
21
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
22
+
14
23
  protected doesMatch(request: Request): [true, Params] | [false] {
15
24
  if (request.method !== 'GET') {
16
25
  return [false];
@@ -24,7 +33,60 @@ export class GetDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body,
24
33
  return [false];
25
34
  }
26
35
 
27
- async handle(_: DecodedRequest<Params, Query, Body>) {
36
+ static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
37
+ const organization = Context.organization!;
38
+
39
+ const templatesTable: string = DocumentTemplate.table;
40
+
41
+ const query = SQL
42
+ .select(SQL.wildcard(templatesTable))
43
+ .from(SQL.table(templatesTable))
44
+ .where('organizationId', organization.id);
45
+
46
+ if (q.filter) {
47
+ query.where(await compileToSQLFilter(q.filter, filterCompilers));
48
+ }
49
+
50
+ if (q.search) {
51
+ const searchFilter: StamhoofdFilter | null = getDocumentTemplateSearchFilter(q.search);
52
+
53
+ if (searchFilter) {
54
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
55
+ }
56
+ }
57
+
58
+ if (q instanceof LimitedFilteredRequest) {
59
+ if (q.pageFilter) {
60
+ query.where(await compileToSQLFilter(q.pageFilter, filterCompilers));
61
+ }
62
+
63
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
64
+ applySQLSorter(query, q.sort, sorters);
65
+ query.limit(q.limit);
66
+ }
67
+
68
+ return query;
69
+ }
70
+
71
+ static async buildData(requestQuery: LimitedFilteredRequest) {
72
+ const query = await this.buildQuery(requestQuery);
73
+ const data = await query.fetch();
74
+
75
+ const templates: DocumentTemplate[] = DocumentTemplate.fromRows(data, DocumentTemplate.table);
76
+
77
+ const next = LimitedFilteredRequestHelper.fixInfiniteLoadingLoop({
78
+ request: requestQuery,
79
+ results: templates,
80
+ sorters,
81
+ });
82
+
83
+ return new PaginatedResponse<DocumentTemplatePrivate[], LimitedFilteredRequest>({
84
+ results: templates.map(t => t.getPrivateStructure()),
85
+ next,
86
+ });
87
+ }
88
+
89
+ async handle(request: DecodedRequest<Params, Query, Body>) {
28
90
  const organization = await Context.setOrganizationScope();
29
91
  await Context.authenticate();
30
92
 
@@ -32,19 +94,33 @@ export class GetDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body,
32
94
  throw Context.auth.error();
33
95
  }
34
96
 
35
- const templates = await DocumentTemplate.where(
36
- {
37
- organizationId: organization.id,
38
- },
39
- {
40
- sort: [{
41
- column: 'createdAt',
42
- direction: 'ASC',
43
- }],
44
- },
45
- );
97
+ LimitedFilteredRequestHelper.throwIfInvalidLimit({
98
+ request: request.query,
99
+ maxLimit: Context.auth.hasSomePlatformAccess() ? 1000 : 100,
100
+ });
101
+
46
102
  return new Response(
47
- templates.map(t => t.getPrivateStructure()),
103
+ await GetDocumentTemplatesEndpoint.buildData(request.query),
48
104
  );
49
105
  }
50
106
  }
107
+
108
+ function getDocumentTemplateSearchFilter(search: string | null): StamhoofdFilter | null {
109
+ if (search === null || search === undefined) {
110
+ return null;
111
+ }
112
+
113
+ const numberFilter = SearchFilterFactory.getIntegerFilter(search);
114
+
115
+ if (numberFilter) {
116
+ return {
117
+ year: numberFilter,
118
+ };
119
+ }
120
+
121
+ return {
122
+ name: {
123
+ $contains: search,
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,282 @@
1
+ import { PatchableArray } from '@simonbackx/simple-encoding';
2
+ import { Endpoint, Request } from '@simonbackx/simple-endpoints';
3
+ import { DocumentTemplate, DocumentTemplateFactory, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
4
+ import { SQL } from '@stamhoofd/sql';
5
+ import { DocumentPrivateSettings, DocumentStatus, DocumentTemplateDefinition, DocumentTemplatePrivate, PermissionLevel, Permissions } from '@stamhoofd/structures';
6
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
8
+ import { PatchDocumentTemplatesEndpoint } from './PatchDocumentTemplatesEndpoint.js';
9
+
10
+ const baseUrl = `/organization/document-templates`;
11
+ const endpoint = new PatchDocumentTemplatesEndpoint();
12
+ type EndpointType = typeof endpoint;
13
+ type Body = EndpointType extends Endpoint<any, any, infer B, any> ? B : never;
14
+
15
+ describe('Endpoint.PatchDocumentTemplatesEndpoint', () => {
16
+ let period: RegistrationPeriod;
17
+ let organization: Organization;
18
+ let user: User;
19
+ let token: Token;
20
+
21
+ beforeEach(async () => {
22
+ TestUtils.setEnvironment('userMode', 'platform');
23
+ });
24
+
25
+ beforeAll(async () => {
26
+ period = await new RegistrationPeriodFactory({
27
+ startDate: new Date(2023, 0, 1),
28
+ endDate: new Date(2023, 11, 31),
29
+ }).create();
30
+
31
+ organization = await new OrganizationFactory({ period })
32
+ .create();
33
+
34
+ user = await new UserFactory({
35
+ organization,
36
+ permissions: Permissions.create({
37
+ level: PermissionLevel.Full,
38
+ }),
39
+ }).create();
40
+
41
+ token = await Token.createToken(user);
42
+ });
43
+
44
+ describe('put fiscal document', () => {
45
+ it('should throw if already has fiscal document in year', async () => {
46
+ // create existing fiscal document in same year
47
+ await new DocumentTemplateFactory({
48
+ organizationId: organization.id,
49
+ type: 'fiscal',
50
+ groups: [],
51
+ year: 2022,
52
+ }).create();
53
+
54
+ // create new fiscal document in same year
55
+ const arr: Body = new PatchableArray();
56
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
57
+ organizationId: organization.id,
58
+ type: 'fiscal',
59
+ groups: [],
60
+ year: 2022,
61
+ }).createWithoutSave());
62
+
63
+ const newDocument = newDocumentModel.getPrivateStructure();
64
+ arr.addPut(newDocument);
65
+
66
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
67
+ request.headers.authorization = 'Bearer ' + token.accessToken;
68
+
69
+ await expect(testServer.test(endpoint, request))
70
+ .rejects
71
+ .toThrow(STExpect.errorWithCode('double_fiscal_document'));
72
+ });
73
+
74
+ it('should not throw if first fiscal document in year', async () => {
75
+ // delete existing docs from organization
76
+ await SQL.delete().from(SQL.table(DocumentTemplate.table)).where('organizationId', organization.id);
77
+
78
+ const arr: Body = new PatchableArray();
79
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
80
+ organizationId: organization.id,
81
+ type: 'fiscal',
82
+ groups: [],
83
+ year: 2022,
84
+ }).createWithoutSave());
85
+
86
+ const newDocument = newDocumentModel.getPrivateStructure();
87
+ arr.addPut(newDocument);
88
+
89
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
90
+ request.headers.authorization = 'Bearer ' + token.accessToken;
91
+
92
+ const result = await testServer.test(endpoint, request);
93
+
94
+ expect(result).toBeDefined();
95
+ expect(result.status).toBe(200);
96
+ });
97
+ });
98
+
99
+ describe('patch fiscal document', () => {
100
+ describe('change type to fiscal', () => {
101
+ it('should throw if already has fiscal document in year', async () => {
102
+ // create existing fiscal document in same year
103
+ await new DocumentTemplateFactory({
104
+ organizationId: organization.id,
105
+ type: 'fiscal',
106
+ groups: [],
107
+ year: 2022,
108
+ }).create();
109
+
110
+ // create new fiscal document in same year
111
+ const arr: Body = new PatchableArray();
112
+
113
+ // create new participation document in same year
114
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
115
+ organizationId: organization.id,
116
+ type: 'participation',
117
+ groups: [],
118
+ year: 2022,
119
+ }).create());
120
+
121
+ // change type to fiscal
122
+ arr.addPatch(DocumentTemplatePrivate.patch({
123
+ id: newDocumentModel.id,
124
+ privateSettings: DocumentPrivateSettings.patch({
125
+ templateDefinition: DocumentTemplateDefinition.patch({
126
+ type: 'fiscal',
127
+ }),
128
+ }),
129
+ }));
130
+
131
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
132
+ request.headers.authorization = 'Bearer ' + token.accessToken;
133
+
134
+ await expect(testServer.test(endpoint, request))
135
+ .rejects
136
+ .toThrow(STExpect.errorWithCode('double_fiscal_document'));
137
+ });
138
+
139
+ it('should not throw if first fiscal document in year', async () => {
140
+ // delete existing docs from organization
141
+ await SQL.delete().from(SQL.table(DocumentTemplate.table)).where('organizationId', organization.id);
142
+
143
+ // create new fiscal document in same year
144
+ const arr: Body = new PatchableArray();
145
+
146
+ // create new participation document in same year
147
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
148
+ organizationId: organization.id,
149
+ type: 'participation',
150
+ groups: [],
151
+ year: 2022,
152
+ }).create());
153
+
154
+ // change type to fiscal
155
+ arr.addPatch(DocumentTemplatePrivate.patch({
156
+ id: newDocumentModel.id,
157
+ privateSettings: DocumentPrivateSettings.patch({
158
+ templateDefinition: DocumentTemplateDefinition.patch({
159
+ type: 'fiscal',
160
+ }),
161
+ }),
162
+ }));
163
+
164
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
165
+ request.headers.authorization = 'Bearer ' + token.accessToken;
166
+
167
+ const result = await testServer.test(endpoint, request);
168
+
169
+ expect(result).toBeDefined();
170
+ expect(result.status).toBe(200);
171
+ });
172
+ });
173
+
174
+ describe('change year', () => {
175
+ it('should throw if already has fiscal document in year', async () => {
176
+ // create existing fiscal document in same year
177
+ await new DocumentTemplateFactory({
178
+ organizationId: organization.id,
179
+ type: 'fiscal',
180
+ groups: [],
181
+ year: 2022,
182
+ }).create();
183
+
184
+ // create new fiscal document in same year
185
+ const arr: Body = new PatchableArray();
186
+
187
+ // create new participation document in other year
188
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
189
+ organizationId: organization.id,
190
+ type: 'fiscal',
191
+ groups: [],
192
+ year: 2021,
193
+ }).create());
194
+
195
+ // change year to same year as other existing document
196
+ arr.addPatch(DocumentTemplatePrivate.patch({
197
+ id: newDocumentModel.id,
198
+ year: 2022,
199
+ }));
200
+
201
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
202
+ request.headers.authorization = 'Bearer ' + token.accessToken;
203
+
204
+ await expect(testServer.test(endpoint, request))
205
+ .rejects
206
+ .toThrow(STExpect.errorWithCode('double_fiscal_document'));
207
+ });
208
+
209
+ it('should not throw if first fiscal document in year', async () => {
210
+ // delete existing docs from organization
211
+ await SQL.delete().from(SQL.table(DocumentTemplate.table)).where('organizationId', organization.id);
212
+
213
+ // create new fiscal document in same year
214
+ const arr: Body = new PatchableArray();
215
+
216
+ // create new participation document in other year
217
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
218
+ organizationId: organization.id,
219
+ type: 'fiscal',
220
+ groups: [],
221
+ year: 2021,
222
+ }).create());
223
+
224
+ // change year to same year as other existing document
225
+ arr.addPatch(DocumentTemplatePrivate.patch({
226
+ id: newDocumentModel.id,
227
+ year: 2022,
228
+ }));
229
+
230
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
231
+ request.headers.authorization = 'Bearer ' + token.accessToken;
232
+
233
+ const result = await testServer.test(endpoint, request);
234
+
235
+ expect(result).toBeDefined();
236
+ expect(result.status).toBe(200);
237
+ });
238
+ });
239
+
240
+ describe('do not change type or year of existing fiscal document', () => {
241
+ it('should not throw if multiple fiscal documents in year', async () => {
242
+ // delete existing docs from organization
243
+ await SQL.delete().from(SQL.table(DocumentTemplate.table)).where('organizationId', organization.id);
244
+
245
+ // create existing fiscal document in same year
246
+ await new DocumentTemplateFactory({
247
+ organizationId: organization.id,
248
+ type: 'fiscal',
249
+ groups: [],
250
+ year: 2022,
251
+ }).create();
252
+
253
+ // create new fiscal document in same year
254
+ const arr: Body = new PatchableArray();
255
+
256
+ // create double fiscal document in same year
257
+ const newDocumentModel: DocumentTemplate = (await new DocumentTemplateFactory({
258
+ organizationId: organization.id,
259
+ type: 'fiscal',
260
+ groups: [],
261
+ year: 2022,
262
+ }).create());
263
+
264
+ // change status to published (do not change year or type)
265
+ arr.addPatch(DocumentTemplatePrivate.patch({
266
+ id: newDocumentModel.id,
267
+ status: DocumentStatus.Published,
268
+ }));
269
+
270
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
271
+ request.headers.authorization = 'Bearer ' + token.accessToken;
272
+
273
+ const result = await testServer.test(endpoint, request);
274
+
275
+ expect(result).toBeDefined();
276
+ expect(result.status).toBe(200);
277
+ expect(result.body.length).toBe(1);
278
+ expect(result.body[0].status).toBe(DocumentStatus.Published);
279
+ });
280
+ });
281
+ });
282
+ });
@@ -1,10 +1,11 @@
1
1
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
- import { DocumentTemplate, Token } from '@stamhoofd/models';
4
+ import { DocumentTemplate } from '@stamhoofd/models';
5
5
  import { DocumentTemplatePrivate, PermissionLevel } from '@stamhoofd/structures';
6
6
 
7
- import { Context } from '../../../../helpers/Context';
7
+ import { SQL, SQLWhereSign } from '@stamhoofd/sql';
8
+ import { Context } from '../../../../helpers/Context.js';
8
9
 
9
10
  type Params = Record<string, never>;
10
11
  type Query = undefined;
@@ -15,7 +16,7 @@ type ResponseBody = DocumentTemplatePrivate[];
15
16
  * One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
16
17
  */
17
18
 
18
- export class PatchDocumentTemplateEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
+ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
20
  bodyDecoder = new PatchableArrayDecoder(DocumentTemplatePrivate as Decoder<DocumentTemplatePrivate>, DocumentTemplatePrivate.patchType() as Decoder<AutoEncoderPatchType<DocumentTemplatePrivate>>, StringDecoder);
20
21
 
21
22
  protected doesMatch(request: Request): [true, Params] | [false] {
@@ -49,7 +50,19 @@ export class PatchDocumentTemplateEndpoint extends Endpoint<Params, Query, Body,
49
50
  template.status = put.status;
50
51
  template.html = put.html;
51
52
  template.updatesEnabled = put.updatesEnabled;
53
+ template.year = put.year;
52
54
  template.organizationId = organization.id;
55
+
56
+ if (await this.doesYearAlreadyHaveFiscalDocument(template)) {
57
+ throw new SimpleError({
58
+ code: 'double_fiscal_document',
59
+ field: 'year',
60
+ message: 'This year already has a fiscal document',
61
+ human: $t('475f5f96-86bf-4124-a005-9904aaf72b37'),
62
+
63
+ });
64
+ }
65
+
53
66
  await template.save();
54
67
 
55
68
  // todo: Generate documents (maybe in background)
@@ -65,7 +78,14 @@ export class PatchDocumentTemplateEndpoint extends Endpoint<Params, Query, Body,
65
78
  throw Context.auth.notFoundOrNoAccess($t(`148bfab7-ca0e-4fac-8a0a-302ca7855fc8`));
66
79
  }
67
80
 
81
+ let shouldCheckIfAlreadyHasFiscalDocument = false;
82
+
68
83
  if (patch.privateSettings) {
84
+ const patchType = patch.privateSettings.templateDefinition?.type;
85
+
86
+ // only check if type has changed and new type is fiscal
87
+ shouldCheckIfAlreadyHasFiscalDocument = patchType !== undefined && template.privateSettings.templateDefinition.type !== patchType && patchType === 'fiscal';
88
+
69
89
  template.privateSettings.patchOrPut(patch.privateSettings);
70
90
  }
71
91
 
@@ -85,6 +105,23 @@ export class PatchDocumentTemplateEndpoint extends Endpoint<Params, Query, Body,
85
105
  template.html = patch.html;
86
106
  }
87
107
 
108
+ if (patch.year) {
109
+ if (shouldCheckIfAlreadyHasFiscalDocument === false) {
110
+ shouldCheckIfAlreadyHasFiscalDocument = template.year !== patch.year;
111
+ }
112
+
113
+ template.year = patch.year;
114
+ }
115
+
116
+ if (shouldCheckIfAlreadyHasFiscalDocument && await this.doesYearAlreadyHaveFiscalDocument(template)) {
117
+ throw new SimpleError({
118
+ code: 'double_fiscal_document',
119
+ field: 'year',
120
+ message: 'This year already has a fiscal document',
121
+ human: $t('475f5f96-86bf-4124-a005-9904aaf72b37'),
122
+ });
123
+ }
124
+
88
125
  await template.save();
89
126
 
90
127
  // Update documents
@@ -111,4 +148,20 @@ export class PatchDocumentTemplateEndpoint extends Endpoint<Params, Query, Body,
111
148
  updatedTemplates,
112
149
  );
113
150
  }
151
+
152
+ private async doesYearAlreadyHaveFiscalDocument(template: DocumentTemplate) {
153
+ let query = SQL.select().from(SQL.table(DocumentTemplate.table))
154
+ .where(SQL.column('organizationId'), template.organizationId)
155
+ .where(SQL.column('year'), template.year)
156
+ .where(SQL.jsonExtract(SQL.column('privateSettings'), '$.value.templateDefinition.type'), 'fiscal');
157
+
158
+ // id is not set if put
159
+ if (template.id) {
160
+ query = query.where(SQL.column('id'), SQLWhereSign.NotEqual, template.id);
161
+ }
162
+
163
+ const result = await query.limit(1).count();
164
+
165
+ return result > 0;
166
+ }
114
167
  }
@@ -1,12 +1,12 @@
1
1
  import { XlsxBuiltInNumberFormat, XlsxTransformerColumn, XlsxTransformerSheet } from '@stamhoofd/excel-writer';
2
2
  import { Platform } from '@stamhoofd/models';
3
- import { ExcelExportType, Gender, GroupType, LimitedFilteredRequest, PlatformFamily, PlatformMember, Platform as PlatformStruct, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
3
+ import { ExcelExportType, Gender, GroupType, LimitedFilteredRequest, MembershipStatus, PlatformFamily, PlatformMember, Platform as PlatformStruct, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
4
4
  import { Formatter } from '@stamhoofd/utility';
5
- import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint';
6
- import { GetMembersEndpoint } from '../endpoints/global/members/GetMembersEndpoint';
7
- import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures';
8
- import { Context } from '../helpers/Context';
9
- import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper';
5
+ import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
6
+ import { GetMembersEndpoint } from '../endpoints/global/members/GetMembersEndpoint.js';
7
+ import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
8
+ import { Context } from '../helpers/Context.js';
9
+ import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
10
10
 
11
11
  export const baseMemberColumns: XlsxTransformerColumn<PlatformMember>[] = [
12
12
  {
@@ -150,6 +150,16 @@ export const baseMemberColumns: XlsxTransformerColumn<PlatformMember>[] = [
150
150
  value: object.details.nationalRegisterNumber?.toString() ?? '',
151
151
  }),
152
152
  },
153
+ {
154
+ id: 'membership',
155
+ name: $t(`c7d995f1-36a0-446e-9fcf-17ffb69f3f45`),
156
+ width: 20,
157
+ getValue: (member: PlatformMember) => {
158
+ return {
159
+ value: formatMembershipStatus(member.membershipStatus),
160
+ };
161
+ },
162
+ },
153
163
 
154
164
  ...XlsxTransformerColumnHelper.creatColumnsForParents(),
155
165
 
@@ -260,6 +270,35 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformMember> = {
260
270
  };
261
271
  },
262
272
  },
273
+ {
274
+ id: 'outstandingBalance',
275
+ name: $t(`beb45452-dee7-4a7f-956c-e6db06aac20f`),
276
+ width: 30,
277
+ getValue: (v) => {
278
+ return {
279
+ value: v.member.balances.reduce((sum, r) => sum + (r.amountOpen), 0) / 1_0000,
280
+ style: {
281
+ numberFormat: {
282
+ id: XlsxBuiltInNumberFormat.Currency2DecimalWithRed,
283
+ },
284
+ },
285
+ };
286
+ },
287
+
288
+ },
289
+ {
290
+ id: 'createdAt',
291
+ name: $t('c38e774e-e8ab-4549-b119-4eed380c626c'),
292
+ width: 20,
293
+ getValue: v => ({
294
+ value: v.member.createdAt,
295
+ style: {
296
+ numberFormat: {
297
+ id: XlsxBuiltInNumberFormat.DateSlash,
298
+ },
299
+ },
300
+ }),
301
+ },
263
302
 
264
303
  // Registration records
265
304
  {
@@ -340,7 +379,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformMember> = {
340
379
  },
341
380
  }
342
381
  : {},
343
- value: options.length === 1 && returnAmount ? options[0].amount : options.map(option => returnAmount ? option.amount : option).join(', '),
382
+ value: options.map(option => returnAmount ? option.amount : option.option.name).join(', '),
344
383
  };
345
384
  },
346
385
  },
@@ -427,10 +466,25 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
427
466
  ],
428
467
  });
429
468
 
430
- function formatGender(gender: Gender) {
469
+ function formatGender(gender: Gender): string {
431
470
  switch (gender) {
432
471
  case Gender.Male: return $t(`f972abd4-de1e-484b-b7da-ad4c75d37808`);
433
472
  case Gender.Female: return $t(`e21f499d-1078-4044-be5d-6693d2636699`);
434
473
  default: return $t(`60f13ba4-c6c9-4388-9add-43a996bf6bee`);
435
474
  }
436
475
  }
476
+
477
+ function formatMembershipStatus(status: MembershipStatus): string {
478
+ switch (status) {
479
+ case MembershipStatus.Trial:
480
+ return $t(`47c7c3c4-9246-40b7-b1e0-2cb408d5f79e`);
481
+ case MembershipStatus.Active:
482
+ return $t(`b56351e9-4847-4a0c-9eec-348d75c794c4`);
483
+ case MembershipStatus.Expiring:
484
+ return $t(`d9858110-37d9-4b4a-8bfb-d76b3cc5ef27`);
485
+ case MembershipStatus.Temporary:
486
+ return $t(`75e62d3c-f348-4104-8a1e-e11e6e7fbe32`);
487
+ case MembershipStatus.Inactive:
488
+ return $t(`1f8620fa-e8a5-4665-99c8-c1907a5b5768`);
489
+ }
490
+ }