@stamhoofd/backend 2.83.5 → 2.84.1

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 (100) hide show
  1. package/index.ts +19 -4
  2. package/package.json +18 -14
  3. package/src/crons/amazon-ses.ts +26 -5
  4. package/src/crons/balance-emails.ts +18 -17
  5. package/src/email-recipient-loaders/registrations.ts +87 -0
  6. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +5 -2
  7. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +40 -40
  8. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +28 -22
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +81 -49
  10. package/src/endpoints/global/files/UploadFile.ts +11 -16
  11. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +234 -0
  12. package/src/endpoints/global/groups/GetGroupsEndpoint.ts +117 -43
  13. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +1054 -0
  14. package/src/endpoints/global/members/GetMembersEndpoint.ts +163 -141
  15. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -6
  16. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +0 -16
  17. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +73 -0
  18. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +1 -2
  19. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +43 -0
  20. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +1016 -0
  21. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +234 -0
  22. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -5
  23. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +474 -554
  24. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +191 -52
  25. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +107 -9
  26. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +89 -0
  27. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +9 -6
  28. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +88 -0
  29. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +0 -6
  30. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -6
  31. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +10 -25
  32. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +0 -5
  33. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +0 -5
  34. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +4 -0
  35. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -0
  36. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +44 -19
  37. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +140 -25
  38. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +40 -10
  39. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +2 -2
  40. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +2 -2
  41. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +4 -1
  42. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +2 -2
  43. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -2
  44. package/src/excel-loaders/members.ts +233 -232
  45. package/src/excel-loaders/payments.ts +1 -1
  46. package/src/excel-loaders/receivable-balances.ts +1 -1
  47. package/src/excel-loaders/registrations.ts +153 -0
  48. package/src/helpers/AdminPermissionChecker.ts +65 -37
  49. package/src/helpers/AuthenticatedStructures.ts +43 -3
  50. package/src/helpers/Context.ts +29 -1
  51. package/src/helpers/GlobalHelper.ts +3 -1
  52. package/src/helpers/GroupedThrottledQueue.test.ts +219 -0
  53. package/src/helpers/GroupedThrottledQueue.ts +108 -0
  54. package/src/helpers/LimitedFilteredRequestHelper.ts +26 -1
  55. package/src/helpers/MemberCharger.ts +0 -5
  56. package/src/helpers/MembershipCharger.ts +3 -9
  57. package/src/helpers/OrganizationCharger.ts +0 -5
  58. package/src/helpers/ThrottledQueue.test.ts +194 -0
  59. package/src/helpers/ThrottledQueue.ts +145 -0
  60. package/src/helpers/XlsxTransformerColumnHelper.ts +44 -1
  61. package/src/middleware/ContextMiddleware.ts +1 -1
  62. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +2 -1
  63. package/src/seeds/1735577912-update-cached-outstanding-balance-from-items.ts +2 -1
  64. package/src/services/BalanceItemPaymentService.ts +1 -33
  65. package/src/services/BalanceItemService.ts +167 -48
  66. package/src/services/FileSignService.ts +18 -13
  67. package/src/services/MemberRecordStore.ts +28 -19
  68. package/src/services/PaymentReallocationService.test.ts +25 -14
  69. package/src/services/PaymentReallocationService.ts +29 -10
  70. package/src/services/PaymentService.ts +4 -16
  71. package/src/services/PlatformMembershipService.ts +8 -4
  72. package/src/services/RegistrationService.ts +66 -2
  73. package/src/sql-filters/base-registration-filter-compilers.ts +43 -0
  74. package/src/sql-filters/groups.ts +67 -0
  75. package/src/sql-filters/members.ts +33 -58
  76. package/src/sql-filters/organization-registration-periods.ts +8 -0
  77. package/src/sql-filters/registration-periods.ts +8 -0
  78. package/src/sql-filters/registrations.ts +11 -22
  79. package/src/sql-sorters/groups.ts +24 -0
  80. package/src/sql-sorters/organization-registration-periods.ts +24 -0
  81. package/src/sql-sorters/registration-periods.ts +47 -0
  82. package/src/sql-sorters/registrations.ts +77 -0
  83. package/tests/actions/patchOrganizationMember.ts +27 -0
  84. package/tests/actions/patchPaymentStatus.ts +45 -0
  85. package/tests/actions/patchUserMember.ts +27 -0
  86. package/tests/assertions/assertBalances.ts +49 -0
  87. package/tests/e2e/api-rate-limits.test.ts +5 -5
  88. package/tests/e2e/bundle-discounts.test.ts +4060 -0
  89. package/tests/e2e/charge-members.test.ts +27 -24
  90. package/tests/e2e/documents.test.ts +398 -0
  91. package/tests/e2e/register.test.ts +292 -312
  92. package/tests/helpers/PayconiqMocker.ts +55 -0
  93. package/tests/init/index.ts +5 -0
  94. package/tests/init/initAdmin.ts +14 -0
  95. package/tests/init/initBundleDiscount.ts +47 -0
  96. package/tests/init/initPayconiq.ts +9 -0
  97. package/tests/init/initPlatformAdmin.ts +13 -0
  98. package/tests/init/initStripe.ts +21 -0
  99. package/tests/jest.setup.ts +29 -0
  100. package/src/seeds-temporary/1736266448-recall-balance-item-price-paid.ts +0 -70
@@ -1,5 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { LimitedFilteredRequest } from '@stamhoofd/structures';
2
+ import { SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { getSortFilter, LimitedFilteredRequest } from '@stamhoofd/structures';
3
4
 
4
5
  export class LimitedFilteredRequestHelper {
5
6
  static throwIfInvalidLimit({ request, maxLimit }: { request: LimitedFilteredRequest; maxLimit: number }) {
@@ -21,4 +22,28 @@ export class LimitedFilteredRequestHelper {
21
22
  });
22
23
  }
23
24
  }
25
+
26
+ static fixInfiniteLoadingLoop<T>({ request, results, sorters }: { request: LimitedFilteredRequest; results: T[]; sorters: SQLSortDefinitions<T> }): LimitedFilteredRequest | undefined {
27
+ let next: LimitedFilteredRequest | undefined;
28
+
29
+ if (results.length >= request.limit) {
30
+ const lastObject = results[results.length - 1];
31
+ const nextFilter = getSortFilter(lastObject, sorters, request.sort);
32
+
33
+ next = new LimitedFilteredRequest({
34
+ filter: request.filter,
35
+ pageFilter: nextFilter,
36
+ sort: request.sort,
37
+ limit: request.limit,
38
+ search: request.search,
39
+ });
40
+
41
+ if (JSON.stringify(nextFilter) === JSON.stringify(request.pageFilter)) {
42
+ console.error('Found infinite loading loop for', request);
43
+ next = undefined;
44
+ }
45
+ }
46
+
47
+ return next;
48
+ }
24
49
  }
@@ -1,6 +1,5 @@
1
1
  import { BalanceItem } from '@stamhoofd/models';
2
2
  import { BalanceItemType, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
3
- import { BalanceItemService } from '../services/BalanceItemService';
4
3
 
5
4
  export class MemberCharger {
6
5
  static async chargeMany({ chargingOrganizationId, membersToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; membersToCharge: MemberWithRegistrationsBlob[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
@@ -15,10 +14,6 @@ export class MemberCharger {
15
14
  }));
16
15
 
17
16
  await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
18
- await BalanceItem.updateOutstanding(balanceItems);
19
-
20
- // Reallocate
21
- await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
22
17
  }
23
18
 
24
19
  private static createBalanceItem({ price, amount, description, chargingOrganizationId, memberBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; memberBeingCharged: MemberWithRegistrationsBlob; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
@@ -1,9 +1,8 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { BalanceItem, Member, MemberPlatformMembership, Platform } from '@stamhoofd/models';
3
3
  import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
4
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType } from '@stamhoofd/structures';
4
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
- import { BalanceItemService } from '../services/BalanceItemService';
7
6
 
8
7
  export const MembershipCharger = {
9
8
  async charge() {
@@ -104,14 +103,14 @@ export const MembershipCharger = {
104
103
  BalanceItemRelationType.Member,
105
104
  BalanceItemRelation.create({
106
105
  id: member.id,
107
- name: member.details.name,
106
+ name: new TranslatedString(member.details.name),
108
107
  }),
109
108
  ],
110
109
  [
111
110
  BalanceItemRelationType.MembershipType,
112
111
  BalanceItemRelation.create({
113
112
  id: type.id,
114
- name: type.name,
113
+ name: new TranslatedString(type.name),
115
114
  }),
116
115
  ],
117
116
  ]);
@@ -131,11 +130,6 @@ export const MembershipCharger = {
131
130
  createdPrice += membership.price;
132
131
  }
133
132
 
134
- await BalanceItem.updateOutstanding(createdBalanceItems);
135
-
136
- // Reallocate
137
- await BalanceItemService.reallocate(createdBalanceItems, chargeVia);
138
-
139
133
  if (memberships.length < chunkSize) {
140
134
  break;
141
135
  }
@@ -1,6 +1,5 @@
1
1
  import { BalanceItem } from '@stamhoofd/models';
2
2
  import { BalanceItemType, Organization as OrganizationStruct } from '@stamhoofd/structures';
3
- import { BalanceItemService } from '../services/BalanceItemService';
4
3
 
5
4
  export class OrganizationCharger {
6
5
  static async chargeMany({ chargingOrganizationId, organizationsToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
@@ -15,10 +14,6 @@ export class OrganizationCharger {
15
14
  }));
16
15
 
17
16
  await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
18
- await BalanceItem.updateOutstanding(balanceItems);
19
-
20
- // Reallocate
21
- await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
22
17
  }
23
18
 
24
19
  private static createBalanceItem({ price, amount, description, chargingOrganizationId, organizationBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; organizationBeingCharged: OrganizationStruct; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
@@ -0,0 +1,194 @@
1
+ import { ThrottledQueue } from './ThrottledQueue';
2
+
3
+ describe('ThrottledQueue', () => {
4
+ // Mock timers for controlling setTimeout
5
+ beforeEach(() => {
6
+ jest.useFakeTimers();
7
+ });
8
+
9
+ afterEach(() => {
10
+ jest.useRealTimers();
11
+ });
12
+
13
+ afterAll(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+
17
+ it('should create an instance with the provided handler', () => {
18
+ const handler = jest.fn();
19
+ const queue = new ThrottledQueue(handler);
20
+
21
+ expect(queue.handler).toBe(handler);
22
+ expect(queue.items.size).toBe(0);
23
+ expect(queue.timeout).toBeNull();
24
+ });
25
+
26
+ it('should add items to the queue', () => {
27
+ const handler = jest.fn();
28
+ const queue = new ThrottledQueue(handler);
29
+
30
+ queue.addItem(1);
31
+ expect(queue.items.size).toBe(1);
32
+ expect(queue.items.has(1)).toBe(true);
33
+ });
34
+
35
+ it('should add multiple items to the queue', () => {
36
+ const handler = jest.fn();
37
+ const queue = new ThrottledQueue(handler);
38
+
39
+ queue.addItems([1, 2, 3]);
40
+ expect(queue.items.size).toBe(3);
41
+ expect(queue.items.has(1)).toBe(true);
42
+ expect(queue.items.has(2)).toBe(true);
43
+ expect(queue.items.has(3)).toBe(true);
44
+ });
45
+
46
+ it('should flush automatically when reaching maxBatchSize', async () => {
47
+ const handler = jest.fn().mockResolvedValue(undefined);
48
+ const queue = new ThrottledQueue(handler);
49
+ queue.maxBatchSize = 3;
50
+
51
+ // Spy on flushAll to check if it's called
52
+ const flushAllSpy = jest.spyOn(queue, 'flushAll');
53
+
54
+ queue.addItems([1, 2]);
55
+ expect(flushAllSpy).not.toHaveBeenCalled();
56
+
57
+ queue.addItem(3);
58
+ expect(flushAllSpy).toHaveBeenCalled();
59
+
60
+ // Wait for all promises to resolve
61
+ await queue.wait();
62
+
63
+ expect(handler).toHaveBeenCalledWith([1, 2, 3]);
64
+ expect(queue.items.size).toBe(0);
65
+ });
66
+
67
+ it('should process items in batches when exceeding maxBatchSize', async () => {
68
+ const handler = jest.fn().mockResolvedValue(undefined);
69
+ const queue = new ThrottledQueue(handler);
70
+ queue.maxBatchSize = 3;
71
+
72
+ const items = [1, 2, 3, 4, 5, 6, 7];
73
+ queue.addItems(items);
74
+ await queue.wait();
75
+
76
+ // Expecting multiple calls with batches of appropriate size
77
+ expect(handler).toHaveBeenCalledTimes(3);
78
+
79
+ // Check if all items were processed
80
+ const processedItems = new Set([
81
+ ...handler.mock.calls[0][0],
82
+ ...handler.mock.calls[1][0],
83
+ ...handler.mock.calls[2][0],
84
+ ]);
85
+
86
+ expect(processedItems.size).toBe(items.length);
87
+ items.forEach(item => expect(processedItems.has(item)).toBe(true));
88
+ });
89
+
90
+ it('should call emptyHandler when queue becomes empty', async () => {
91
+ const handler = jest.fn().mockResolvedValue(undefined);
92
+ const queue = new ThrottledQueue(handler);
93
+ const emptyHandler = jest.fn();
94
+ queue.emptyHandler = emptyHandler;
95
+
96
+ queue.maxBatchSize = 3;
97
+ queue.addItems([1, 2, 3]);
98
+ await queue.wait();
99
+
100
+ expect(emptyHandler).toHaveBeenCalled();
101
+ });
102
+
103
+ it('should not call emptyHandler when queue is filled during processing', async () => {
104
+ const handler = jest.fn().mockResolvedValue(undefined);
105
+ const queue = new ThrottledQueue(handler);
106
+ const emptyHandler = jest.fn();
107
+ queue.emptyHandler = emptyHandler;
108
+
109
+ queue.maxBatchSize = 3;
110
+ queue.addItems([1, 2, 3]);
111
+ // flush happened automatically above
112
+ queue.addItems([1]);
113
+ await queue.wait();
114
+
115
+ expect(emptyHandler).not.toHaveBeenCalled();
116
+
117
+ await queue.flushAndWait();
118
+ expect(emptyHandler).toHaveBeenCalled();
119
+ });
120
+
121
+ it('should not fail if handler throws an error', async () => {
122
+ const error = new Error('Test error');
123
+ const handler = jest.fn().mockRejectedValue(error);
124
+ const queue = new ThrottledQueue(handler);
125
+
126
+ // Spy on console.error
127
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
128
+
129
+ queue.addItem(1);
130
+ await queue.flushAndWait();
131
+
132
+ expect(handler).toHaveBeenCalledWith([1]);
133
+ expect(consoleErrorSpy).toHaveBeenCalled();
134
+ expect(consoleErrorSpy.mock.calls[0][0]).toContain('Error processing batch in ThrottledQueue:');
135
+
136
+ consoleErrorSpy.mockRestore();
137
+ });
138
+
139
+ it('should handle flushAndWait when queue is empty', async () => {
140
+ const handler = jest.fn();
141
+ const queue = new ThrottledQueue(handler);
142
+
143
+ await queue.flushAndWait();
144
+ expect(handler).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('should allow adding items while processing', async () => {
148
+ // Setup a handler that adds more items to the queue when called
149
+ const queue = new ThrottledQueue(async (items: number[]) => {
150
+ if (items.includes(1)) {
151
+ queue.addItem(4);
152
+ }
153
+ });
154
+
155
+ queue.addItems([1, 2, 3]);
156
+ await queue.flushAndWait();
157
+
158
+ expect(queue.items.size).toBe(1);
159
+ expect(queue.items.has(4)).toBe(true);
160
+ });
161
+
162
+ it('should handle startTimeout and stopTimeout correctly', async () => {
163
+ const handler = jest.fn();
164
+ const queue = new ThrottledQueue(handler);
165
+ queue.maxDelay = 1000; // Set a max delay for the timeout
166
+
167
+ queue.addItem(1);
168
+ expect(queue.timeout).not.toBeNull();
169
+
170
+ await queue.flushAndWait();
171
+ expect(queue.timeout).toBeNull();
172
+ });
173
+
174
+ it('should automatically flush after maxDelay', async () => {
175
+ const handler = jest.fn().mockResolvedValue(undefined);
176
+ const queue = new ThrottledQueue(handler);
177
+ queue.maxDelay = 1000; // Set a max delay for the timeout
178
+
179
+ queue.addItem(1);
180
+ expect(queue.timeout).not.toBeNull();
181
+
182
+ jest.advanceTimersByTime(500);
183
+ await queue.wait();
184
+ expect(handler).not.toHaveBeenCalled();
185
+
186
+ // Fast-forward time
187
+ jest.advanceTimersByTime(500);
188
+
189
+ await queue.wait();
190
+ expect(handler).toHaveBeenCalledWith([1]);
191
+ expect(queue.items.size).toBe(0);
192
+ expect(queue.timeout).toBeNull();
193
+ });
194
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Define an expensive action that needs to run on a list of items.
3
+ * Instead of running the action per item, it will call it on a batch of items or if a certain time has passed.
4
+ *
5
+ * Note that the handler can be called in parallel in some situations.
6
+ */
7
+ export class ThrottledQueue<T> {
8
+ items: Set<T> = new Set<T>();
9
+ timeout: NodeJS.Timeout | null = null;
10
+
11
+ maxBatchSize = 100;
12
+
13
+ /**
14
+ * The queue is cleared at least after this duration.
15
+ * Set to null (default) to disable the timeout.
16
+ * In milliseconds.
17
+ */
18
+ maxDelay: number | null = 10_000;
19
+
20
+ handler: (items: T[]) => Promise<void> | void;
21
+ emptyHandler?: () => void;
22
+
23
+ constructor(handler: (items: T[]) => Promise<void> | void, options: { maxBatchSize?: number; maxDelay?: number | null; emptyHandler?: () => void } = {}) {
24
+ this.handler = handler;
25
+ this.maxBatchSize = options.maxBatchSize ?? 100;
26
+ this.maxDelay = options.maxDelay ?? 10_000;
27
+ this.emptyHandler = options.emptyHandler;
28
+ }
29
+
30
+ addItems(items: T[]): void {
31
+ for (const item of items) {
32
+ this.items.add(item);
33
+ }
34
+
35
+ if (this.items.size >= this.maxBatchSize) {
36
+ this.flushAll();
37
+ return;
38
+ }
39
+ this.startTimeout();
40
+ }
41
+
42
+ addItem(item: T): void {
43
+ this.items.add(item);
44
+
45
+ if (this.items.size >= this.maxBatchSize) {
46
+ this.flushAll();
47
+ return;
48
+ }
49
+ this.startTimeout();
50
+ }
51
+
52
+ stopTimeout() {
53
+ if (this.timeout) {
54
+ clearTimeout(this.timeout);
55
+ this.timeout = null;
56
+ }
57
+ }
58
+
59
+ startTimeout() {
60
+ if (this.timeout || this.maxDelay === null) {
61
+ // Keep existing
62
+ return;
63
+ }
64
+
65
+ this.timeout = setTimeout(() => {
66
+ this.timeout = null;
67
+ this.flushAll();
68
+ }, this.maxDelay); // Adjust the timeout duration as needed
69
+ }
70
+
71
+ pendingFlushes: Set<Promise<void>> = new Set<Promise<void>>();
72
+
73
+ private async process(items: T[]) {
74
+ // Calculate an efficient batch size (not simply using maxBatchSize)
75
+ const batchCount = Math.ceil(items.length / this.maxBatchSize);
76
+ if (batchCount === 0) {
77
+ return;
78
+ }
79
+ const batchSize = Math.ceil(items.length / batchCount);
80
+
81
+ for (let i = 0; i < items.length; i += batchSize) {
82
+ // Avoid cloning the array if the batch size is larger than the remaining items
83
+ const batch = batchSize < items.length ? items.slice(i, i + batchSize) : items;
84
+ try {
85
+ await this.handler(batch);
86
+ }
87
+ catch (error) {
88
+ console.error('Error processing batch in ThrottledQueue:', error);
89
+ }
90
+ }
91
+ }
92
+
93
+ processErrorHandler(error: Error) {
94
+ console.error('Error in ThrottledQueue flush:', error);
95
+ }
96
+
97
+ /**
98
+ * Flushes all items in the queue.
99
+ * Items that are added while processing will not be processed automatically, but rather when the tiemout is reached or the maximum is reached.
100
+ */
101
+ flushAll() {
102
+ if (this.items.size === 0) {
103
+ // Nothing to flush or clear
104
+ return;
105
+ }
106
+
107
+ // Move items, so we can continue adding items while we are processing
108
+ const items = [...this.items];
109
+ this.items.clear();
110
+
111
+ const flush = this.process(items)
112
+ .catch(this.processErrorHandler)
113
+ .finally(() => {
114
+ this.pendingFlushes.delete(flush);
115
+
116
+ if (this.pendingFlushes.size === 0 && this.items.size === 0) {
117
+ // Call empty handler to signal that the queue is empty
118
+ this.stopTimeout();
119
+
120
+ if (this.emptyHandler) {
121
+ this.emptyHandler();
122
+ }
123
+ }
124
+ });
125
+
126
+ this.pendingFlushes.add(flush);
127
+ }
128
+
129
+ async wait() {
130
+ if (this.pendingFlushes.size === 0) {
131
+ return;
132
+ }
133
+
134
+ // Wait for all pending flushes to complete
135
+ await Promise.all(this.pendingFlushes);
136
+ }
137
+
138
+ /**
139
+ * Note: this won't wait on new items that are added while flushing.
140
+ */
141
+ async flushAndWait() {
142
+ this.flushAll();
143
+ await this.wait();
144
+ }
145
+ }
@@ -1,4 +1,4 @@
1
- import { XlsxTransformerColumn } from '@stamhoofd/excel-writer';
1
+ import { isXlsxTransformerConcreteColumn, XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
2
  import { Address, CountryHelper, Parent, ParentTypeHelper, PlatformMember, RecordAnswer, RecordCategory, RecordSettings, RecordType } from '@stamhoofd/structures';
3
3
 
4
4
  export class XlsxTransformerColumnHelper {
@@ -239,4 +239,47 @@ export class XlsxTransformerColumnHelper {
239
239
  },
240
240
  };
241
241
  }
242
+
243
+ /**
244
+ * Makes it possible to reuse an XlsxTransformerColumn, for example member columns exist, PlatformRegistration has
245
+ * a property member, so we can reuse the member columns for PlatformRegistration.
246
+ * @param param0
247
+ * @returns
248
+ */
249
+ private static transformConcreteColumnForProperty<T, O>({ column, key, getPropertyValue }: { column: XlsxTransformerConcreteColumn<T>; key: string; getPropertyValue: (object: O) => T }): XlsxTransformerConcreteColumn<O> {
250
+ return {
251
+ ...column,
252
+ id: `${key}.${column.id}`,
253
+ getValue: (object: O) => column.getValue(getPropertyValue(object)),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Makes it possible to reuse an XlsxTransformerColumn, for example member columns exist, PlatformRegistration has
259
+ * a property member, so we can reuse the member columns for PlatformRegistration.
260
+ * @param param0
261
+ * @returns
262
+ */
263
+ static transformColumnForProperty<T, O>({ column, key, getPropertyValue }: { column: XlsxTransformerColumn<T>; key: string; getPropertyValue: (object: O) => T }): XlsxTransformerColumn<O> {
264
+ if (isXlsxTransformerConcreteColumn(column)) {
265
+ return this.transformConcreteColumnForProperty({ column, key, getPropertyValue });
266
+ }
267
+
268
+ return {
269
+ match: (id: string) => {
270
+ if (!id.startsWith(key + '.')) {
271
+ return;
272
+ }
273
+
274
+ const subId = id.substring(key.length + 1);
275
+ const subColumns = column.match(subId);
276
+
277
+ if (!subColumns) {
278
+ return;
279
+ }
280
+
281
+ return subColumns.map(column => XlsxTransformerColumnHelper.transformConcreteColumnForProperty({ column, key, getPropertyValue }));
282
+ },
283
+ };
284
+ }
242
285
  }
@@ -10,7 +10,7 @@ export const ContextMiddleware: RequestMiddleware = {
10
10
  return ContextInstance.start(request, run);
11
11
  },
12
12
 
13
- handleRequest: function (request: Request) {
13
+ handleRequest() {
14
14
  // Noop
15
15
  },
16
16
  };
@@ -1,6 +1,7 @@
1
1
  import { Migration } from '@simonbackx/simple-database';
2
2
  import { logger } from '@simonbackx/simple-logging';
3
3
  import { BalanceItem } from '@stamhoofd/models';
4
+ import { BalanceItemService } from '../services/BalanceItemService';
4
5
 
5
6
  /**
6
7
  * This migration is required to keep '1733994455-balance-item-status-open' working
@@ -16,7 +17,7 @@ export default new Migration(async () => {
16
17
 
17
18
  await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
18
19
  for await (const items of BalanceItem.select().limit(1000).allBatched()) {
19
- await BalanceItem.updateOutstanding(items);
20
+ await BalanceItemService.updatePaidAndPending(items);
20
21
 
21
22
  c += items.length;
22
23
  process.stdout.write('.');
@@ -1,6 +1,7 @@
1
1
  import { Migration } from '@simonbackx/simple-database';
2
2
  import { logger } from '@simonbackx/simple-logging';
3
3
  import { BalanceItem } from '@stamhoofd/models';
4
+ import { BalanceItemService } from '../services/BalanceItemService';
4
5
 
5
6
  export default new Migration(async () => {
6
7
  if (STAMHOOFD.environment == 'test') {
@@ -13,7 +14,7 @@ export default new Migration(async () => {
13
14
 
14
15
  await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
15
16
  for await (const items of BalanceItem.select().limit(1000).allBatched()) {
16
- await BalanceItem.updateOutstanding(items);
17
+ await BalanceItemService.updatePaidAndPending(items);
17
18
 
18
19
  c += items.length;
19
20
  process.stdout.write('.');
@@ -1,44 +1,12 @@
1
1
  import { ManyToOneRelation } from '@simonbackx/simple-database';
2
2
  import { BalanceItemPayment, Organization } from '@stamhoofd/models';
3
- import { BalanceItemStatus } from '@stamhoofd/structures';
4
3
  import { BalanceItemService } from './BalanceItemService';
5
4
 
6
5
  type Loaded<T> = (T) extends ManyToOneRelation<infer Key, infer Model> ? Record<Key, Model> : never;
7
6
 
8
7
  export const BalanceItemPaymentService = {
9
8
  async markPaid(balanceItemPayment: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
10
- const wasPaid = balanceItemPayment.balanceItem.isPaid;
11
-
12
- // Update cached amountPaid of the balance item (balanceItemPayment will get overwritten later, but we need it to calculate the status)
13
- balanceItemPayment.balanceItem.pricePaid += balanceItemPayment.price;
14
-
15
- if (balanceItemPayment.balanceItem.status === BalanceItemStatus.Hidden && balanceItemPayment.balanceItem.pricePaid !== 0) {
16
- balanceItemPayment.balanceItem.status = BalanceItemStatus.Due;
17
- }
18
-
19
- await balanceItemPayment.balanceItem.save();
20
- const isPaid = balanceItemPayment.balanceItem.isPaid;
21
-
22
- // Do logic of balance item
23
- if (isPaid && !wasPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
24
- // Only call markPaid once (if it wasn't (partially) paid before)
25
- await BalanceItemService.markPaid(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
26
- }
27
- else {
28
- await BalanceItemService.markUpdated(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
29
- }
30
- },
31
-
32
- /**
33
- * Safe method to correct balance items that missed a markPaid call, but avoid double marking an order as valid.
34
- */
35
- async markPaidRepeated(balanceItemPayment: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
36
- const isPaid = balanceItemPayment.balanceItem.isPaid;
37
-
38
- // Do logic of balance item
39
- if (isPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
40
- await BalanceItemService.markPaidRepeated(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
41
- }
9
+ await BalanceItemService.markPaid(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
42
10
  },
43
11
 
44
12
  /**