@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.
- package/package.json +17 -17
- package/src/boot.ts +31 -16
- package/src/crons/settlement-sync.test.ts +59 -1
- package/src/crons/settlement-sync.ts +20 -9
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
- package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
- package/src/helpers/MollieSettlementSync.test.ts +43 -0
- package/src/helpers/MollieSettlementSync.ts +29 -4
- package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
- package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
- package/src/helpers/SettlementExporter.test.ts +22 -0
- package/src/helpers/SettlementExporter.ts +13 -3
- package/src/helpers/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +375 -1
- package/src/helpers/StripeSettlementSync.ts +264 -113
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +47 -11
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.test.ts +82 -9
- package/src/services/SettlementService.ts +72 -6
- package/src/services/WebshopCrowdfundingService.test.ts +433 -0
- package/src/services/WebshopCrowdfundingService.ts +98 -0
- package/tests/filters/orders.test.ts +24 -1
- 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.
|
|
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.
|
|
69
|
-
"@stamhoofd/backend-i18n": "2.
|
|
70
|
-
"@stamhoofd/backend-middleware": "2.
|
|
71
|
-
"@stamhoofd/crons": "2.
|
|
72
|
-
"@stamhoofd/email": "2.
|
|
73
|
-
"@stamhoofd/excel-writer": "2.
|
|
74
|
-
"@stamhoofd/logging": "2.
|
|
75
|
-
"@stamhoofd/models": "2.
|
|
76
|
-
"@stamhoofd/object-differ": "2.
|
|
77
|
-
"@stamhoofd/queues": "2.
|
|
78
|
-
"@stamhoofd/sql": "2.
|
|
79
|
-
"@stamhoofd/structures": "2.
|
|
80
|
-
"@stamhoofd/types": "2.
|
|
81
|
-
"@stamhoofd/utility": "2.
|
|
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.
|
|
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": "
|
|
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 =
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts
CHANGED
|
@@ -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
|
|
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
|
});
|