@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,57 +1,181 @@
1
- import { BalanceItem, Order, Organization, Payment, Webshop } from '@stamhoofd/models';
2
- import { AuditLogSource, BalanceItemStatus, OrderStatus, ReceivableBalanceType } from '@stamhoofd/structures';
1
+ import { BalanceItem, CachedBalance, Document, MemberUser, Order, Organization, Payment, Webshop } from '@stamhoofd/models';
2
+ import { AuditLogSource, BalanceItemStatus, BalanceItemType, OrderStatus, ReceivableBalanceType } from '@stamhoofd/structures';
3
+ import { Formatter } from '@stamhoofd/utility';
3
4
  import { AuditLogService } from './AuditLogService';
4
- import { RegistrationService } from './RegistrationService';
5
5
  import { PaymentReallocationService } from './PaymentReallocationService';
6
- import { Formatter } from '@stamhoofd/utility';
6
+ import { RegistrationService } from './RegistrationService';
7
+ import { Model } from '@simonbackx/simple-database';
8
+ import { GroupedThrottledQueue } from '../helpers/GroupedThrottledQueue';
9
+ import { ThrottledQueue } from '../helpers/ThrottledQueue';
10
+
11
+ const memberUpdateQueue = new GroupedThrottledQueue(async (organizationId: string, memberIds: string[]) => {
12
+ await CachedBalance.updateForMembers(organizationId, memberIds);
13
+
14
+ for (const memberId of memberIds) {
15
+ await PaymentReallocationService.reallocate(organizationId, memberId, ReceivableBalanceType.member);
16
+ }
17
+
18
+ if (memberIds.length) {
19
+ // Now also include the userIds of the members
20
+ const userMemberIds = (await MemberUser.select().where('membersId', memberIds).fetch()).map(m => m.usersId);
21
+ userUpdateQueue.addItems(organizationId, userMemberIds);
22
+ }
23
+ }, { maxDelay: 10_000 });
24
+
25
+ const userUpdateQueue = new GroupedThrottledQueue(async (organizationId: string, userIds: string[]) => {
26
+ await CachedBalance.updateForUsers(organizationId, userIds);
27
+
28
+ for (const userId of userIds) {
29
+ await PaymentReallocationService.reallocate(organizationId, userId, ReceivableBalanceType.user);
30
+ }
31
+ }, { maxDelay: 10_000 });
32
+
33
+ const organizationUpdateQueue = new GroupedThrottledQueue(async (organizationId: string, organizationIds: string[]) => {
34
+ await CachedBalance.updateForOrganizations(organizationId, organizationIds);
35
+
36
+ for (const payingOrganizationId of organizationIds) {
37
+ await PaymentReallocationService.reallocate(organizationId, payingOrganizationId, ReceivableBalanceType.organization);
38
+ }
39
+ }, { maxDelay: 60_000 });
40
+
41
+ export const registrationUpdateQueue = new GroupedThrottledQueue(async (organizationId: string, registrationIds: string[]) => {
42
+ await CachedBalance.updateForRegistrations(organizationId, registrationIds);
43
+ await Document.updateForRegistrations(registrationIds, organizationId);
44
+ }, { maxDelay: 10_000 });
45
+
46
+ const bundleDiscountsUpdateQueue = new ThrottledQueue(async (registrationIds: string[]) => {
47
+ for (const registrationId of registrationIds) {
48
+ await RegistrationService.updateDiscounts(registrationId);
49
+ }
50
+ }, { maxDelay: 10_000 });
7
51
 
8
52
  export const BalanceItemService = {
9
- /**
10
- * Safe method to correct balance items that missed a markPaid call, but avoid double marking an order as valid.
11
- */
12
- async markPaidRepeated(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
13
- if (balanceItem.pricePaid <= 0) {
53
+ listening: false,
54
+
55
+ listen() {
56
+ if (this.listening) {
14
57
  return;
15
58
  }
59
+ this.listening = true;
60
+ Model.modelEventBus.addListener(this, async (event) => {
61
+ if (!(event.model instanceof BalanceItem)) {
62
+ return;
63
+ }
16
64
 
17
- await this.markDue(balanceItem);
65
+ if (event.type === 'created' || event.type === 'deleted') {
66
+ await this.scheduleUpdate(event.model);
67
+ return;
68
+ }
18
69
 
19
- // Registrations are safe to mark valid multiple times
20
- if (balanceItem.registrationId) {
21
- await RegistrationService.markValid(balanceItem.registrationId);
70
+ // Check changed:
71
+ // status, unitPrice, dueAt, amount
72
+ if (
73
+ 'status' in event.changedFields
74
+ || 'unitPrice' in event.changedFields
75
+ || 'dueAt' in event.changedFields
76
+ || 'amount' in event.changedFields
77
+ || 'memberId' in event.changedFields
78
+ || 'userId' in event.changedFields
79
+ || 'payingOrganizationId' in event.changedFields
80
+ || 'registrationId' in event.changedFields
81
+ ) {
82
+ await this.scheduleUpdate(event.model);
83
+ }
84
+ });
85
+ },
86
+
87
+ /**
88
+ * Schedule an update for the balance item:
89
+ * - Updates cached outstanding balances for members, users, organizations and registrations
90
+ *
91
+ * Does not execute the update immediately, but schedules it to be run in the background.
92
+ */
93
+ async scheduleUpdate(item: BalanceItem) {
94
+ await this.scheduleUpdates([item]);
95
+
96
+ // Todo: optimize
97
+ if (item.type === BalanceItemType.RegistrationBundleDiscount) {
98
+ // Save the applied discount to the related registration
99
+ if (item.registrationId) {
100
+ bundleDiscountsUpdateQueue.addItem(item.registrationId);
101
+ }
22
102
  }
103
+ },
23
104
 
24
- // Orders aren't safe to mark paid twice - so only mark paid if not yet valid
25
- // The only downside of this is that we won't send a paid email for transfer orders
26
- // we should fix that in the future by introducing a paidAt timestamp for orders
27
- if (balanceItem.orderId) {
28
- const order = await Order.getByID(balanceItem.orderId);
29
- if (order && !order.validAt) {
30
- await order.markPaid(payment, organization);
105
+ /**
106
+ * Call this when a payment or payment balance items have changed.
107
+ * It will also call updateOutstanding automatically, so no need to call that separately again
108
+ */
109
+ async updatePaidAndPending(items: BalanceItem[]) {
110
+ console.log('updatePaidAndPending for', items.length, 'items');
111
+ await BalanceItem.updatePricePaid(items.map(i => i.id));
112
+ await this.scheduleUpdates(items);
113
+ },
31
114
 
32
- // Save number in balance description
33
- if (order.number !== null) {
34
- const webshop = await Webshop.getByID(order.webshopId);
115
+ /**
116
+ * In some situations we need immediate updates
117
+ */
118
+ async flushRegistrationDiscountsCache() {
119
+ await bundleDiscountsUpdateQueue.flushAndWait();
120
+ },
35
121
 
36
- if (webshop) {
37
- balanceItem.description = order.generateBalanceDescription(webshop);
38
- await balanceItem.save();
39
- }
40
- }
122
+ /**
123
+ * Make sure all the pending changes for cached balances are run
124
+ */
125
+ async flushCaches(organizationId: string) {
126
+ await memberUpdateQueue.flushGroupAndWait(organizationId);
127
+ await userUpdateQueue.flushGroupAndWait(organizationId);
128
+ await organizationUpdateQueue.flushGroupAndWait(organizationId);
129
+ await registrationUpdateQueue.flushGroupAndWait(organizationId);
130
+ await bundleDiscountsUpdateQueue.flushAndWait();
131
+ },
132
+
133
+ async flushAll() {
134
+ await memberUpdateQueue.flushAndWait();
135
+ await userUpdateQueue.flushAndWait();
136
+ await organizationUpdateQueue.flushAndWait();
137
+ await registrationUpdateQueue.flushAndWait();
138
+ await bundleDiscountsUpdateQueue.flushAndWait();
139
+ },
140
+
141
+ /**
142
+ * Update how many every object in the system owes or needs to be reimbursed
143
+ * and also updates the pricePaid/pricePending cached values in Balance items and members
144
+ */
145
+ async scheduleUpdates(items: BalanceItem[]) {
146
+ console.log('Schedule outstanding balance for', items.length, 'items');
147
+
148
+ for (const item of items) {
149
+ if (item.memberId) {
150
+ memberUpdateQueue.addItem(item.organizationId, item.memberId);
151
+ }
152
+
153
+ if (item.userId) {
154
+ userUpdateQueue.addItem(item.organizationId, item.userId);
155
+ }
156
+
157
+ if (item.payingOrganizationId) {
158
+ organizationUpdateQueue.addItem(item.organizationId, item.payingOrganizationId);
159
+ }
160
+
161
+ if (item.registrationId) {
162
+ registrationUpdateQueue.addItem(item.organizationId, item.registrationId);
41
163
  }
42
164
  }
43
165
  },
44
166
 
45
167
  async markDue(balanceItem: BalanceItem) {
46
168
  if (balanceItem.status === BalanceItemStatus.Hidden) {
47
- await BalanceItem.reactivateItems([balanceItem]);
169
+ balanceItem.status = BalanceItemStatus.Due;
170
+ await balanceItem.save();
48
171
  }
49
172
 
50
173
  // status and pricePaid changes are handled inside balanceitempayment
51
174
  if (balanceItem.dependingBalanceItemId) {
52
175
  const depending = await BalanceItem.getByID(balanceItem.dependingBalanceItemId);
53
176
  if (depending && depending.status === BalanceItemStatus.Hidden) {
54
- await BalanceItem.reactivateItems([depending]);
177
+ depending.status = BalanceItemStatus.Due;
178
+ await balanceItem.save();
55
179
  }
56
180
  }
57
181
  },
@@ -59,13 +183,22 @@ export const BalanceItemService = {
59
183
  async markPaid(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
60
184
  await this.markDue(balanceItem);
61
185
 
186
+ if (balanceItem.paidAt) {
187
+ // Already ran side effects
188
+ // If we for example deleted a related order or registration - and we still have the balance item, mark it as paid again, we don't want to reactivate the order or registration
189
+ await this.markUpdated(balanceItem, payment, organization);
190
+ return;
191
+ }
192
+
62
193
  // It is possible this balance item was earlier paid
63
194
  // and later the regigstration / order has been canceled and it became a negative balance item - which as some point has been reembursed and marked as 'paid'
64
195
  // in that case, we should be careful not to mark the registration as valid again
65
196
 
66
197
  // If registration
67
198
  if (balanceItem.registrationId) {
68
- await RegistrationService.markValid(balanceItem.registrationId);
199
+ if (balanceItem.type === BalanceItemType.Registration) {
200
+ await RegistrationService.markValid(balanceItem.registrationId);
201
+ }
69
202
  }
70
203
 
71
204
  // If order
@@ -85,24 +218,9 @@ export const BalanceItemService = {
85
218
  }
86
219
  }
87
220
  }
88
- },
89
-
90
- async reallocate(balanceItems: BalanceItem[], organizationId: string) {
91
- const memberIds = Formatter.uniqueArray(balanceItems.map(b => b.memberId).filter(b => b !== null));
92
- const payingOrganizationIds = Formatter.uniqueArray(balanceItems.map(b => b.payingOrganizationId).filter(b => b !== null));
93
- const userIds = Formatter.uniqueArray(balanceItems.map(b => b.userId).filter(b => b !== null));
94
-
95
- for (const memberId of memberIds) {
96
- await PaymentReallocationService.reallocate(organizationId, memberId, ReceivableBalanceType.member);
97
- }
98
-
99
- for (const payingOrganizationId of payingOrganizationIds) {
100
- await PaymentReallocationService.reallocate(organizationId, payingOrganizationId, ReceivableBalanceType.organization);
101
- }
102
221
 
103
- for (const userId of userIds) {
104
- await PaymentReallocationService.reallocate(organizationId, userId, ReceivableBalanceType.user);
105
- }
222
+ balanceItem.paidAt = new Date();
223
+ await balanceItem.save();
106
224
  },
107
225
 
108
226
  async markUpdated(balanceItem: BalanceItem, payment: Payment, organization: Organization) {
@@ -124,6 +242,7 @@ export const BalanceItemService = {
124
242
  if (balanceItem.orderId) {
125
243
  const order = await Order.getByID(balanceItem.orderId);
126
244
  if (order) {
245
+ // This is safe to run multiple times. Doesn't have side effects
127
246
  await order.undoPaid(payment, organization);
128
247
  }
129
248
  }
@@ -1,21 +1,30 @@
1
+ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; // ES Modules import
2
+ import {
3
+ getSignedUrl,
4
+ } from '@aws-sdk/s3-request-presigner';
1
5
  import { DecodedRequest, Request, Response } from '@simonbackx/simple-endpoints';
2
6
  import { SimpleError } from '@simonbackx/simple-errors';
3
7
  import { File } from '@stamhoofd/structures';
4
- import AWS from 'aws-sdk';
5
- import * as jose from 'jose';
6
8
  import chalk from 'chalk';
9
+ import * as jose from 'jose';
7
10
 
8
11
  /**
9
12
  * This service creates signed urls for valid files
10
13
  */
11
14
  export class FileSignService {
12
- static s3 = new AWS.S3({
13
- endpoint: STAMHOOFD.SPACES_ENDPOINT,
14
- accessKeyId: STAMHOOFD.SPACES_KEY,
15
- secretAccessKey: STAMHOOFD.SPACES_SECRET,
16
- });
15
+ static s3: S3Client;
17
16
 
18
17
  static async load() {
18
+ this.s3 = new S3Client({
19
+ forcePathStyle: false, // Configures to use subdomain/virtual calling format.
20
+ endpoint: 'https://' + STAMHOOFD.SPACES_ENDPOINT,
21
+ credentials: {
22
+ accessKeyId: STAMHOOFD.SPACES_KEY,
23
+ secretAccessKey: STAMHOOFD.SPACES_SECRET,
24
+ },
25
+ region: 'eu-west-1', // Not used, but required by the S3Client
26
+ });
27
+
19
28
  /**
20
29
  * Note the algorithm is only used for signing. For verification the algorithm inside the public keys are used
21
30
  */
@@ -96,20 +105,16 @@ export class FileSignService {
96
105
  }
97
106
 
98
107
  static async withSignedUrl(file: File, duration = 60 * 60) {
99
- console.log('Generating signed url:', file.id);
100
-
101
108
  if (file.signedUrl) {
102
109
  console.error('Warning: file already signed');
103
110
  }
104
111
 
105
112
  try {
106
- const url = await this.s3.getSignedUrlPromise('getObject', {
113
+ const command = new GetObjectCommand({
107
114
  Bucket: STAMHOOFD.SPACES_BUCKET,
108
115
  Key: file.path,
109
- Expires: duration,
110
116
  });
111
-
112
- console.log('Signed url:', url);
117
+ const url = await getSignedUrl(this.s3, command, { expiresIn: duration });
113
118
 
114
119
  return new File({
115
120
  ...file,
@@ -1,5 +1,6 @@
1
1
  import { Model } from '@simonbackx/simple-database';
2
2
  import { Organization, Platform } from '@stamhoofd/models';
3
+ import { QueueHandler } from '@stamhoofd/queues';
3
4
  import { RecordSettings } from '@stamhoofd/structures';
4
5
 
5
6
  export type RecordCacheEntry = { record: RecordSettings; rootCategoryId: string; organizationId: string | null };
@@ -118,34 +119,42 @@ export class MemberRecordStore {
118
119
  static async loadAll() {
119
120
  this.cache = new Map<string, RecordCacheEntry>();
120
121
 
121
- const platform = await Platform.getShared();
122
- for (const recordCategory of platform.config.recordsConfiguration.recordCategories) {
123
- for (const record of recordCategory.getAllRecords()) {
124
- // Add to cache
125
- this.cache.set(record.id, {
126
- record: record.clone(),
127
- organizationId: null,
128
- rootCategoryId: recordCategory.id,
129
- });
130
- }
131
- }
122
+ // We use a queue here so we can abort this long running task properly
123
+ // + can await it when shutting down the server or tests
124
+ await QueueHandler.schedule('MemberRecordStore.loadAll', async ({ abort }) => {
125
+ this.cache = new Map<string, RecordCacheEntry>();
132
126
 
133
- for await (const organization of Organization.select().all()) {
134
- for (const recordCategory of organization.meta.recordsConfiguration.recordCategories) {
127
+ const platform = await Platform.getShared();
128
+ for (const recordCategory of platform.config.recordsConfiguration.recordCategories) {
135
129
  for (const record of recordCategory.getAllRecords()) {
136
- if (this.cache.has(record.id)) {
137
- console.error(`Duplicate record id ${record.id} found in organization ${organization.id} for record ${record.name} (${record.id}) in ${recordCategory.name}`);
138
- continue;
139
- }
140
130
  // Add to cache
141
131
  this.cache.set(record.id, {
142
132
  record: record.clone(),
143
- organizationId: organization.id,
133
+ organizationId: null,
144
134
  rootCategoryId: recordCategory.id,
145
135
  });
146
136
  }
147
137
  }
148
- }
138
+ abort.throwIfAborted();
139
+
140
+ for await (const organization of Organization.select().all()) {
141
+ for (const recordCategory of organization.meta.recordsConfiguration.recordCategories) {
142
+ for (const record of recordCategory.getAllRecords()) {
143
+ if (this.cache.has(record.id)) {
144
+ console.error(`Duplicate record id ${record.id} found in organization ${organization.id} for record ${record.name} (${record.id}) in ${recordCategory.name}`);
145
+ continue;
146
+ }
147
+ // Add to cache
148
+ this.cache.set(record.id, {
149
+ record: record.clone(),
150
+ organizationId: organization.id,
151
+ rootCategoryId: recordCategory.id,
152
+ });
153
+ }
154
+ }
155
+ abort.throwIfAborted();
156
+ }
157
+ });
149
158
  }
150
159
 
151
160
  static async getRecord(id: string): Promise<RecordCacheEntry | null> {
@@ -1,6 +1,7 @@
1
1
  import { BalanceItem, BalanceItemPayment, MemberFactory, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
2
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, PaymentMethod, PaymentStatus, ReceivableBalanceType } from '@stamhoofd/structures';
2
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, PaymentMethod, PaymentStatus, ReceivableBalanceType, TranslatedString } from '@stamhoofd/structures';
3
3
  import { PaymentReallocationService } from './PaymentReallocationService';
4
+ import { BalanceItemService } from './BalanceItemService';
4
5
 
5
6
  let sharedOrganization: Organization | undefined;
6
7
 
@@ -46,7 +47,7 @@ async function createItem(options: {
46
47
  for (const [type, id] of Object.entries(options.relations)) {
47
48
  b.relations.set(type as BalanceItemRelationType, BalanceItemRelation.create({
48
49
  id,
49
- name: 'Test ' + id,
50
+ name: new TranslatedString('Test ' + id),
50
51
  }));
51
52
  }
52
53
  }
@@ -100,7 +101,7 @@ async function createItem(options: {
100
101
  await balanceItemPayment.save();
101
102
  }
102
103
 
103
- await BalanceItem.updateOutstanding([b]);
104
+ await BalanceItemService.updatePaidAndPending([b]);
104
105
  const balance = (await BalanceItem.getByID(b.id))!;
105
106
 
106
107
  await expectItem(balance, {
@@ -116,7 +117,7 @@ async function createItem(options: {
116
117
  }
117
118
 
118
119
  async function expectItem(b: BalanceItem, options: { pricePaid?: number; priceOpen?: number; pricePending?: number; paid?: number[]; pending?: number[]; failed?: number[] }) {
119
- await BalanceItem.updateOutstanding([b]);
120
+ await BalanceItemService.updatePaidAndPending([b]);
120
121
  b = (await BalanceItem.getByID(b.id))!;
121
122
 
122
123
  const loaded = await BalanceItem.loadPayments([b]);
@@ -460,7 +461,7 @@ describe('PaymentReallocationService', () => {
460
461
  });
461
462
  });
462
463
 
463
- it('Balances with different relations should create a reallocation payment', async () => {
464
+ it.skip('Balances with different relations should create a reallocation payment', async () => {
464
465
  const memberId = (await getMember()).id;
465
466
  const b1 = await createItem({
466
467
  unitPrice: 30 * 100,
@@ -507,7 +508,7 @@ describe('PaymentReallocationService', () => {
507
508
  });
508
509
  });
509
510
 
510
- it('Balances with different relations should create a reallocation payment with 3 items', async () => {
511
+ it.skip('Balances with different relations should create a reallocation payment with 3 items', async () => {
511
512
  const memberId = (await getMember()).id;
512
513
  const b1 = await createItem({
513
514
  unitPrice: 45 * 100,
@@ -567,7 +568,7 @@ describe('PaymentReallocationService', () => {
567
568
  });
568
569
  });
569
570
 
570
- it('Balances with different relations should create a reallocation payment with 3 items and remaining open should prefer most similar item', async () => {
571
+ it.skip('Balances with different relations should create a reallocation payment with 3 items and remaining open should prefer most similar item', async () => {
571
572
  const memberId = (await getMember()).id;
572
573
  const b1 = await createItem({
573
574
  unitPrice: 40 * 100,
@@ -631,7 +632,7 @@ describe('PaymentReallocationService', () => {
631
632
  /**
632
633
  * Note: if this one fails randomly, it might because it isn't working stable enough and doesn't fulfil the requirements
633
634
  */
634
- it('Balances with different relations should create a reallocation payment with 3 items and remaining open should prefer largest amount', async () => {
635
+ it.skip('Balances with different relations should create a reallocation payment with 3 items and remaining open should prefer largest amount', async () => {
635
636
  const memberId = (await getMember()).id;
636
637
  const b1 = await createItem({
637
638
  unitPrice: 40 * 100,
@@ -686,7 +687,7 @@ describe('PaymentReallocationService', () => {
686
687
  });
687
688
  });
688
689
 
689
- it('Balances with different relations should create a reallocation payment with 3 items and remaining open should prefer first due amount', async () => {
690
+ it('Balances due in the future should not be reallocated', async () => {
690
691
  const memberId = (await getMember()).id;
691
692
  const b1 = await createItem({
692
693
  unitPrice: 40 * 100,
@@ -710,6 +711,11 @@ describe('PaymentReallocationService', () => {
710
711
  objectId: memberId,
711
712
  // This is due later, so it should be the last one to be paid
712
713
  dueAt: new Date('2050-01-01'),
714
+ relations: {
715
+ [BalanceItemRelationType.Group]: 'group1',
716
+ [BalanceItemRelationType.GroupPrice]: 'defaultprice',
717
+ [BalanceItemRelationType.Member]: 'member1',
718
+ },
713
719
  });
714
720
 
715
721
  const b3 = await createItem({
@@ -718,6 +724,11 @@ describe('PaymentReallocationService', () => {
718
724
  paid: [],
719
725
  priceOpen: 15 * 100, // This adds internal assert
720
726
  objectId: memberId,
727
+ relations: {
728
+ [BalanceItemRelationType.Group]: 'group1',
729
+ [BalanceItemRelationType.GroupPrice]: 'defaultprice',
730
+ [BalanceItemRelationType.Member]: 'member1',
731
+ },
721
732
  });
722
733
 
723
734
  await PaymentReallocationService.reallocate(
@@ -728,17 +739,17 @@ describe('PaymentReallocationService', () => {
728
739
 
729
740
  // Check if the balance items are now equal
730
741
  await expectItem(b1, {
731
- priceOpen: 0,
732
- paid: [40 * 100, -40 * 100],
742
+ priceOpen: -25_00, // Still paid 25 too much
743
+ paid: [25_00],
733
744
  });
734
745
 
735
746
  await expectItem(b2, {
736
- priceOpen: 5 * 100,
737
- paid: [25 * 100],
747
+ priceOpen: 30 * 100,
748
+ paid: [],
738
749
  });
739
750
 
740
751
  await expectItem(b3, {
741
- priceOpen: 0 * 100,
752
+ priceOpen: 0 * 100, // Paid with canceled balance item that was already paid and should be refunded
742
753
  paid: [15 * 100],
743
754
  });
744
755
  });
@@ -2,6 +2,7 @@ import { BalanceItem, BalanceItemPayment, CachedBalance, Payment } from '@stamho
2
2
  import { SQL } from '@stamhoofd/sql';
3
3
  import { BalanceItemStatus, doBalanceItemRelationsMatch, PaymentMethod, PaymentStatus, PaymentType, ReceivableBalanceType } from '@stamhoofd/structures';
4
4
  import { Sorter } from '@stamhoofd/utility';
5
+ import { BalanceItemService } from './BalanceItemService';
5
6
 
6
7
  type BalanceItemWithRemaining = {
7
8
  balanceItem: BalanceItem;
@@ -30,12 +31,12 @@ export const PaymentReallocationService = {
30
31
  return;
31
32
  }
32
33
 
33
- let balanceItems = await CachedBalance.balanceForObjects(organizationId, [objectId], type);
34
+ let balanceItems = (await CachedBalance.balanceForObjects(organizationId, [objectId], type)).filter(b => b.isAfterDueDate);
34
35
 
35
36
  const didMerge: BalanceItem[] = [];
36
37
 
37
38
  // First try to merge balance items that are the same and have canceled variants
38
- for (const balanceItem of balanceItems) {
39
+ /* for (const balanceItem of balanceItems) {
39
40
  if (balanceItem.status !== BalanceItemStatus.Due) {
40
41
  continue;
41
42
  }
@@ -46,20 +47,20 @@ export const PaymentReallocationService = {
46
47
  // Not possible to merge into one: there are 2 due items
47
48
  continue;
48
49
  }
49
- const similarCanceledItems = balanceItems.filter(b => b.id !== balanceItem.id && b.type === balanceItem.type && b.status !== BalanceItemStatus.Due && doBalanceItemRelationsMatch(b.relations, balanceItem.relations, 0));
50
+ const similarCanceledItems = balanceItems.filter(b => b.id !== balanceItem.id && b.type === balanceItem.type && b.status !== BalanceItemStatus.Due && (b.pricePaid !== 0 || b.pricePending !== 0) && doBalanceItemRelationsMatch(b.relations, balanceItem.relations, 0));
50
51
 
51
52
  if (similarCanceledItems.length) {
52
53
  await this.mergeBalanceItems(balanceItem, similarCanceledItems);
53
54
  didMerge.push(balanceItem, ...similarCanceledItems);
54
55
  }
55
- }
56
+ } */
56
57
 
57
58
  if (didMerge.length) {
58
59
  // Update outstanding
59
- await BalanceItem.updateOutstanding(didMerge);
60
+ await BalanceItemService.updatePaidAndPending(didMerge);
60
61
 
61
62
  // Reload balance items
62
- balanceItems = await CachedBalance.balanceForObjects(organizationId, [objectId], type);
63
+ balanceItems = (await CachedBalance.balanceForObjects(organizationId, [objectId], type)).filter(b => b.isAfterDueDate);
63
64
  }
64
65
 
65
66
  // The algorithm:
@@ -93,6 +94,13 @@ export const PaymentReallocationService = {
93
94
  return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
94
95
  },
95
96
  },
97
+ {
98
+ // Priority 1: same relation ids, same amount
99
+ alterPayments: false,
100
+ match: (negativeItem, p) => {
101
+ return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
102
+ },
103
+ },
96
104
  {
97
105
  // Priority 2: same relation ids, different amount
98
106
  alterPayments: true,
@@ -101,9 +109,19 @@ export const PaymentReallocationService = {
101
109
  },
102
110
  },
103
111
  {
112
+ // Priority 2: same relation ids, different amount
113
+ alterPayments: false,
114
+ match: (negativeItem, p) => {
115
+ return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
116
+ },
117
+ },
118
+
119
+ // For now I would skip the next priorties because merging these often creates a lot of confusion
120
+ /* {
104
121
  // Priority 3: same type, one mismatching relation id
105
122
  alterPayments: false,
106
123
  match: (negativeItem, p) => {
124
+ // todo: maybe do allow this, but only if the amount is the same
107
125
  return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 1);
108
126
  },
109
127
  },
@@ -111,6 +129,7 @@ export const PaymentReallocationService = {
111
129
  // Priority 4: same type, two mismatching relation ids
112
130
  alterPayments: false,
113
131
  match: (negativeItem, p) => {
132
+ // todo: maybe do allow this, but only if the amount is the same
114
133
  return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 2);
115
134
  },
116
135
  },
@@ -120,8 +139,8 @@ export const PaymentReallocationService = {
120
139
  match: (negativeItem, p) => {
121
140
  return p.balanceItem.type === negativeItem.balanceItem.type && p.remaining === -negativeItem.remaining;
122
141
  },
123
- },
124
- {
142
+ }, */
143
+ /* {
125
144
  // Priority 6: same type
126
145
  alterPayments: false,
127
146
  match: (negativeItem, p) => {
@@ -134,7 +153,7 @@ export const PaymentReallocationService = {
134
153
  match: () => {
135
154
  return true;
136
155
  },
137
- },
156
+ }, */
138
157
  ];
139
158
 
140
159
  for (const matchMethod of matchMethods) {
@@ -205,7 +224,7 @@ export const PaymentReallocationService = {
205
224
  }
206
225
 
207
226
  // Update outstanding
208
- await BalanceItem.updateOutstanding([
227
+ await BalanceItemService.updatePaidAndPending([
209
228
  ...negativeItems.filter(n => n.remaining !== n.balanceItem.priceOpen).map(n => n.balanceItem),
210
229
  ...positiveItems.filter(p => p.remaining !== p.balanceItem.priceOpen).map(p => p.balanceItem),
211
230
  ]);