@stamhoofd/backend 2.55.2 → 2.57.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 (27) hide show
  1. package/index.ts +4 -0
  2. package/package.json +12 -11
  3. package/src/crons.ts +4 -3
  4. package/src/endpoints/global/audit-logs/GetAuditLogsEndpoint.ts +150 -0
  5. package/src/endpoints/global/events/PatchEventsEndpoint.ts +27 -3
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +27 -9
  7. package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +2 -1
  8. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +17 -2
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +17 -1
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +12 -9
  11. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -1
  12. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +5 -3
  13. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +21 -1
  14. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +4 -306
  15. package/src/helpers/AdminPermissionChecker.ts +102 -1
  16. package/src/helpers/AuthenticatedStructures.ts +46 -2
  17. package/src/helpers/EmailResumer.ts +8 -3
  18. package/src/seeds/1732117645-move-rrn.ts +77 -0
  19. package/src/services/AuditLogService.ts +232 -0
  20. package/src/services/BalanceItemPaymentService.ts +45 -0
  21. package/src/services/BalanceItemService.ts +88 -0
  22. package/src/services/GroupService.ts +13 -0
  23. package/src/services/PaymentService.ts +308 -0
  24. package/src/services/RegistrationService.ts +78 -0
  25. package/src/services/explainPatch.ts +639 -0
  26. package/src/sql-filters/audit-logs.ts +10 -0
  27. package/src/sql-sorters/audit-logs.ts +35 -0
@@ -0,0 +1,308 @@
1
+ import createMollieClient, { PaymentStatus as MolliePaymentStatus } from '@mollie/api-client';
2
+ import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment } from '@stamhoofd/models';
3
+ import { QueueHandler } from '@stamhoofd/queues';
4
+ import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
5
+ import { BuckarooHelper } from '../helpers/BuckarooHelper';
6
+ import { StripeHelper } from '../helpers/StripeHelper';
7
+ import { BalanceItemPaymentService } from './BalanceItemPaymentService';
8
+
9
+ export const PaymentService = {
10
+ async handlePaymentStatusUpdate(payment: Payment, organization: Organization, status: PaymentStatus) {
11
+ if (payment.status === status) {
12
+ return;
13
+ }
14
+
15
+ if (status === PaymentStatus.Succeeded) {
16
+ payment.status = PaymentStatus.Succeeded;
17
+ payment.paidAt = new Date();
18
+ await payment.save();
19
+
20
+ // Prevent concurrency issues
21
+ await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
22
+ const unloaded = (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment));
23
+ const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
24
+ unloaded,
25
+ );
26
+
27
+ for (const balanceItemPayment of balanceItemPayments) {
28
+ await BalanceItemPaymentService.markPaid(balanceItemPayment, organization);
29
+ }
30
+
31
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
32
+ });
33
+ return;
34
+ }
35
+
36
+ const oldStatus = payment.status;
37
+
38
+ // Save before updating balance items
39
+ payment.status = status;
40
+ payment.paidAt = null;
41
+ await payment.save();
42
+
43
+ // If OLD status was succeeded, we need to revert the actions
44
+ if (oldStatus === PaymentStatus.Succeeded) {
45
+ // No longer succeeded
46
+ await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
47
+ const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
48
+ (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment)),
49
+ );
50
+
51
+ for (const balanceItemPayment of balanceItemPayments) {
52
+ await BalanceItemPaymentService.undoPaid(balanceItemPayment, organization);
53
+ }
54
+
55
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
56
+ });
57
+ }
58
+
59
+ // Moved to failed
60
+ if (status == PaymentStatus.Failed) {
61
+ await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
62
+ const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
63
+ (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment)),
64
+ );
65
+
66
+ for (const balanceItemPayment of balanceItemPayments) {
67
+ await BalanceItemPaymentService.markFailed(balanceItemPayment, organization);
68
+ }
69
+
70
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
71
+ });
72
+ }
73
+
74
+ // If OLD status was FAILED, we need to revert the actions
75
+ if (oldStatus === PaymentStatus.Failed) { // OLD FAILED!! -> NOW PENDING
76
+ await QueueHandler.schedule('balance-item-update/' + organization.id, async () => {
77
+ const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
78
+ (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment)),
79
+ );
80
+
81
+ for (const balanceItemPayment of balanceItemPayments) {
82
+ await BalanceItemPaymentService.undoFailed(balanceItemPayment, organization);
83
+ }
84
+
85
+ await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
86
+ });
87
+ }
88
+ },
89
+
90
+ /**
91
+ * ID of payment is needed because of race conditions (need to fetch payment in a race condition save queue)
92
+ */
93
+ async pollStatus(paymentId: string, org: Organization | null, cancel = false): Promise<Payment | undefined> {
94
+ // Prevent polling the same payment multiple times at the same time: create a queue to prevent races
95
+ return await QueueHandler.schedule('payments/' + paymentId, async () => {
96
+ // Get a new copy of the payment (is required to prevent concurreny bugs)
97
+ const payment = await Payment.getByID(paymentId);
98
+ if (!payment) {
99
+ return;
100
+ }
101
+
102
+ if (!payment.organizationId) {
103
+ console.error('Payment without organization not supported', payment.id);
104
+ return;
105
+ }
106
+
107
+ const organization = org ?? await Organization.getByID(payment.organizationId);
108
+ if (!organization) {
109
+ console.error('Organization not found for payment', payment.id);
110
+ return;
111
+ }
112
+
113
+ const testMode = organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production';
114
+
115
+ if (payment.status === PaymentStatus.Pending || payment.status === PaymentStatus.Created || (payment.provider === PaymentProvider.Buckaroo && payment.status === PaymentStatus.Failed)) {
116
+ if (payment.provider === PaymentProvider.Stripe) {
117
+ try {
118
+ let status = await StripeHelper.getStatus(payment, cancel || this.shouldTryToCancel(payment.status, payment), testMode);
119
+
120
+ if (this.isManualExpired(status, payment)) {
121
+ console.error('Manually marking Stripe payment as expired', payment.id);
122
+ status = PaymentStatus.Failed;
123
+ }
124
+
125
+ await this.handlePaymentStatusUpdate(payment, organization, status);
126
+ }
127
+ catch (e) {
128
+ console.error('Payment check failed Stripe', payment.id, e);
129
+ if (this.isManualExpired(payment.status, payment)) {
130
+ console.error('Manually marking Stripe payment as expired', payment.id);
131
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
132
+ }
133
+ }
134
+ }
135
+ else if (payment.provider === PaymentProvider.Mollie) {
136
+ // check status via mollie
137
+ const molliePayments = await MolliePayment.where({ paymentId: payment.id }, { limit: 1 });
138
+ if (molliePayments.length == 1) {
139
+ const molliePayment = molliePayments[0];
140
+ // check status
141
+ const token = await MollieToken.getTokenFor(organization.id);
142
+
143
+ if (token) {
144
+ try {
145
+ const mollieClient = createMollieClient({ accessToken: await token.getAccessToken() });
146
+ const mollieData = await mollieClient.payments.get(molliePayment.mollieId, {
147
+ testmode: organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production',
148
+ });
149
+
150
+ console.log(mollieData); // log to log files to check issues
151
+
152
+ const details = mollieData.details as any;
153
+ if (details?.consumerName) {
154
+ payment.ibanName = details.consumerName;
155
+ }
156
+ if (details?.consumerAccount) {
157
+ payment.iban = details.consumerAccount;
158
+ }
159
+ if (details?.cardHolder) {
160
+ payment.ibanName = details.cardHolder;
161
+ }
162
+ if (details?.cardNumber) {
163
+ payment.iban = 'xxxx xxxx xxxx ' + details.cardNumber;
164
+ }
165
+
166
+ if (mollieData.status === MolliePaymentStatus.paid) {
167
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
168
+ }
169
+ else if (mollieData.status === MolliePaymentStatus.failed || mollieData.status === MolliePaymentStatus.expired || mollieData.status === MolliePaymentStatus.canceled) {
170
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
171
+ }
172
+ else if (this.isManualExpired(payment.status, payment)) {
173
+ // Mollie still returning pending after 1 day: mark as failed
174
+ console.error('Manually marking Mollie payment as expired', payment.id);
175
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
176
+ }
177
+ }
178
+ catch (e) {
179
+ console.error('Payment check failed Mollie', payment.id, e);
180
+ if (this.isManualExpired(payment.status, payment)) {
181
+ console.error('Manually marking Mollie payment as expired', payment.id);
182
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
183
+ }
184
+ }
185
+ }
186
+ else {
187
+ console.warn('Mollie payment is missing for organization ' + organization.id + ' while checking payment status...');
188
+
189
+ if (this.isManualExpired(payment.status, payment)) {
190
+ console.error('Manually marking payment without mollie token as expired', payment.id);
191
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
192
+ }
193
+ }
194
+ }
195
+ else {
196
+ if (this.isManualExpired(payment.status, payment)) {
197
+ console.error('Manually marking payment without mollie payments as expired', payment.id);
198
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
199
+ }
200
+ }
201
+ }
202
+ else if (payment.provider == PaymentProvider.Buckaroo) {
203
+ const helper = new BuckarooHelper(organization.privateMeta.buckarooSettings?.key ?? '', organization.privateMeta.buckarooSettings?.secret ?? '', organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production');
204
+ try {
205
+ let status = await helper.getStatus(payment);
206
+
207
+ if (this.isManualExpired(status, payment)) {
208
+ console.error('Manually marking Buckaroo payment as expired', payment.id);
209
+ status = PaymentStatus.Failed;
210
+ }
211
+
212
+ await this.handlePaymentStatusUpdate(payment, organization, status);
213
+ }
214
+ catch (e) {
215
+ console.error('Payment check failed Buckaroo', payment.id, e);
216
+ if (this.isManualExpired(payment.status, payment)) {
217
+ console.error('Manually marking Buckaroo payment as expired', payment.id);
218
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
219
+ }
220
+ }
221
+ }
222
+ else if (payment.provider == PaymentProvider.Payconiq) {
223
+ // Check status
224
+
225
+ const payconiqPayments = await PayconiqPayment.where({ paymentId: payment.id }, { limit: 1 });
226
+ if (payconiqPayments.length == 1) {
227
+ const payconiqPayment = payconiqPayments[0];
228
+
229
+ if (cancel) {
230
+ console.error('Cancelling Payconiq payment on request', payment.id);
231
+ await payconiqPayment.cancel(organization);
232
+ }
233
+
234
+ let status = await payconiqPayment.getStatus(organization);
235
+
236
+ if (!cancel && this.shouldTryToCancel(status, payment)) {
237
+ console.error('Manually cancelling Payconiq payment', payment.id);
238
+ if (await payconiqPayment.cancel(organization)) {
239
+ status = PaymentStatus.Failed;
240
+ }
241
+ }
242
+
243
+ if (this.isManualExpired(status, payment)) {
244
+ console.error('Manually marking Payconiq payment as expired', payment.id);
245
+ status = PaymentStatus.Failed;
246
+ }
247
+
248
+ await this.handlePaymentStatusUpdate(payment, organization, status);
249
+ }
250
+ else {
251
+ console.warn('Payconiq payment is missing for organization ' + organization.id + ' while checking payment status...');
252
+
253
+ if (this.isManualExpired(payment.status, payment)) {
254
+ console.error('Manually marking Payconiq payment as expired because not found', payment.id);
255
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
256
+ }
257
+ }
258
+ }
259
+ else {
260
+ console.error('Invalid payment provider', payment.provider, 'for payment', payment.id);
261
+ if (this.isManualExpired(payment.status, payment)) {
262
+ console.error('Manually marking unknown payment as expired', payment.id);
263
+ await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
264
+ }
265
+ }
266
+ }
267
+ else {
268
+ // Do a manual update if needed
269
+ if (payment.status === PaymentStatus.Succeeded) {
270
+ if (payment.provider === PaymentProvider.Stripe) {
271
+ // Update the status
272
+ await StripeHelper.getStatus(payment, false, testMode);
273
+ }
274
+ }
275
+ }
276
+ return payment;
277
+ });
278
+ },
279
+
280
+ isManualExpired(status: PaymentStatus, payment: Payment) {
281
+ if ((status == PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method !== PaymentMethod.DirectDebit) {
282
+ // If payment is not succeeded after one day, mark as failed
283
+ if (payment.createdAt < new Date(new Date().getTime() - 60 * 1000 * 60 * 24)) {
284
+ return true;
285
+ }
286
+ }
287
+ return false;
288
+ },
289
+
290
+ /**
291
+ * Try to cancel a payment that is still pending
292
+ */
293
+ shouldTryToCancel(status: PaymentStatus, payment: Payment): boolean {
294
+ if ((status == PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method !== PaymentMethod.DirectDebit) {
295
+ let timeout = STAMHOOFD.environment === 'development' ? 60 * 1000 * 2 : 60 * 1000 * 30;
296
+
297
+ // If payconiq and not yet 'identified' (scanned), cancel after 5 minutes
298
+ if (payment.provider === PaymentProvider.Payconiq && status === PaymentStatus.Created) {
299
+ timeout = STAMHOOFD.environment === 'development' ? 60 * 1000 * 1 : 60 * 1000 * 5;
300
+ }
301
+
302
+ if (payment.createdAt < new Date(new Date().getTime() - timeout)) {
303
+ return true;
304
+ }
305
+ }
306
+ return false;
307
+ },
308
+ };
@@ -0,0 +1,78 @@
1
+ import { ManyToOneRelation } from '@simonbackx/simple-database';
2
+ import { Document, Group, Member, Registration } from '@stamhoofd/models';
3
+ import { AuditLogType, EmailTemplateType } from '@stamhoofd/structures';
4
+ import { GroupService } from './GroupService';
5
+ import { AuditLogService } from './AuditLogService';
6
+
7
+ export const RegistrationService = {
8
+ async markValid(registrationId: string) {
9
+ const registration = await Registration.getByID(registrationId);
10
+ if (!registration) {
11
+ throw new Error('Registration not found');
12
+ }
13
+
14
+ if (registration.registeredAt !== null && registration.deactivatedAt === null) {
15
+ return false;
16
+ }
17
+
18
+ registration.reservedUntil = null;
19
+ registration.registeredAt = registration.registeredAt ?? new Date();
20
+ registration.deactivatedAt = null;
21
+ registration.canRegister = false;
22
+ await registration.save();
23
+ registration.scheduleStockUpdate();
24
+
25
+ await Member.updateMembershipsForId(registration.memberId);
26
+
27
+ await registration.sendEmailTemplate({
28
+ type: EmailTemplateType.RegistrationConfirmation,
29
+ });
30
+
31
+ const member = await Member.getByID(registration.memberId);
32
+ if (member) {
33
+ const registrationMemberRelation = new ManyToOneRelation(Member, 'member');
34
+ registrationMemberRelation.foreignKey = Member.registrations.foreignKey;
35
+ await Document.updateForRegistration(registration.setRelation(registrationMemberRelation, member));
36
+ }
37
+
38
+ // Update group occupancy
39
+ const group = await GroupService.updateOccupancy(registration.groupId);
40
+
41
+ // Create a log
42
+ if (member && group) {
43
+ await AuditLogService.log({
44
+ type: AuditLogType.MemberRegistered,
45
+ member,
46
+ group,
47
+ registration,
48
+ });
49
+ }
50
+
51
+ return true;
52
+ },
53
+
54
+ async deactivate(registration: Registration, group?: Group, member?: Member) {
55
+ if (registration.deactivatedAt !== null) {
56
+ return;
57
+ }
58
+
59
+ // Clear the registration
60
+ registration.deactivatedAt = new Date();
61
+ await registration.save();
62
+ registration.scheduleStockUpdate();
63
+
64
+ await Member.updateMembershipsForId(registration.memberId);
65
+
66
+ const fetchedMember = member ?? await Member.getByID(registration.memberId);
67
+ const fetchedGroup = group ?? await Group.getByID(registration.groupId);
68
+
69
+ if (fetchedMember && fetchedGroup) {
70
+ await AuditLogService.log({
71
+ type: AuditLogType.MemberUnregistered,
72
+ member: fetchedMember,
73
+ group: fetchedGroup,
74
+ registration,
75
+ });
76
+ }
77
+ },
78
+ };