@stamhoofd/backend 2.141.0 → 2.142.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.141.0",
3
+ "version": "2.142.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -65,20 +65,20 @@
65
65
  "@simonbackx/simple-errors": "1.5.0",
66
66
  "@simonbackx/simple-logging": "1.0.1",
67
67
  "@simplewebauthn/server": "13.3.2",
68
- "@stamhoofd/backend-env": "2.141.0",
69
- "@stamhoofd/backend-i18n": "2.141.0",
70
- "@stamhoofd/backend-middleware": "2.141.0",
71
- "@stamhoofd/crons": "2.141.0",
72
- "@stamhoofd/email": "2.141.0",
73
- "@stamhoofd/excel-writer": "2.141.0",
74
- "@stamhoofd/logging": "2.141.0",
75
- "@stamhoofd/models": "2.141.0",
76
- "@stamhoofd/object-differ": "2.141.0",
77
- "@stamhoofd/queues": "2.141.0",
78
- "@stamhoofd/sql": "2.141.0",
79
- "@stamhoofd/structures": "2.141.0",
80
- "@stamhoofd/types": "2.141.0",
81
- "@stamhoofd/utility": "2.141.0",
68
+ "@stamhoofd/backend-env": "2.142.0",
69
+ "@stamhoofd/backend-i18n": "2.142.0",
70
+ "@stamhoofd/backend-middleware": "2.142.0",
71
+ "@stamhoofd/crons": "2.142.0",
72
+ "@stamhoofd/email": "2.142.0",
73
+ "@stamhoofd/excel-writer": "2.142.0",
74
+ "@stamhoofd/logging": "2.142.0",
75
+ "@stamhoofd/models": "2.142.0",
76
+ "@stamhoofd/object-differ": "2.142.0",
77
+ "@stamhoofd/queues": "2.142.0",
78
+ "@stamhoofd/sql": "2.142.0",
79
+ "@stamhoofd/structures": "2.142.0",
80
+ "@stamhoofd/types": "2.142.0",
81
+ "@stamhoofd/utility": "2.142.0",
82
82
  "archiver": "7.0.1",
83
83
  "argon2": "0.44.0",
84
84
  "axios": "1.18.1",
@@ -102,7 +102,7 @@
102
102
  "uuid": "14.0.1"
103
103
  },
104
104
  "devDependencies": {
105
- "@stamhoofd/test-utils": "2.141.0",
105
+ "@stamhoofd/test-utils": "2.142.0",
106
106
  "@types/cookie": "0.6.0",
107
107
  "@types/luxon": "3.7.2",
108
108
  "@types/mailparser": "3.4.6",
@@ -117,5 +117,5 @@
117
117
  "publishConfig": {
118
118
  "access": "public"
119
119
  },
120
- "gitHead": "cf38053fe1507022767148347f436100a7ef07ed"
120
+ "gitHead": "2903821fe8196e3bb194da193aaf1cda2c3bf076"
121
121
  }
@@ -96,7 +96,7 @@ export async function reportProblemSettlements() {
96
96
  const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
97
97
 
98
98
  const describe = (settlement: Settlement) => {
99
- return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
99
+ return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + Formatter.price(settlement.uncollectibleFees) + ' niet-aanrekenbare kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
100
100
  };
101
101
 
102
102
  Email.sendWebmaster({
@@ -0,0 +1,165 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { BalanceItemFactory, Invoice, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
5
+ import { PatchableArray } from '@simonbackx/simple-encoding';
6
+ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
7
+ import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
8
+ import { PaymentMethod, PaymentStatus } from '@stamhoofd/structures';
9
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
10
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
11
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
14
+ import { initPlatformAdmin } from '../../../../tests/init/index.js';
15
+ import { initMembershipOrganization } from '../../../../tests/init/initMembershipOrganization.js';
16
+ import { PatchOrganizationsEndpoint } from './PatchOrganizationsEndpoint.js';
17
+
18
+ const baseUrl = `/admin/organizations`;
19
+ const endpoint = new PatchOrganizationsEndpoint();
20
+
21
+ describe('Endpoint.PatchOrganizationsEndpoint', () => {
22
+ let membershipOrganization: Organization;
23
+
24
+ beforeEach(async () => {
25
+ TestUtils.setEnvironment('userMode', 'organization');
26
+ });
27
+
28
+ beforeAll(async () => {
29
+ membershipOrganization = await initMembershipOrganization();
30
+ });
31
+
32
+ const deleteOrganization = async (organization: Organization) => {
33
+ const { adminToken } = await initPlatformAdmin();
34
+
35
+ const patch: PatchableArrayAutoEncoder<OrganizationStruct> = new PatchableArray();
36
+ patch.addDelete(organization.id);
37
+
38
+ // Platform admins are not scoped to an organization, so no host is required
39
+ const request = Request.patch({
40
+ path: baseUrl,
41
+ host: '',
42
+ body: patch,
43
+ headers: {
44
+ authorization: 'Bearer ' + adminToken.accessToken,
45
+ },
46
+ });
47
+
48
+ return await testServer.test(endpoint, request);
49
+ };
50
+
51
+ const createPayment = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
52
+ const payment = new Payment();
53
+ payment.organizationId = membershipOrganization.id;
54
+ payment.payingOrganizationId = payingOrganizationId;
55
+ payment.method = PaymentMethod.Transfer;
56
+ payment.status = PaymentStatus.Succeeded;
57
+ payment.price = 10_00;
58
+ await payment.save();
59
+ return payment;
60
+ };
61
+
62
+ const createApplicationFee = async ({ payingOrganizationId }: { payingOrganizationId: string }) => {
63
+ const charge = new SettlementCharge();
64
+ charge.type = SettlementChargeType.ApplicationFeeService;
65
+ charge.externalId = uuidv4();
66
+ charge.amount = -30_00;
67
+ charge.organizationId = payingOrganizationId;
68
+ charge.occurredAt = new Date();
69
+ await charge.save();
70
+
71
+ const fee = new ApplicationFee();
72
+ fee.externalId = uuidv4();
73
+ fee.type = ApplicationFeeType.Service;
74
+ fee.amount = 30_00;
75
+ fee.organizationId = membershipOrganization.id;
76
+ fee.payingOrganizationId = payingOrganizationId;
77
+ fee.settlementChargeId = charge.id;
78
+ fee.occurredAt = new Date();
79
+ await fee.save();
80
+ return fee;
81
+ };
82
+
83
+ const createInvoice = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
84
+ const invoice = new Invoice();
85
+ invoice.organizationId = membershipOrganization.id;
86
+ invoice.payingOrganizationId = payingOrganizationId;
87
+ await invoice.save();
88
+ return invoice;
89
+ };
90
+
91
+ test('An organization without financial records can be deleted', async () => {
92
+ const organization = await new OrganizationFactory({}).create();
93
+
94
+ const response = await deleteOrganization(organization);
95
+
96
+ expect(response.status).toBe(200);
97
+ expect(await Organization.getByID(organization.id)).toBeUndefined();
98
+ });
99
+
100
+ const payerRecords = [
101
+ {
102
+ name: 'a balance item it owes',
103
+ create: async (organization: Organization) => {
104
+ await new BalanceItemFactory({
105
+ organizationId: membershipOrganization.id,
106
+ payingOrganizationId: organization.id,
107
+ amount: 1,
108
+ unitPrice: 10_00,
109
+ }).create();
110
+ },
111
+ },
112
+ {
113
+ name: 'a payment it made',
114
+ create: async (organization: Organization) => {
115
+ await createPayment({ payingOrganizationId: organization.id });
116
+ },
117
+ },
118
+ {
119
+ name: 'an application fee it was charged',
120
+ create: async (organization: Organization) => {
121
+ await createApplicationFee({ payingOrganizationId: organization.id });
122
+ },
123
+ },
124
+ {
125
+ name: 'an invoice it has to pay',
126
+ create: async (organization: Organization) => {
127
+ await createInvoice({ payingOrganizationId: organization.id });
128
+ },
129
+ },
130
+ ];
131
+
132
+ test.each(payerRecords)('An organization with $name cannot be deleted', async ({ create }) => {
133
+ const organization = await new OrganizationFactory({}).create();
134
+ await create(organization);
135
+
136
+ await expect(deleteOrganization(organization)).rejects.toThrow(STExpect.errorWithCode('organization_has_financial_records'));
137
+ expect(await Organization.getByID(organization.id)).toBeDefined();
138
+ });
139
+
140
+ test('Financial records the organization received do not block the delete', async () => {
141
+ const organization = await new OrganizationFactory({}).create();
142
+
143
+ await new BalanceItemFactory({
144
+ organizationId: organization.id,
145
+ amount: 1,
146
+ unitPrice: 10_00,
147
+ }).create();
148
+
149
+ const payment = new Payment();
150
+ payment.organizationId = organization.id;
151
+ payment.method = PaymentMethod.Transfer;
152
+ payment.status = PaymentStatus.Succeeded;
153
+ payment.price = 10_00;
154
+ await payment.save();
155
+
156
+ const invoice = new Invoice();
157
+ invoice.organizationId = organization.id;
158
+ await invoice.save();
159
+
160
+ const response = await deleteOrganization(organization);
161
+
162
+ expect(response.status).toBe(200);
163
+ expect(await Organization.getByID(organization.id)).toBeUndefined();
164
+ });
165
+ });
@@ -3,7 +3,8 @@ import { PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encodin
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
6
+ import { BalanceItem, Invoice, Organization, OrganizationRegistrationPeriod, Payment, Platform, RegistrationPeriod } from '@stamhoofd/models';
7
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
7
8
  import { Organization as OrganizationStruct } from '@stamhoofd/structures';
8
9
 
9
10
  import { Formatter } from '@stamhoofd/utility';
@@ -61,6 +62,22 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
61
62
  });
62
63
  }
63
64
 
65
+ // Financial records may not disappear together with the organization that had to pay them
66
+ const payerRecordCounts = await Promise.all([
67
+ BalanceItem.select().where('payingOrganizationId', id).count(),
68
+ Payment.select().where('payingOrganizationId', id).count(),
69
+ ApplicationFee.select().where('payingOrganizationId', id).count(),
70
+ Invoice.select().where('payingOrganizationId', id).count(),
71
+ ]);
72
+
73
+ if (payerRecordCounts.some(count => count > 0)) {
74
+ throw new SimpleError({
75
+ code: 'organization_has_financial_records',
76
+ message: 'Organization is still referenced as paying organization',
77
+ human: $t('Er zijn nog openstaande bedragen, betalingen, transactiekosten of facturen waarvoor {organization} moet betalen of betaald heeft. Die gegevens mogen niet verloren gaan.', { organization: organization.name }),
78
+ });
79
+ }
80
+
64
81
  await organization.delete();
65
82
  }
66
83
 
@@ -2,17 +2,18 @@ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
2
  import { PatchableArray } from '@simonbackx/simple-encoding';
3
3
  import { Request } from '@simonbackx/simple-endpoints';
4
4
  import type { Organization, User } from '@stamhoofd/models';
5
- import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
5
+ import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitation, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
6
6
  import type { RegistrationInvitation as RegistrationInvitationStruct } from '@stamhoofd/structures';
7
- import { PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
7
+ import { GroupType, PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
8
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
8
9
  import { testServer } from '../../../../tests/helpers/TestServer.js';
9
10
  import { PatchRegistrationInvitationsEndpoint } from './PatchRegistrationInvitationsEndpoint.js';
10
11
 
11
12
  describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
12
13
  const endpoint = new PatchRegistrationInvitationsEndpoint();
13
14
 
14
- const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization; user: User | null }) => {
15
- const request = Request.buildJson('PATCH', '/registration-invitations', organization.getApiHost(), patch);
15
+ const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization | null; user: User | null }) => {
16
+ const request = Request.buildJson('PATCH', '/registration-invitations', organization?.getApiHost(), patch);
16
17
  if (user) {
17
18
  const token = await Token.createToken(user);
18
19
  request.headers.authorization = 'Bearer ' + token.accessToken;
@@ -401,4 +402,153 @@ describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
401
402
  })).rejects.toThrow('Je hebt geen toegangsrechten om deze uitnodiging te verwijderen.');
402
403
  });
403
404
  });
405
+
406
+ describe('Without organization scope (platform admins)', () => {
407
+ beforeEach(() => {
408
+ TestUtils.setEnvironment('userMode', 'platform');
409
+ });
410
+
411
+ test('A platform admin can invite for an event group', async () => {
412
+ const organization = await new OrganizationFactory({}).create();
413
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
414
+
415
+ const admin = await new UserFactory({
416
+ globalPermissions: Permissions.create({
417
+ level: PermissionLevel.Full,
418
+ }),
419
+ }).create();
420
+
421
+ const member = await new MemberFactory({
422
+ organization,
423
+ firstName: 'John',
424
+ lastName: 'Doe',
425
+ birthDay: { year: 1994, month: 6, day: 24 },
426
+ }).create();
427
+
428
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
429
+
430
+ patch.addPut(RegistrationInvitationRequest.create({
431
+ groupId: group.id,
432
+ memberId: member.id,
433
+ }));
434
+
435
+ const response = await patchInvitations({
436
+ patch,
437
+ organization: null,
438
+ user: admin,
439
+ });
440
+
441
+ expect(response.body).toHaveLength(1);
442
+ expect(response.body[0].group.id).toBe(group.id);
443
+ expect(response.body[0].member.id).toBe(member.id);
444
+
445
+ // The invitation belongs to the organization of the group
446
+ const invitation = await RegistrationInvitation.getByID(response.body[0].id);
447
+ expect(invitation?.organizationId).toBe(organization.id);
448
+ });
449
+
450
+ test('Should fail for a user without platform access', async () => {
451
+ const organization = await new OrganizationFactory({}).create();
452
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
453
+
454
+ const user = await new UserFactory({
455
+ organization,
456
+ permissions: Permissions.create({
457
+ level: PermissionLevel.Full,
458
+ }),
459
+ }).create();
460
+
461
+ const member = await new MemberFactory({
462
+ organization,
463
+ firstName: 'John',
464
+ lastName: 'Doe',
465
+ birthDay: { year: 1994, month: 6, day: 24 },
466
+ }).create();
467
+
468
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
469
+
470
+ patch.addPut(RegistrationInvitationRequest.create({
471
+ groupId: group.id,
472
+ memberId: member.id,
473
+ }));
474
+
475
+ await expect(patchInvitations({
476
+ patch,
477
+ organization: null,
478
+ user,
479
+ })).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
480
+ });
481
+
482
+ test('Should fail for a platform admin without access to the group', async () => {
483
+ const organization = await new OrganizationFactory({}).create();
484
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
485
+
486
+ // Some platform access (passes the fast throw), but no rights on this group or its organization
487
+ const admin = await new UserFactory({
488
+ globalPermissions: Permissions.create({
489
+ level: PermissionLevel.None,
490
+ resources: new Map([
491
+ [PermissionsResourceType.OrganizationTags, new Map([
492
+ ['unrelated-tag-id', ResourcePermissions.create({
493
+ level: PermissionLevel.Read,
494
+ })],
495
+ ])],
496
+ ]),
497
+ }),
498
+ }).create();
499
+
500
+ const member = await new MemberFactory({
501
+ organization,
502
+ firstName: 'John',
503
+ lastName: 'Doe',
504
+ birthDay: { year: 1994, month: 6, day: 24 },
505
+ }).create();
506
+
507
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
508
+
509
+ patch.addPut(RegistrationInvitationRequest.create({
510
+ groupId: group.id,
511
+ memberId: member.id,
512
+ }));
513
+
514
+ await expect(patchInvitations({
515
+ patch,
516
+ organization: null,
517
+ user: admin,
518
+ })).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
519
+ });
520
+
521
+ test('A platform admin can delete an invitation', async () => {
522
+ const organization = await new OrganizationFactory({}).create();
523
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
524
+
525
+ const admin = await new UserFactory({
526
+ globalPermissions: Permissions.create({
527
+ level: PermissionLevel.Full,
528
+ }),
529
+ }).create();
530
+
531
+ const member = await new MemberFactory({
532
+ organization,
533
+ firstName: 'John',
534
+ lastName: 'Doe',
535
+ birthDay: { year: 1994, month: 6, day: 24 },
536
+ }).create();
537
+
538
+ const invitation = await new RegistrationInvitationFactory({ member, group, organization }).create();
539
+
540
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
541
+
542
+ patch.addDelete(invitation.id);
543
+
544
+ const response = await patchInvitations({
545
+ patch,
546
+ organization: null,
547
+ user: admin,
548
+ });
549
+
550
+ expect(response.body).toHaveLength(0);
551
+ expect(await RegistrationInvitation.getByID(invitation.id)).toBeUndefined();
552
+ });
553
+ });
404
554
  });
@@ -35,11 +35,15 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
35
35
  }
36
36
 
37
37
  async handle(request: DecodedRequest<Params, Query, Body>) {
38
- const organization = await Context.setOrganizationScope();
38
+ const organization = await Context.setOptionalOrganizationScope();
39
39
  await Context.authenticate();
40
40
 
41
41
  // Fast throw first (more in depth checking for patches later)
42
- if (!await Context.auth.hasSomeAccess(organization.id)) {
42
+ if (organization) {
43
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
44
+ throw Context.auth.error();
45
+ }
46
+ } else if (!Context.auth.hasSomePlatformAccess()) {
43
47
  throw Context.auth.error();
44
48
  }
45
49
 
@@ -48,11 +52,11 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
48
52
 
49
53
  const puts = request.body.getPuts();
50
54
  for (const { put } of puts) {
51
- await this.checkCanCreateRegistrationInvitation(put, organization.id);
55
+ const group = await this.checkCanCreateRegistrationInvitation(put, organization?.id ?? null);
52
56
 
53
57
  const invitation = new RegistrationInvitation();
54
58
  invitation.id = put.id;
55
- invitation.organizationId = organization.id;
59
+ invitation.organizationId = group.organizationId;
56
60
  invitation.groupId = put.groupId;
57
61
  invitation.memberId = put.memberId;
58
62
 
@@ -125,12 +129,13 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
125
129
  /**
126
130
  * Will throw if not allowed to invite.
127
131
  * @param invitation
128
- * @param organizationId id of organization to invite for, should match the organizationId in the invitation
132
+ * @param organizationId organization scope of the request; when set, the group must belong to it (platform admins can invite without a scope)
133
+ * @returns the group to invite for
129
134
  */
130
- private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string) {
135
+ private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string | null): Promise<Group> {
131
136
  const group = await Group.getByID(invitation.groupId);
132
137
 
133
- if (!group || group.organizationId !== organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
138
+ if (!group || (organizationId !== null && group.organizationId !== organizationId) || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
134
139
  throw Context.auth.error($t(`%1ST`));
135
140
  }
136
141
 
@@ -147,7 +152,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
147
152
 
148
153
  if (!member
149
154
  // in userMode 'organization' we can only invite members from the same organization
150
- || (STAMHOOFD.userMode === 'organization' && member.organizationId !== organizationId)
155
+ || (STAMHOOFD.userMode === 'organization' && member.organizationId !== group.organizationId)
151
156
  // read access is suficient
152
157
  || !await Context.auth.canAccessMember(member, PermissionLevel.Read)
153
158
  ) {
@@ -163,5 +168,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
163
168
  human: $t('%1S2'),
164
169
  });
165
170
  }
171
+
172
+ return group;
166
173
  }
167
174
  }
@@ -1,3 +1,4 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
1
2
  import type { StripeAccount } from '@stamhoofd/models';
2
3
  import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
3
4
  import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
@@ -15,6 +16,7 @@ import { initMembershipOrganization } from '../../tests/init/initMembershipOrgan
15
16
  import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
16
17
  import { SettlementService } from '../services/SettlementService.js';
17
18
  import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
19
+ import { WebmasterReport } from './WebmasterReport.js';
18
20
 
19
21
  describe('ApplicationFeeInvoicer', () => {
20
22
  const stripeMocker = new StripeMocker();
@@ -262,8 +264,18 @@ describe('ApplicationFeeInvoicer', () => {
262
264
  await createFee(organization, stripeAccount, { amount: 30_00 });
263
265
  await createFee(organization, broken, { amount: 40_00 });
264
266
 
265
- // Deleting the account clears payingStripeAccountId: that group can't be billed anymore
266
- await broken.delete();
267
+ // A month the legacy invoicer billed may never be billed again: that group throws
268
+ const legacy = new Payment();
269
+ legacy.organizationId = membershipOrganization.id;
270
+ legacy.payingOrganizationId = organization.id;
271
+ legacy.stripeAccountId = broken.id;
272
+ legacy.method = PaymentMethod.AccountDeductions;
273
+ legacy.provider = PaymentProvider.Stripe;
274
+ legacy.status = PaymentStatus.Succeeded;
275
+ legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
276
+ legacy.price = 40_00;
277
+ legacy.paidAt = occurredAt;
278
+ await legacy.save();
267
279
 
268
280
  await invoiceMonth();
269
281
 
@@ -272,14 +284,63 @@ describe('ApplicationFeeInvoicer', () => {
272
284
  expect(payments[0].price).toBe(30_00);
273
285
  });
274
286
 
287
+ test('fees without the Stripe account they were deducted from are skipped, not retried forever', async () => {
288
+ const { organization, stripeAccount } = await init();
289
+ const removed = await stripeMocker.createStripeAccount(organization.id);
290
+
291
+ await createFee(organization, stripeAccount, { amount: 30_00 });
292
+ const orphaned = await createFee(organization, removed, { amount: 40_00 });
293
+
294
+ // Deleting the account row clears payingStripeAccountId: that fee can no longer be checked
295
+ // against what the legacy invoicer billed per account, so it is not billed at all
296
+ await removed.delete();
297
+
298
+ await WebmasterReport.group('Overslaan applicatiekosten', async () => {
299
+ await invoiceMonth();
300
+ });
301
+
302
+ const payments = await getFeePayments(organization);
303
+ expect(payments).toHaveLength(1);
304
+ expect(payments[0].price).toBe(30_00);
305
+ expect((await ApplicationFee.getByID(orphaned.id))!.balanceItemId).toBeNull();
306
+
307
+ // Skipping is silent here: the sync that stored the fee already reported it, and this run
308
+ // repeats every night
309
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Overslaan applicatiekosten'));
310
+ expect(emails).toHaveLength(0);
311
+ });
312
+
275
313
  test('charges of a billed fee keep pointing at the deduction row', async () => {
276
314
  const { organization, stripeAccount } = await init();
277
315
  const fee = await createFee(organization, stripeAccount);
278
316
 
279
317
  await invoiceMonth();
280
318
 
281
- const charge = await SettlementCharge.getByID(fee.settlementChargeId);
319
+ const charge = await SettlementCharge.getByID(fee.settlementChargeId!);
282
320
  expect(charge).toBeDefined();
283
321
  expect(charge!.amount).toBe(-30_00);
284
322
  });
323
+
324
+ test('fees of a deleted organization are never billed, and do not block the other accounts', async () => {
325
+ const { organization, stripeAccount } = await init();
326
+ const { organization: other, stripeAccount: otherAccount } = await init();
327
+
328
+ const orphaned = await createFee(organization, stripeAccount, { amount: 30_00 });
329
+ await createFee(other, otherAccount, { amount: 40_00 });
330
+
331
+ // Takes the Stripe account and the deduction charge with it, but not our income
332
+ await organization.delete();
333
+
334
+ await invoiceMonth();
335
+
336
+ const payments = await getFeePayments(other);
337
+ expect(payments).toHaveLength(1);
338
+ expect(payments[0].price).toBe(40_00);
339
+
340
+ const stored = await ApplicationFee.getByID(orphaned.id);
341
+ expect(stored).toBeDefined();
342
+ expect(stored!.payingOrganizationId).toBeNull();
343
+ expect(stored!.settlementChargeId).toBeNull();
344
+ expect(stored!.balanceItemId).toBeNull();
345
+ });
285
346
  });