@stamhoofd/backend 2.141.0 → 2.143.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 (37) hide show
  1. package/package.json +17 -17
  2. package/src/boot.ts +31 -16
  3. package/src/crons/settlement-sync.test.ts +59 -1
  4. package/src/crons/settlement-sync.ts +20 -9
  5. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  6. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  7. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  8. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  9. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
  10. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
  11. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
  12. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  13. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
  14. package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
  15. package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
  16. package/src/helpers/MollieSettlementSync.test.ts +43 -0
  17. package/src/helpers/MollieSettlementSync.ts +29 -4
  18. package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
  19. package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
  20. package/src/helpers/SettlementExporter.test.ts +22 -0
  21. package/src/helpers/SettlementExporter.ts +13 -3
  22. package/src/helpers/SettlementSyncRunner.test.ts +53 -0
  23. package/src/helpers/SettlementSyncRunner.ts +20 -3
  24. package/src/helpers/StripeSettlementSync.test.ts +375 -1
  25. package/src/helpers/StripeSettlementSync.ts +264 -113
  26. package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
  27. package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
  28. package/src/helpers/waitUntilDeadline.test.ts +48 -0
  29. package/src/helpers/waitUntilDeadline.ts +28 -0
  30. package/src/services/ApplicationFeeService.ts +47 -11
  31. package/src/services/BalanceItemService.ts +5 -0
  32. package/src/services/SettlementService.test.ts +82 -9
  33. package/src/services/SettlementService.ts +72 -6
  34. package/src/services/WebshopCrowdfundingService.test.ts +433 -0
  35. package/src/services/WebshopCrowdfundingService.ts +98 -0
  36. package/tests/filters/orders.test.ts +24 -1
  37. package/tests/vitest.setup.ts +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.141.0",
3
+ "version": "2.143.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.143.0",
69
+ "@stamhoofd/backend-i18n": "2.143.0",
70
+ "@stamhoofd/backend-middleware": "2.143.0",
71
+ "@stamhoofd/crons": "2.143.0",
72
+ "@stamhoofd/email": "2.143.0",
73
+ "@stamhoofd/excel-writer": "2.143.0",
74
+ "@stamhoofd/logging": "2.143.0",
75
+ "@stamhoofd/models": "2.143.0",
76
+ "@stamhoofd/object-differ": "2.143.0",
77
+ "@stamhoofd/queues": "2.143.0",
78
+ "@stamhoofd/sql": "2.143.0",
79
+ "@stamhoofd/structures": "2.143.0",
80
+ "@stamhoofd/types": "2.143.0",
81
+ "@stamhoofd/utility": "2.143.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.143.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": "8959c4f720d332be9769e033d40bb25f9e6aa322"
121
121
  }
package/src/boot.ts CHANGED
@@ -16,6 +16,7 @@ import { Platform } from '@stamhoofd/models';
16
16
  import { QueueHandler } from '@stamhoofd/queues';
17
17
  import { resumeEmails } from './helpers/EmailResumer.js';
18
18
  import { GlobalHelper } from './helpers/GlobalHelper.js';
19
+ import { waitUntilDeadline } from './helpers/waitUntilDeadline.js';
19
20
  import { SetupStepUpdater } from './helpers/SetupStepUpdater.js';
20
21
  import { ContextMiddleware } from './middleware/ContextMiddleware.js';
21
22
  import { TenantScopeMiddleware } from './middleware/TenantScopeMiddleware.js';
@@ -29,6 +30,7 @@ import { UitpasService } from './services/uitpas/UitpasService.js';
29
30
  import { UniqueMemberNumberService } from './services/UniqueMemberNumberService.js';
30
31
  import { UniqueUserService } from './services/UniqueUserService.js';
31
32
  import { QueryableModel, SQLLogger } from '@stamhoofd/sql';
33
+ import { WebshopCrowdfundingService } from './services/WebshopCrowdfundingService.js';
32
34
 
33
35
  process.on('unhandledRejection', (error: Error) => {
34
36
  console.error('unhandledRejection');
@@ -51,6 +53,13 @@ if (new Date().getTimezoneOffset() !== 0) {
51
53
  throw new Error('Process should always run in UTC timezone');
52
54
  }
53
55
 
56
+ /**
57
+ * How long a shutdown waits for the running crons, queue jobs and emails together. Aborting them is
58
+ * a request: a job that never checks it, or that hangs on a network call, may not keep the restart
59
+ * waiting. Everything left behind runs again after the restart.
60
+ */
61
+ const MAXIMUM_SHUTDOWN_WAIT = 29 * 1000;
62
+
54
63
  const seeds = async (options: { shutdown: () => Promise<void> }) => {
55
64
  if (await checkReadOnly()) {
56
65
  console.error(new StyledText(`[SEEDS] `).addClass('migration', 'tag'), new StyledText('MySQL is in read-only mode: Seeds are disabled.').addClass('error'));
@@ -210,13 +219,15 @@ export const boot = async (options: { killProcess: boolean }) => {
210
219
  if (shuttingDown) {
211
220
  return;
212
221
  }
222
+ const deadline = new Date(Date.now() + MAXIMUM_SHUTDOWN_WAIT);
223
+
213
224
  shuttingDown = true;
214
225
  QueryableModel.shutdownMigrations = true;
215
226
  productionLog('Shutting down...');
216
227
  // Disable keep alive
217
228
  routerServer.defaultHeaders = Object.assign(routerServer.defaultHeaders, { Connection: 'close' });
218
229
  if (routerServer.server) {
219
- routerServer.server.headersTimeout = 5000;
230
+ routerServer.server.headersTimeout = 1000;
220
231
  routerServer.server.keepAliveTimeout = 1;
221
232
  }
222
233
 
@@ -236,27 +247,31 @@ export const boot = async (options: { killProcess: boolean }) => {
236
247
  console.error('Failed to stop HTTP server:');
237
248
  console.error(err);
238
249
  }
250
+ const shutdownError = new SimpleError({
251
+ code: 'SHUTDOWN',
252
+ message: 'Shutting down',
253
+ statusCode: 503,
254
+ });
255
+ QueueHandler.abortAll(shutdownError);
239
256
 
240
- await BalanceItemService.flushAll();
241
- await waitForCrons();
242
- QueueHandler.abortAll(
243
- new SimpleError({
244
- code: 'SHUTDOWN',
245
- message: 'Shutting down',
246
- statusCode: 503,
247
- }),
248
- );
249
- await QueueHandler.awaitAll();
257
+ await Promise.allSettled([
258
+ BalanceItemService.flushAll(),
259
+ WebshopCrowdfundingService.flush(),
260
+ waitUntilDeadline(waitForCrons(), { deadline, description: 'crons' }),
261
+ waitUntilDeadline(QueueHandler.awaitAll(), { deadline, description: 'queues' }),
262
+ ]);
250
263
 
251
- try {
264
+ // Any last queues started in above jobs
265
+ QueueHandler.abortAll(shutdownError);
266
+ await waitUntilDeadline(QueueHandler.awaitAll(), { deadline, description: 'queues' });
267
+
268
+ // Wait for emails
269
+ await waitUntilDeadline((async () => {
252
270
  while (Email.currentQueue.length > 0) {
253
271
  console.log(`${Email.currentQueue.length} emails still in queue. Waiting 500ms...`);
254
272
  await sleep(500);
255
273
  }
256
- } catch (err) {
257
- console.error('Failed to wait for emails to finish:');
258
- console.error(err);
259
- }
274
+ })(), { deadline, description: 'emails' });
260
275
 
261
276
  try {
262
277
  await Database.end();
@@ -1,11 +1,15 @@
1
1
  import { EmailMocker } from '@stamhoofd/email';
2
2
  import type { Organization } from '@stamhoofd/models';
3
3
  import { OrganizationFactory } from '@stamhoofd/models';
4
+ import { QueueHandler } from '@stamhoofd/queues';
4
5
  import { PaymentProvider } from '@stamhoofd/structures';
6
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
5
7
  import { v4 as uuidv4 } from 'uuid';
8
+ import { vi } from 'vitest';
6
9
 
10
+ import { SettlementSyncRunner } from '../helpers/SettlementSyncRunner.js';
7
11
  import { SettlementService } from '../services/SettlementService.js';
8
- import { reportProblemSettlements } from './settlement-sync.js';
12
+ import { reportProblemSettlements, syncSettlements } from './settlement-sync.js';
9
13
 
10
14
  describe('Cron.settlement-sync', () => {
11
15
  let organization: Organization;
@@ -36,4 +40,58 @@ describe('Cron.settlement-sync', () => {
36
40
  const emails = await EmailMocker.transactional.getSucceededEmails();
37
41
  expect(emails.find(e => e.subject.startsWith('Uitbetalingen met problemen'))).toBeDefined();
38
42
  });
43
+
44
+ describe('The nightly run', () => {
45
+ /**
46
+ * The run only starts between 5 and 6 AM Brussels time, and remembers the day it ran: every
47
+ * test needs its own day.
48
+ */
49
+ const arriveAt5AM = (dayOfMarch: number) => {
50
+ vi.setSystemTime(new Date(Date.UTC(2026, 2, dayOfMarch, 4, 30)));
51
+ };
52
+
53
+ beforeEach(() => {
54
+ TestUtils.setEnvironment('environment', 'production');
55
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] });
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.useRealTimers();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ test('runs on the same queue as the manual sync, and only once a day', async () => {
64
+ arriveAt5AM(10);
65
+
66
+ const runs: string[][] = [];
67
+ vi.spyOn(SettlementSyncRunner.prototype, 'run').mockImplementation(async () => {
68
+ // Nested in the queue of the manual sync, so the two never walk at once
69
+ runs.push(QueueHandler.asyncLocalStorage.getStore() ?? []);
70
+ return { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
71
+ });
72
+
73
+ await syncSettlements();
74
+ await syncSettlements();
75
+
76
+ expect(runs).toEqual([['settlement-sync']]);
77
+ });
78
+
79
+ test('an interrupted run is not remembered as the sync of this day', async () => {
80
+ arriveAt5AM(11);
81
+
82
+ const spy = vi.spyOn(SettlementSyncRunner.prototype, 'run').mockImplementation(async ({ abort } = {}) => {
83
+ abort?.abort();
84
+ abort?.throwIfAborted();
85
+ return { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
86
+ });
87
+
88
+ await expect(syncSettlements()).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
89
+
90
+ // A restart may not skip the sync of this day: the next tick starts over
91
+ spy.mockResolvedValue({ feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 });
92
+ await syncSettlements();
93
+
94
+ expect(spy).toHaveBeenCalledTimes(2);
95
+ });
96
+ });
39
97
  });
@@ -3,6 +3,7 @@
3
3
  import { registerCron } from '@stamhoofd/crons';
4
4
  import { Email } from '@stamhoofd/email';
5
5
  import { Settlement } from '@stamhoofd/models/models/Settlement.js';
6
+ import { QueueHandler } from '@stamhoofd/queues';
6
7
  import { PaymentProvider } from '@stamhoofd/structures';
7
8
  import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
8
9
  import { Formatter } from '@stamhoofd/utility';
@@ -25,7 +26,10 @@ const MAXIMUM_PENDING_FEES_AGE_MS = 31 * 24 * 60 * 60 * 1000;
25
26
 
26
27
  let lastSettlementSync: Date | null = null;
27
28
 
28
- async function syncSettlements() {
29
+ /**
30
+ * Exported for tests only.
31
+ */
32
+ export async function syncSettlements() {
29
33
  if (STAMHOOFD.environment !== 'production') {
30
34
  return;
31
35
  }
@@ -46,15 +50,22 @@ async function syncSettlements() {
46
50
 
47
51
  const start = new Date(today.getTime() - SYNC_WINDOW_DAYS * 24 * 60 * 60 * 1000);
48
52
 
49
- const runner = new SettlementSyncRunner();
50
- await runner.run({
51
- start,
52
- providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
53
- stripe: { retryUnsynced: true },
53
+ // The same queue as the manual backfill: the two never walk the same period at once, and a
54
+ // shutdown aborts this run instead of waiting for the whole window. An aborted run throws, so
55
+ // it is not remembered as the sync of this day: a later tick within the hour runs it again,
56
+ // and otherwise the next day re-walks the same window anyway
57
+ await QueueHandler.schedule('settlement-sync', async ({ abort }) => {
58
+ const runner = new SettlementSyncRunner();
59
+ await runner.run({
60
+ start,
61
+ providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
62
+ stripe: { retryUnsynced: true },
63
+ abort,
64
+ });
65
+
66
+ await reportProblemSettlements();
54
67
  });
55
68
 
56
- await reportProblemSettlements();
57
-
58
69
  lastSettlementSync = new Date();
59
70
  }
60
71
 
@@ -96,7 +107,7 @@ export async function reportProblemSettlements() {
96
107
  const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
97
108
 
98
109
  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)';
110
+ 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
111
  };
101
112
 
102
113
  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('%Zks', { 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
  });