@stamhoofd/backend 2.106.1 → 2.107.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 (30) hide show
  1. package/index.ts +6 -1
  2. package/package.json +17 -11
  3. package/src/boot.ts +28 -22
  4. package/src/endpoints/frontend/FrontendEnvironmentEndpoint.ts +89 -0
  5. package/src/endpoints/global/billing/ActivatePackagesEndpoint.ts +30 -0
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +3 -2
  7. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +8 -3
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +109 -109
  9. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +7 -0
  10. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +9 -7
  11. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +7 -0
  12. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +9 -2
  13. package/src/excel-loaders/payments.ts +5 -5
  14. package/src/excel-loaders/receivable-balances.ts +7 -7
  15. package/src/helpers/AdminPermissionChecker.ts +20 -0
  16. package/src/helpers/BuckarooHelper.ts +1 -1
  17. package/src/helpers/ServiceFeeHelper.ts +8 -4
  18. package/src/helpers/StripeHelper.ts +20 -35
  19. package/src/seeds/1752848561-groups-registration-periods.ts +35 -0
  20. package/src/services/BalanceItemService.ts +15 -2
  21. package/src/services/PaymentReallocationService.test.ts +298 -128
  22. package/src/services/PaymentReallocationService.ts +46 -16
  23. package/src/services/PaymentService.ts +49 -2
  24. package/src/services/uitpas/getSocialTariffForEvent.ts +2 -2
  25. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +2 -2
  26. package/src/services/uitpas/registerTicketSales.ts +2 -2
  27. package/tests/e2e/bundle-discounts.test.ts +415 -391
  28. package/tests/e2e/documents.test.ts +21 -21
  29. package/tests/e2e/register.test.ts +93 -93
  30. package/tests/e2e/stock.test.ts +4 -4
@@ -36,7 +36,7 @@ export const PaymentReallocationService = {
36
36
  const didMerge: BalanceItem[] = [];
37
37
 
38
38
  // First try to merge balance items that are the same and have canceled variants
39
- /* for (const balanceItem of balanceItems) {
39
+ for (const balanceItem of balanceItems) {
40
40
  if (balanceItem.status !== BalanceItemStatus.Due) {
41
41
  continue;
42
42
  }
@@ -53,7 +53,7 @@ export const PaymentReallocationService = {
53
53
  await this.mergeBalanceItems(balanceItem, similarCanceledItems);
54
54
  didMerge.push(balanceItem, ...similarCanceledItems);
55
55
  }
56
- } */
56
+ }
57
57
 
58
58
  if (didMerge.length) {
59
59
  // Update outstanding
@@ -79,6 +79,13 @@ export const PaymentReallocationService = {
79
79
  const negativeItems = balanceItems.filter(b => b.priceOpen < 0).map(balanceItem => ({ balanceItem, remaining: balanceItem.priceOpen }));
80
80
  const positiveItems = balanceItems.filter(b => b.priceOpen > 0).map(balanceItem => ({ balanceItem, remaining: balanceItem.priceOpen }));
81
81
 
82
+ // If the total remaining is zero, we'll just merge everything together. This removes the restriction that two merged balance items should have the same remaining amount.
83
+ const canReachZero = (negativeItems.reduce((p, i) => p + i.remaining, 0) === -positiveItems.reduce((p, i) => p + i.remaining, 0));
84
+
85
+ // Rules:
86
+ // Total outstanding amount should be zero to merge all items into one payment reallocation
87
+ // otherwise, only swap if the amounts match exactly
88
+
82
89
  if (negativeItems.length === 0 || positiveItems.length === 0) {
83
90
  return;
84
91
  }
@@ -88,48 +95,58 @@ export const PaymentReallocationService = {
88
95
 
89
96
  const matchMethods: { alterPayments: boolean; match: (negativeItem: BalanceItemWithRemaining, positive: BalanceItemWithRemaining) => boolean }[] = [
90
97
  {
91
- // Priority 1: same relation ids, same amount
98
+ // Priority 1: same type, same relation ids, same amount
92
99
  alterPayments: true,
93
100
  match: (negativeItem, p) => {
94
101
  return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
95
102
  },
96
103
  },
97
104
  {
98
- // Priority 1: same relation ids, same amount
105
+ // Priority 1: same type, same relation ids, same amount
99
106
  alterPayments: false,
100
107
  match: (negativeItem, p) => {
101
108
  return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
102
109
  },
103
110
  },
104
111
  {
105
- // Priority 2: same relation ids, different amount
112
+ // Priority 2: same type, same relation ids, different amount
106
113
  alterPayments: true,
107
114
  match: (negativeItem, p) => {
108
115
  return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
109
116
  },
110
117
  },
111
118
  {
112
- // Priority 2: same relation ids, different amount
119
+ // Priority 2: same type, same relation ids, different amount
113
120
  alterPayments: false,
114
121
  match: (negativeItem, p) => {
115
122
  return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 0);
116
123
  },
117
124
  },
118
-
119
- // For now I would skip the next priorties because merging these often creates a lot of confusion
120
- /* {
125
+ {
126
+ // Priority 3: same type, one mismatching relation id, same amount
127
+ alterPayments: false,
128
+ match: (negativeItem, p) => {
129
+ return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 1);
130
+ },
131
+ },
132
+ {
121
133
  // Priority 3: same type, one mismatching relation id
122
134
  alterPayments: false,
123
135
  match: (negativeItem, p) => {
124
- // todo: maybe do allow this, but only if the amount is the same
125
136
  return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 1);
126
137
  },
127
138
  },
139
+ {
140
+ // Priority 4: same type, two mismatching relation ids, same amount
141
+ alterPayments: false,
142
+ match: (negativeItem, p) => {
143
+ return p.remaining === -negativeItem.remaining && p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 2);
144
+ },
145
+ },
128
146
  {
129
147
  // Priority 4: same type, two mismatching relation ids
130
148
  alterPayments: false,
131
149
  match: (negativeItem, p) => {
132
- // todo: maybe do allow this, but only if the amount is the same
133
150
  return p.balanceItem.type === negativeItem.balanceItem.type && doBalanceItemRelationsMatch(p.balanceItem.relations, negativeItem.balanceItem.relations, 2);
134
151
  },
135
152
  },
@@ -139,8 +156,15 @@ export const PaymentReallocationService = {
139
156
  match: (negativeItem, p) => {
140
157
  return p.balanceItem.type === negativeItem.balanceItem.type && p.remaining === -negativeItem.remaining;
141
158
  },
142
- }, */
143
- /* {
159
+ },
160
+ {
161
+ // Priority 6: same amount
162
+ alterPayments: false,
163
+ match: (negativeItem, p) => {
164
+ return p.remaining === -negativeItem.remaining;
165
+ },
166
+ },
167
+ {
144
168
  // Priority 6: same type
145
169
  alterPayments: false,
146
170
  match: (negativeItem, p) => {
@@ -150,10 +174,10 @@ export const PaymentReallocationService = {
150
174
  {
151
175
  // Priority: any
152
176
  alterPayments: false,
153
- match: () => {
177
+ match: (negativeItem, p) => {
154
178
  return true;
155
179
  },
156
- }, */
180
+ },
157
181
  ];
158
182
 
159
183
  for (const matchMethod of matchMethods) {
@@ -172,7 +196,7 @@ export const PaymentReallocationService = {
172
196
  }
173
197
 
174
198
  const match = positiveItems.find((p) => {
175
- return p.remaining > 0 && matchMethod.match(negativeItem, p);
199
+ return p.remaining > 0 && matchMethod.match(negativeItem, p) && (matchMethod.alterPayments || canReachZero || p.remaining === -negativeItem.remaining);
176
200
  });
177
201
 
178
202
  if (!match) {
@@ -183,6 +207,12 @@ export const PaymentReallocationService = {
183
207
  await this.swapPayments(negativeItem, match);
184
208
  }
185
209
  else {
210
+ if (match.remaining !== -negativeItem.remaining) {
211
+ if (!canReachZero) {
212
+ continue;
213
+ }
214
+ }
215
+
186
216
  // Add to pending payment
187
217
  const moveAmount = Math.min(match.remaining, -negativeItem.remaining);
188
218
 
@@ -1,11 +1,11 @@
1
1
  import createMollieClient, { PaymentStatus as MolliePaymentStatus } from '@mollie/api-client';
2
2
  import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment } from '@stamhoofd/models';
3
3
  import { QueueHandler } from '@stamhoofd/queues';
4
- import { AuditLogSource, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
4
+ import { AuditLogSource, BalanceItemRelation, BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
5
5
  import { BuckarooHelper } from '../helpers/BuckarooHelper';
6
6
  import { StripeHelper } from '../helpers/StripeHelper';
7
- import { BalanceItemPaymentService } from './BalanceItemPaymentService';
8
7
  import { AuditLogService } from './AuditLogService';
8
+ import { BalanceItemPaymentService } from './BalanceItemPaymentService';
9
9
  import { BalanceItemService } from './BalanceItemService';
10
10
 
11
11
  export const PaymentService = {
@@ -331,4 +331,51 @@ export const PaymentService = {
331
331
  }
332
332
  return false;
333
333
  },
334
+
335
+ /**
336
+ * Say the total amount to pay is 15,238 because (e.g. because of VAT). In that case,
337
+ * we'll need to round the payment to 1 cent. That can cause issues in the financial statements because
338
+ * the total amount of balances does not match the total amount received/paid.
339
+ *
340
+ * To fix that, we create an extra balance item with the difference. So the rounding always matches.
341
+ */
342
+ async round(payment: Payment) {
343
+ if (!payment.organizationId) {
344
+ throw new Error('Cannot round payments without organizationId')
345
+ }
346
+
347
+ const amount = payment.price;
348
+ const rounded = Payment.roundPrice(payment.price)
349
+ const difference = rounded - amount;
350
+
351
+ if (difference === 0) {
352
+ return;
353
+ }
354
+
355
+ if (difference > 100) {
356
+ throw new Error('Unexpected rounding difference.')
357
+ }
358
+
359
+ const balanceItem = new BalanceItem();
360
+ balanceItem.type = BalanceItemType.Rounding;
361
+ balanceItem.userId = payment.payingUserId;
362
+ balanceItem.payingOrganizationId = payment.payingOrganizationId
363
+ balanceItem.unitPrice = difference;
364
+ balanceItem.pricePaid = 0;
365
+ balanceItem.organizationId = payment.organizationId;
366
+ balanceItem.status = BalanceItemStatus.Hidden;
367
+ await balanceItem.save();
368
+
369
+ // Create balance item payment
370
+ const balanceItemPayment = new BalanceItemPayment();
371
+ balanceItemPayment.organizationId = payment.organizationId;
372
+ balanceItemPayment.balanceItemId = balanceItem.id;
373
+ balanceItemPayment.price = difference;
374
+ balanceItemPayment.paymentId = payment.id;
375
+ await balanceItemPayment.save();
376
+
377
+ // Change payment total price
378
+ payment.price += difference
379
+ await payment.save();
380
+ }
334
381
  };
@@ -29,7 +29,7 @@ function assertsIsStaticSocialTariffResponse(json: unknown): asserts json is Sta
29
29
  export async function getSocialTariffForEvent(access_token: string, basePrice: number, uitpasEventUrl: string) {
30
30
  const baseUrl = 'https://api-test.uitpas.be/tariffs/static';
31
31
  const params = new URLSearchParams();
32
- params.append('regularPrice', (basePrice / 100).toFixed(2));
32
+ params.append('regularPrice', (basePrice / 10_000).toFixed(2));
33
33
  const eventId = uitpasEventUrl.split('/').pop(); // Extract the event ID from the URL
34
34
  if (!eventId) {
35
35
  throw new SimpleError({
@@ -86,5 +86,5 @@ export async function getSocialTariffForEvent(access_token: string, basePrice: n
86
86
  if (json.available.length > 1) {
87
87
  console.warn('Multiple social tariffs available for event', eventId, '(used ', json.available[0].price, ' as base price. All options:', json.available);
88
88
  }
89
- return Math.round(json.available[0].price * 100);
89
+ return Math.round(json.available[0].price * 100) * 100;
90
90
  }
@@ -54,7 +54,7 @@ function isSocialTariffErrorResponse(
54
54
  async function getSocialTariffForUitpasNumber(access_token: string, uitpasNumber: string, basePrice: number, uitpasEventUrl: string) {
55
55
  const baseUrl = 'https://api-test.uitpas.be/tariffs';
56
56
  const params = new URLSearchParams();
57
- params.append('regularPrice', (basePrice / 100).toFixed(2));
57
+ params.append('regularPrice', (basePrice / 1_0000).toFixed(2));
58
58
  const eventId = uitpasEventUrl.split('/').pop();
59
59
  if (!eventId) {
60
60
  throw new SimpleError({
@@ -156,7 +156,7 @@ async function getSocialTariffForUitpasNumber(access_token: string, uitpasNumber
156
156
  console.log('Social tariff for UiTPAS number', uitpasNumber, 'with event id', uitpasEventUrl, 'is', json.available[0].price, 'euros');
157
157
  return UitpasNumberAndPrice.create({
158
158
  uitpasNumber,
159
- price: Math.round((json.available[0].price) * 100),
159
+ price: Math.round((json.available[0].price) * 100) * 100,
160
160
  uitpasTariffId: json.available[0].id,
161
161
  });
162
162
  }
@@ -76,7 +76,7 @@ export async function registerTicketSales(access_token: string, registerTicketSa
76
76
  return {
77
77
  uitpasNumber: ticketSale.uitpasNumber,
78
78
  eventId,
79
- regularPrice: (ticketSale.basePrice / 100).toFixed(2), // Convert from cents to euros
79
+ regularPrice: (ticketSale.basePrice / 10000).toFixed(2), // Convert from 4 decimals to 0 decimals
80
80
  regularPriceLabel: ticketSale.basePriceLabel,
81
81
  tariff: {
82
82
  id: ticketSale.uitpasTariffId,
@@ -131,7 +131,7 @@ export async function registerTicketSales(access_token: string, registerTicketSa
131
131
  }
132
132
  results.set(request, {
133
133
  ticketSaleId: ticketSale.id,
134
- reducedPriceUitpas: Math.round(ticketSale.tariff.price * 100),
134
+ reducedPriceUitpas: Math.round(ticketSale.tariff.price * 100) * 100,
135
135
  registeredAt: now,
136
136
  });
137
137
  }