@stamhoofd/backend 2.62.0 → 2.64.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 (33) hide show
  1. package/index.ts +8 -6
  2. package/package.json +11 -11
  3. package/src/audit-logs/PaymentLogger.ts +1 -1
  4. package/src/crons/index.ts +1 -0
  5. package/src/crons/update-cached-balances.ts +39 -0
  6. package/src/email-recipient-loaders/receivable-balances.ts +5 -0
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +41 -16
  8. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +2 -0
  9. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +6 -3
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +85 -25
  11. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +15 -1
  12. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +89 -30
  13. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +62 -17
  14. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +52 -2
  15. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +8 -2
  16. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +10 -2
  17. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +19 -8
  18. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +10 -5
  19. package/src/helpers/AdminPermissionChecker.ts +3 -2
  20. package/src/helpers/AuthenticatedStructures.ts +127 -9
  21. package/src/helpers/MembershipCharger.ts +4 -0
  22. package/src/helpers/OrganizationCharger.ts +4 -0
  23. package/src/seeds/1733994455-balance-item-status-open.ts +30 -0
  24. package/src/seeds/1734596144-fill-previous-period-id.ts +55 -0
  25. package/src/seeds/1734700082-update-cached-outstanding-balance-from-items.ts +40 -0
  26. package/src/services/BalanceItemPaymentService.ts +8 -4
  27. package/src/services/BalanceItemService.ts +22 -3
  28. package/src/services/PaymentReallocationService.test.ts +746 -0
  29. package/src/services/PaymentReallocationService.ts +339 -0
  30. package/src/services/PaymentService.ts +13 -0
  31. package/src/services/PlatformMembershipService.ts +167 -137
  32. package/src/sql-filters/receivable-balances.ts +2 -1
  33. package/src/sql-sorters/receivable-balances.ts +3 -3
@@ -0,0 +1,339 @@
1
+ import { BalanceItem, BalanceItemPayment, CachedBalance, Payment } from '@stamhoofd/models';
2
+ import { SQL } from '@stamhoofd/sql';
3
+ import { BalanceItemStatus, doBalanceItemRelationsMatch, PaymentMethod, PaymentStatus, PaymentType, ReceivableBalanceType } from '@stamhoofd/structures';
4
+ import { Sorter } from '@stamhoofd/utility';
5
+
6
+ type BalanceItemWithRemaining = {
7
+ balanceItem: BalanceItem;
8
+ remaining: number;
9
+ };
10
+
11
+ export const PaymentReallocationService = {
12
+ /**
13
+ * Move all canceled balance items payments to one single non-canceled item (note: they should be equal)
14
+ *
15
+ * This avoids the situation where you have multiple balance items for a registration, but only one registration
16
+ * -> this can cause confusion because people might think 'hey' the price for that registration is z, not b (while it is about a canceled registration)
17
+ */
18
+ async mergeBalanceItems(mergeToItem: BalanceItem, otherItems: BalanceItem[]) {
19
+ // Move all balance item payments to the merged item
20
+ await SQL.update(BalanceItemPayment.table)
21
+ .set('balanceItemId', mergeToItem.id)
22
+ .where('balanceItemId', otherItems.map(bi => bi.id))
23
+ .update();
24
+ },
25
+
26
+ async reallocate(organizationId: string, objectId: string, type: ReceivableBalanceType) {
27
+ if (STAMHOOFD.environment === 'production') {
28
+ // Disabled on production for now
29
+ // until this has been tested more
30
+ return;
31
+ }
32
+
33
+ let balanceItems = await CachedBalance.balanceForObjects(organizationId, [objectId], type);
34
+
35
+ const didMerge: BalanceItem[] = [];
36
+
37
+ // First try to merge balance items that are the same and have canceled variants
38
+ for (const balanceItem of balanceItems) {
39
+ if (balanceItem.status !== BalanceItemStatus.Due) {
40
+ continue;
41
+ }
42
+
43
+ const similarDueItems = balanceItems.filter(b => b.id !== balanceItem.id && b.status === BalanceItemStatus.Due && doBalanceItemRelationsMatch(b.relations, balanceItem.relations, 0));
44
+
45
+ if (similarDueItems.length) {
46
+ // Not possible to merge into one: there are 2 due items
47
+ continue;
48
+ }
49
+ const similarCanceledItems = balanceItems.filter(b => b.id !== balanceItem.id && b.status !== BalanceItemStatus.Due && doBalanceItemRelationsMatch(b.relations, balanceItem.relations, 0));
50
+
51
+ if (similarCanceledItems.length) {
52
+ await this.mergeBalanceItems(balanceItem, similarCanceledItems);
53
+ didMerge.push(balanceItem, ...similarCanceledItems);
54
+ }
55
+ }
56
+
57
+ if (didMerge.length) {
58
+ // Update outstanding
59
+ await BalanceItem.updateOutstanding(didMerge);
60
+
61
+ // Reload balance items
62
+ balanceItems = await CachedBalance.balanceForObjects(organizationId, [objectId], type);
63
+ }
64
+
65
+ // The algorithm:
66
+ // Search balance items that were paid too much, or have a negative open amount.
67
+ // Find the best match in the positive list (balances that have an open amount). Try to find either:
68
+ // - Same type, One with the same relation ids and the same amount -> special case: try to move the balance item payments around since this is safe (no impact on finances or tax)
69
+ // - Same type, One with the same relation ids
70
+ // - Same type, tne with one mismatching relation ids (e.g. same group id, different price id; same webshop, same product, different price)
71
+ // - Same type, One with two mismatching relation ids (e.g. same webshop, different product)
72
+ // - Same type, same amount
73
+ // - Same type
74
+ // - Unrelated balance items
75
+ // This priority must be given for each balance item, so we start with priority 1 for all items, then priority 2 for all items...
76
+ // The result should be a list of postive and negative balance items that is maximized (as much paid items should have been resolved) and equals zero.
77
+
78
+ const negativeItems = balanceItems.filter(b => b.priceOpen < 0).map(balanceItem => ({ balanceItem, remaining: balanceItem.priceOpen }));
79
+ const positiveItems = balanceItems.filter(b => b.priceOpen > 0).map(balanceItem => ({ balanceItem, remaining: balanceItem.priceOpen }));
80
+
81
+ if (negativeItems.length === 0 || positiveItems.length === 0) {
82
+ return;
83
+ }
84
+
85
+ // Now we reach the part where we are going to create reallocation payments (we are no longer going to alter existing payments)
86
+ const pendingPayment = new Map<BalanceItem, number>();
87
+
88
+ const matchMethods: { alterPayments: boolean; match: (negativeItem: BalanceItemWithRemaining, positive: BalanceItemWithRemaining) => boolean }[] = [
89
+ {
90
+ // Priority 1: same relation ids, same amount
91
+ alterPayments: true,
92
+ match: (negativeItem, p) => {
93
+ return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
94
+ },
95
+ },
96
+ {
97
+ // Priority 2: same relation ids, different amount
98
+ alterPayments: true,
99
+ match: (negativeItem, p) => {
100
+ return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
101
+ },
102
+ },
103
+ {
104
+ // Priority 3: same type, one mismatching relation id
105
+ alterPayments: false,
106
+ match: (negativeItem, p) => {
107
+ return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 1);
108
+ },
109
+ },
110
+ {
111
+ // Priority 4: same type, two mismatching relation ids
112
+ alterPayments: false,
113
+ match: (negativeItem, p) => {
114
+ return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 2);
115
+ },
116
+ },
117
+ {
118
+ // Priority 5: same type, same amount
119
+ alterPayments: false,
120
+ match: (negativeItem, p) => {
121
+ return p.balanceItem.type === negativeItem.balanceItem.type && p.remaining === -negativeItem.remaining;
122
+ },
123
+ },
124
+ {
125
+ // Priority 6: same type
126
+ alterPayments: false,
127
+ match: (negativeItem, p) => {
128
+ return p.balanceItem.type === negativeItem.balanceItem.type;
129
+ },
130
+ },
131
+ {
132
+ // Priority: any
133
+ alterPayments: false,
134
+ match: () => {
135
+ return true;
136
+ },
137
+ },
138
+ ];
139
+
140
+ for (const matchMethod of matchMethods) {
141
+ // Sort negative items on hight > low
142
+ negativeItems.sort((a, b) => b.remaining - a.remaining);
143
+
144
+ // Sort positive items on due date, then hight > low
145
+ positiveItems.sort((a, b) => Sorter.stack(
146
+ Sorter.byDateValue(b.balanceItem.dueAt ?? new Date(0), a.balanceItem.dueAt ?? new Date(0)),
147
+ b.remaining - a.remaining,
148
+ ));
149
+
150
+ for (const negativeItem of negativeItems) {
151
+ if (negativeItem.remaining >= 0) {
152
+ continue;
153
+ }
154
+
155
+ const match = positiveItems.find((p) => {
156
+ return p.remaining > 0 && matchMethod.match(negativeItem, p);
157
+ });
158
+
159
+ if (!match) {
160
+ continue;
161
+ }
162
+
163
+ if (matchMethod.alterPayments) {
164
+ await this.swapPayments(negativeItem, match);
165
+ }
166
+ else {
167
+ // Add to pending payment
168
+ const moveAmount = Math.min(match.remaining, -negativeItem.remaining);
169
+
170
+ negativeItem.remaining += moveAmount;
171
+ match.remaining -= moveAmount;
172
+
173
+ pendingPayment.set(negativeItem.balanceItem, (pendingPayment.get(negativeItem.balanceItem) ?? 0) - moveAmount);
174
+ pendingPayment.set(match.balanceItem, (pendingPayment.get(match.balanceItem) ?? 0) + moveAmount);
175
+ }
176
+ }
177
+ }
178
+
179
+ // Create payment
180
+ if (pendingPayment.size !== 0) {
181
+ // Assert total is zero
182
+ const total = Array.from(pendingPayment.values()).reduce((a, b) => a + b, 0);
183
+ if (total !== 0) {
184
+ throw new Error('Total is not zero');
185
+ }
186
+
187
+ const payment = new Payment();
188
+ payment.organizationId = organizationId;
189
+ payment.price = 0;
190
+ payment.type = PaymentType.Reallocation;
191
+ payment.method = PaymentMethod.Unknown;
192
+ payment.status = PaymentStatus.Succeeded;
193
+ await payment.save();
194
+
195
+ // Create balance item payments
196
+ for (const [balanceItem, price] of pendingPayment) {
197
+ const balanceItemPayment = new BalanceItemPayment();
198
+ balanceItemPayment.balanceItemId = balanceItem.id;
199
+ balanceItemPayment.paymentId = payment.id;
200
+ balanceItemPayment.price = price;
201
+ balanceItemPayment.organizationId = organizationId;
202
+ await balanceItemPayment.save();
203
+ }
204
+ }
205
+
206
+ // Update outstanding
207
+ await BalanceItem.updateOutstanding([
208
+ ...negativeItems.filter(n => n.remaining !== n.balanceItem.priceOpen).map(n => n.balanceItem),
209
+ ...positiveItems.filter(p => p.remaining !== p.balanceItem.priceOpen).map(p => p.balanceItem),
210
+ ]);
211
+ },
212
+
213
+ async swapPayments(negativeItem: BalanceItemWithRemaining, match: BalanceItemWithRemaining) {
214
+ const { balanceItemPayments: allBalanceItemPayments, payments } = await BalanceItem.loadPayments([negativeItem.balanceItem, match.balanceItem]);
215
+
216
+ // Remove balance item payments of failed or deleted payments
217
+ const balanceItemPayments = allBalanceItemPayments.filter((bp) => {
218
+ const payment = payments.find(p => p.id === bp.paymentId);
219
+ if (!payment || payment.status === PaymentStatus.Failed) {
220
+ return false;
221
+ }
222
+ return true;
223
+ });
224
+
225
+ if (balanceItemPayments.length === 0) {
226
+ return;
227
+ }
228
+
229
+ // First try to find exact matches
230
+ await this.doSwapPayments(balanceItemPayments, negativeItem, match, { split: false, exactOnly: true });
231
+
232
+ // Try with not exact matches
233
+ await this.doSwapPayments(balanceItemPayments, negativeItem, match, { split: false, exactOnly: false });
234
+
235
+ // Try with matches that are too big
236
+ await this.doSwapPayments(balanceItemPayments, negativeItem, match, { split: true, exactOnly: false });
237
+ },
238
+
239
+ async doSwapPayments(balanceItemPayments: BalanceItemPayment[], negativeItem: BalanceItemWithRemaining, match: BalanceItemWithRemaining, options: { split: boolean; exactOnly: boolean }) {
240
+ if (negativeItem.remaining >= 0 || match.remaining <= 0) {
241
+ // Stop because one item is zero or switched sign
242
+ return;
243
+ }
244
+
245
+ // Try with matches that are too big
246
+ for (const bp of balanceItemPayments) {
247
+ const price = bp.price;
248
+
249
+ if (bp.balanceItemId === match.balanceItem.id) {
250
+ if (price < 0) {
251
+ if (price < negativeItem.remaining || price < -match.remaining) {
252
+ if (!options.split) {
253
+ continue;
254
+ }
255
+ // Split the balance item payment in two: a part for negative item, and a part for match
256
+ const swap = -Math.min(-negativeItem.remaining, match.remaining);
257
+
258
+ // We need to split
259
+ bp.price -= swap;
260
+ await bp.save();
261
+
262
+ // Create a new duplicate
263
+ const newBP = new BalanceItemPayment();
264
+ newBP.organizationId = bp.organizationId;
265
+ newBP.paymentId = bp.paymentId;
266
+ newBP.balanceItemId = negativeItem.balanceItem.id;
267
+ newBP.price = swap;
268
+ await newBP.save();
269
+
270
+ negativeItem.remaining -= swap;
271
+ match.remaining += swap;
272
+ }
273
+ else {
274
+ if (options.exactOnly && !(price === negativeItem.remaining || price === -match.remaining)) {
275
+ continue;
276
+ }
277
+
278
+ // Swap
279
+ bp.balanceItemId = negativeItem.balanceItem.id;
280
+ await bp.save();
281
+
282
+ negativeItem.remaining -= price;
283
+ match.remaining += price;
284
+ }
285
+
286
+ if (negativeItem.remaining >= 0 || match.remaining <= 0) {
287
+ // Stop because one item is zero or switched sign
288
+ return;
289
+ }
290
+ }
291
+ }
292
+ else {
293
+ if (price > 0) {
294
+ if (price > -negativeItem.remaining || price > match.remaining) {
295
+ if (!options.split) {
296
+ continue;
297
+ }
298
+
299
+ // Split the balance item payment in two: a part for negative item, and a part for match
300
+ const swap = Math.min(-negativeItem.remaining, match.remaining);
301
+
302
+ // We need to split
303
+ bp.price -= swap;
304
+ await bp.save();
305
+
306
+ // Create a new duplicate
307
+ const newBP = new BalanceItemPayment();
308
+ newBP.organizationId = bp.organizationId;
309
+ newBP.paymentId = bp.paymentId;
310
+ newBP.balanceItemId = match.balanceItem.id;
311
+ newBP.price = swap;
312
+ await newBP.save();
313
+
314
+ negativeItem.remaining += swap;
315
+ match.remaining -= swap;
316
+ }
317
+ else {
318
+ if (options.exactOnly && !(price === -negativeItem.remaining || price === match.remaining)) {
319
+ continue;
320
+ }
321
+
322
+ // Swap
323
+ bp.balanceItemId = match.balanceItem.id;
324
+ await bp.save();
325
+
326
+ negativeItem.remaining += price;
327
+ match.remaining -= price;
328
+ }
329
+
330
+ if (negativeItem.remaining >= 0 || match.remaining <= 0) {
331
+ // Stop because one item is zero or switched sign
332
+ return;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ },
338
+
339
+ };
@@ -6,6 +6,7 @@ import { BuckarooHelper } from '../helpers/BuckarooHelper';
6
6
  import { StripeHelper } from '../helpers/StripeHelper';
7
7
  import { BalanceItemPaymentService } from './BalanceItemPaymentService';
8
8
  import { AuditLogService } from './AuditLogService';
9
+ import { BalanceItemService } from './BalanceItemService';
9
10
 
10
11
  export const PaymentService = {
11
12
  async handlePaymentStatusUpdate(payment: Payment, organization: Organization, status: PaymentStatus) {
@@ -31,6 +32,9 @@ export const PaymentService = {
31
32
  }
32
33
 
33
34
  await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
35
+
36
+ // Reallocate
37
+ await BalanceItemService.reallocate(balanceItemPayments.map(p => p.balanceItem), organization.id);
34
38
  });
35
39
  return;
36
40
  }
@@ -55,6 +59,9 @@ export const PaymentService = {
55
59
  }
56
60
 
57
61
  await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
62
+
63
+ // Reallocate
64
+ await BalanceItemService.reallocate(balanceItemPayments.map(p => p.balanceItem), organization.id);
58
65
  });
59
66
  }
60
67
 
@@ -70,6 +77,9 @@ export const PaymentService = {
70
77
  }
71
78
 
72
79
  await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
80
+
81
+ // Reallocate
82
+ await BalanceItemService.reallocate(balanceItemPayments.map(p => p.balanceItem), organization.id);
73
83
  });
74
84
  }
75
85
 
@@ -85,6 +95,9 @@ export const PaymentService = {
85
95
  }
86
96
 
87
97
  await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
98
+
99
+ // Reallocate
100
+ await BalanceItemService.reallocate(balanceItemPayments.map(p => p.balanceItem), organization.id);
88
101
  });
89
102
  }
90
103
  });