@stamhoofd/backend 2.142.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 +19 -8
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +1 -1
- 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/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/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +206 -1
- package/src/helpers/StripeSettlementSync.ts +106 -61
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +26 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +20 -2
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.ts +26 -3
- 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
|
|
|
@@ -74,7 +74,7 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
74
74
|
throw new SimpleError({
|
|
75
75
|
code: 'organization_has_financial_records',
|
|
76
76
|
message: 'Organization is still referenced as paying organization',
|
|
77
|
-
human: $t('
|
|
77
|
+
human: $t('%Zks', { organization: organization.name }),
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
3
|
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
3
4
|
import { OrganizationFactory, Payment, Token as TokenModel, UserFactory } from '@stamhoofd/models';
|
|
4
5
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import type { AbortSignal } from '@stamhoofd/queues';
|
|
5
7
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
8
|
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
7
9
|
import { STExpect } from '@stamhoofd/test-utils';
|
|
10
|
+
import { vi } from 'vitest';
|
|
8
11
|
|
|
9
12
|
import { StripeMocker } from '../../../../../tests/helpers/StripeMocker.js';
|
|
13
|
+
import { SettlementSyncRunner } from '../../../../helpers/SettlementSyncRunner.js';
|
|
10
14
|
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
11
15
|
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
12
16
|
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
@@ -88,6 +92,86 @@ describe('Endpoint.SettlementsSync', () => {
|
|
|
88
92
|
expect(statusResponse.body).toEqual([]);
|
|
89
93
|
});
|
|
90
94
|
|
|
95
|
+
const shutdown = () => {
|
|
96
|
+
QueueHandler.abortAll(new SimpleError({
|
|
97
|
+
code: 'SHUTDOWN',
|
|
98
|
+
message: 'Shutting down',
|
|
99
|
+
statusCode: 503,
|
|
100
|
+
}));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
test('A sync that is canceled by a shutdown walks nothing and leaves no status behind', async () => {
|
|
104
|
+
const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
|
|
105
|
+
stripeMocker.createBalanceTransaction({
|
|
106
|
+
type: 'stripe_fee',
|
|
107
|
+
amount: 10000,
|
|
108
|
+
created: new Date(2026, 0, 15),
|
|
109
|
+
payout: payout.id,
|
|
110
|
+
source: null,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Occupy the queue, so the sync is still waiting its turn when the shutdown aborts it
|
|
114
|
+
let release = () => {};
|
|
115
|
+
const occupied = QueueHandler.schedule('settlement-sync', async () => {
|
|
116
|
+
await new Promise<void>((resolve) => {
|
|
117
|
+
release = resolve;
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await post(membershipOrganization, adminToken);
|
|
123
|
+
expect(response.status).toBe(200);
|
|
124
|
+
expect((await getStatus(membershipOrganization, adminToken)).body).toHaveLength(1);
|
|
125
|
+
|
|
126
|
+
shutdown();
|
|
127
|
+
} finally {
|
|
128
|
+
release();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await occupied;
|
|
132
|
+
await QueueHandler.awaitAll();
|
|
133
|
+
|
|
134
|
+
expect(await Settlement.select().where('externalId', payout.id).count()).toBe(0);
|
|
135
|
+
|
|
136
|
+
// The status list may not keep a sync that never ran
|
|
137
|
+
expect((await getStatus(membershipOrganization, adminToken)).body).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('A shutdown interrupts a sync that is already walking', async () => {
|
|
141
|
+
let running: AbortSignal | null = null;
|
|
142
|
+
let stopWalking = () => {};
|
|
143
|
+
|
|
144
|
+
const spy = vi.spyOn(SettlementSyncRunner.prototype, 'run').mockImplementation(async ({ abort } = {}) => {
|
|
145
|
+
running = abort ?? null;
|
|
146
|
+
|
|
147
|
+
// Walk until the shutdown asks us to stop
|
|
148
|
+
await new Promise<void>((resolve) => {
|
|
149
|
+
stopWalking = resolve;
|
|
150
|
+
abort?.on('abort', () => resolve());
|
|
151
|
+
});
|
|
152
|
+
abort?.throwIfAborted();
|
|
153
|
+
|
|
154
|
+
return { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const response = await post(membershipOrganization, adminToken);
|
|
159
|
+
expect(response.status).toBe(200);
|
|
160
|
+
await vi.waitFor(() => expect(running).not.toBeNull());
|
|
161
|
+
|
|
162
|
+
shutdown();
|
|
163
|
+
await QueueHandler.awaitAll();
|
|
164
|
+
} finally {
|
|
165
|
+
// A failed assertion may not leave the walk (and the queue behind it) parked forever
|
|
166
|
+
stopWalking();
|
|
167
|
+
spy.mockRestore();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The signal of the queue job reaches the runner, so the walk stops at its next safe point
|
|
171
|
+
expect(running!.isAborted).toBe(true);
|
|
172
|
+
expect((await getStatus(membershipOrganization, adminToken)).body).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
91
175
|
test('A user without platform full access cannot run the sync', async () => {
|
|
92
176
|
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
93
177
|
const token = await TokenModel.createToken(user);
|
|
@@ -86,17 +86,18 @@ export class SettlementsSyncEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
86
86
|
});
|
|
87
87
|
SettlementsSyncEndpoint.queue.push(item);
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
// A shutdown aborts the queue: the run stops at its next safe point instead of holding up
|
|
90
|
+
// the restart, and the status list is cleaned up whether the run finished, was aborted, or
|
|
91
|
+
// was canceled before it started
|
|
92
|
+
QueueHandler.schedule('settlement-sync', async ({ abort }) => {
|
|
93
|
+
const runner = new SettlementSyncRunner();
|
|
94
|
+
runner.callback = (summary) => {
|
|
95
|
+
item.count = summary.synced + summary.skipped + summary.failed;
|
|
96
|
+
item.failed = summary.failed + summary.failedFeeMonths;
|
|
97
|
+
};
|
|
98
|
+
await runner.run({ start, end, providers, stripe: { force }, abort });
|
|
99
|
+
}).finally(() => {
|
|
100
|
+
SettlementsSyncEndpoint.queue = SettlementsSyncEndpoint.queue.filter(queued => queued !== item);
|
|
100
101
|
}).catch(console.error);
|
|
101
102
|
|
|
102
103
|
return new Response(undefined);
|
|
@@ -9,6 +9,7 @@ import { Formatter, isReservedWebshopPathSegment } from '@stamhoofd/utility';
|
|
|
9
9
|
|
|
10
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
11
|
import { RecordAnswerHelper } from '../../../../helpers/RecordAnswerHelper.js';
|
|
12
|
+
import { WebshopCrowdfundingService } from '../../../../services/WebshopCrowdfundingService.js';
|
|
12
13
|
|
|
13
14
|
type Params = { id: string };
|
|
14
15
|
type Query = undefined;
|
|
@@ -226,6 +227,11 @@ export class PatchWebshopEndpoint extends Endpoint<Params, Query, Body, Response
|
|
|
226
227
|
throw e;
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
if (request.body.meta?.crowdfunding !== undefined) {
|
|
231
|
+
// A changed crowdfunding configuration (e.g. just enabled) requires a recalculation of the cached amounts
|
|
232
|
+
await WebshopCrowdfundingService.updateWebshop(webshop);
|
|
233
|
+
}
|
|
234
|
+
|
|
229
235
|
return new Response(PrivateWebshop.create(webshop));
|
|
230
236
|
});
|
|
231
237
|
}
|
|
@@ -10,6 +10,7 @@ import { AuditLogSource, BalanceItemRelation, BalanceItemRelationType, BalanceIt
|
|
|
10
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
11
|
import { ServiceFeeHelper } from '../../../../helpers/ServiceFeeHelper.js';
|
|
12
12
|
import { AuditLogService } from '../../../../services/AuditLogService.js';
|
|
13
|
+
import { BalanceItemService } from '../../../../services/BalanceItemService.js';
|
|
13
14
|
import { OrderService } from '../../../../services/OrderService.js';
|
|
14
15
|
import { PaymentService } from '../../../../services/PaymentService.js';
|
|
15
16
|
import { shouldReserveUitpasNumbers, UitpasService } from '../../../../services/uitpas/UitpasService.js';
|
|
@@ -232,6 +233,9 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
232
233
|
|
|
233
234
|
balanceItem.description = order.generateBalanceDescription(webshop);
|
|
234
235
|
await balanceItem.save();
|
|
236
|
+
|
|
237
|
+
// Update the cached pricePending of the balance item, so the unresolved payment is visible
|
|
238
|
+
await BalanceItemService.updatePaidAndPending([balanceItem]);
|
|
235
239
|
}
|
|
236
240
|
} catch (e) {
|
|
237
241
|
await order.deleteOrderBecauseOfCreationError();
|
|
@@ -15,6 +15,7 @@ import { Context } from '../../../helpers/Context.js';
|
|
|
15
15
|
import { ServiceFeeHelper } from '../../../helpers/ServiceFeeHelper.js';
|
|
16
16
|
import { StripeHelper } from '../../../helpers/StripeHelper.js';
|
|
17
17
|
import { AuditLogService } from '../../../services/AuditLogService.js';
|
|
18
|
+
import { BalanceItemService } from '../../../services/BalanceItemService.js';
|
|
18
19
|
import { MollieService } from '../../../services/MollieService.js';
|
|
19
20
|
import { OrderService } from '../../../services/OrderService.js';
|
|
20
21
|
import { PaymentService } from '../../../services/PaymentService.js';
|
|
@@ -356,6 +357,9 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
// Update the cached pricePending of the balance item, so the unresolved payment is visible
|
|
361
|
+
await BalanceItemService.updatePaidAndPending([balanceItem]);
|
|
362
|
+
|
|
359
363
|
return new Response(OrderResponse.create({
|
|
360
364
|
paymentUrl: paymentUrl,
|
|
361
365
|
paymentQRCode,
|
|
@@ -3,10 +3,14 @@ import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
|
3
3
|
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
4
4
|
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
5
5
|
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
6
|
+
import { AbortSignal } from '@stamhoofd/queues';
|
|
6
7
|
import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
|
|
7
8
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
9
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
10
|
+
import { vi } from 'vitest';
|
|
8
11
|
import type { MollieMockPayment, MollieMockRefund } from '../../tests/helpers/MollieMocker.js';
|
|
9
12
|
import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
|
|
13
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
10
14
|
import { MollieSettlementSync } from './MollieSettlementSync.js';
|
|
11
15
|
import type { SettlementSyncSummary } from './ProviderSettlementSyncRunner.js';
|
|
12
16
|
|
|
@@ -340,6 +344,45 @@ describe('Helper.MollieSettlementSync', () => {
|
|
|
340
344
|
expect(summary.failed).toBe(0);
|
|
341
345
|
});
|
|
342
346
|
|
|
347
|
+
test('An interrupted walk gives up its synced state without counting a failure', async () => {
|
|
348
|
+
const { token, mockPayment, mockRefund } = await init();
|
|
349
|
+
|
|
350
|
+
const settlement = mollieMocker.createSettlement({ payments: [mockPayment], refunds: [mockRefund], value: '30.00', settledAt: new Date(2026, 2, 3) });
|
|
351
|
+
|
|
352
|
+
await runCron(token);
|
|
353
|
+
expect((await Settlement.select().where('externalId', settlement.id).first(true)).syncedAt).not.toBeNull();
|
|
354
|
+
|
|
355
|
+
// Older, so the newest-first walk only reaches it after the one it is interrupted in
|
|
356
|
+
const untouched = mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00', settledAt: new Date(2026, 2, 2) });
|
|
357
|
+
|
|
358
|
+
// Abort while the walk stores its first entry, so the settlement is only walked halfway
|
|
359
|
+
const abort = new AbortSignal();
|
|
360
|
+
const upsertPaymentLine = SettlementService.upsertPaymentLine.bind(SettlementService);
|
|
361
|
+
const spy = vi.spyOn(SettlementService, 'upsertPaymentLine').mockImplementation(async (row, data) => {
|
|
362
|
+
abort.abort();
|
|
363
|
+
return await upsertPaymentLine(row, data);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const summary: SettlementSyncSummary = { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
|
|
367
|
+
try {
|
|
368
|
+
await expect(new MollieSettlementSync({ token }).syncSettlements({
|
|
369
|
+
start: new Date(2020, 0, 1),
|
|
370
|
+
summary,
|
|
371
|
+
abort,
|
|
372
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
|
|
373
|
+
} finally {
|
|
374
|
+
spy.mockRestore();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const row = await Settlement.select().where('externalId', settlement.id).first(true);
|
|
378
|
+
expect(row.syncedAt).toBeNull();
|
|
379
|
+
expect(row.syncFailureCount).toBe(0);
|
|
380
|
+
expect(summary).toMatchObject({ synced: 0, failed: 0 });
|
|
381
|
+
|
|
382
|
+
// The walk stopped at the settlement it was in: the next one was never started
|
|
383
|
+
expect(await Settlement.select().where('externalId', untouched.id).count()).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
|
|
343
386
|
test('An unlinked refund entry in a settlement is skipped without affecting the known refund', async () => {
|
|
344
387
|
const { token, refundPayment, mockPayment, mockRefund } = await init();
|
|
345
388
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MollieToken } from '@stamhoofd/models';
|
|
2
2
|
import { MolliePayment, Payment } from '@stamhoofd/models';
|
|
3
3
|
import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
4
|
+
import type { AbortSignal } from '@stamhoofd/queues';
|
|
4
5
|
import { PaymentProvider } from '@stamhoofd/structures';
|
|
5
6
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
6
7
|
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
@@ -47,6 +48,11 @@ type MollieSettlement = {
|
|
|
47
48
|
type SettlementSyncState = {
|
|
48
49
|
settlementRow: Settlement;
|
|
49
50
|
reported: ReportedRows;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stops the walk at the next entry (a restart).
|
|
54
|
+
*/
|
|
55
|
+
abort: AbortSignal;
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -89,14 +95,17 @@ export class MollieSettlementSync {
|
|
|
89
95
|
/**
|
|
90
96
|
* Walk the settlements newest first, until they settle before `start`.
|
|
91
97
|
*/
|
|
92
|
-
async syncSettlements({ start, end = new Date(), summary }: {
|
|
98
|
+
async syncSettlements({ start, end = new Date(), summary, abort }: {
|
|
93
99
|
start: Date;
|
|
94
100
|
end?: Date;
|
|
95
101
|
summary?: SettlementSyncSummary;
|
|
102
|
+
abort?: AbortSignal;
|
|
96
103
|
}): Promise<void> {
|
|
97
104
|
let url: string | null = 'https://api.mollie.com/v2/settlements?limit=250';
|
|
98
105
|
|
|
99
106
|
while (url) {
|
|
107
|
+
abort?.throwIfAborted();
|
|
108
|
+
|
|
100
109
|
const request = await this.#get(url);
|
|
101
110
|
|
|
102
111
|
if (request.status !== 200) {
|
|
@@ -112,6 +121,8 @@ export class MollieSettlementSync {
|
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
for (const settlement of settlements) {
|
|
124
|
+
abort?.throwIfAborted();
|
|
125
|
+
|
|
115
126
|
if (settlement.settledAt === null) {
|
|
116
127
|
// Skip: this is the open settlement
|
|
117
128
|
continue;
|
|
@@ -134,11 +145,14 @@ export class MollieSettlementSync {
|
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
try {
|
|
137
|
-
await SettlementService.lock(PaymentProvider.Mollie, settlement.id,
|
|
148
|
+
await SettlementService.lock(PaymentProvider.Mollie, settlement.id, signal => this.#syncSettlement(settlement, signal), { abort });
|
|
138
149
|
if (summary) {
|
|
139
150
|
summary.synced += 1;
|
|
140
151
|
}
|
|
141
152
|
} catch (e) {
|
|
153
|
+
// An interrupted settlement is not a failing settlement
|
|
154
|
+
abort?.throwIfAborted();
|
|
155
|
+
|
|
142
156
|
console.error('Sync of Mollie settlement ' + settlement.id + ' failed', e);
|
|
143
157
|
if (summary) {
|
|
144
158
|
summary.failed += 1;
|
|
@@ -163,7 +177,7 @@ export class MollieSettlementSync {
|
|
|
163
177
|
});
|
|
164
178
|
}
|
|
165
179
|
|
|
166
|
-
async #syncSettlement(settlement: MollieSettlement) {
|
|
180
|
+
async #syncSettlement(settlement: MollieSettlement, abort: AbortSignal) {
|
|
167
181
|
const settlementRow = await SettlementService.upsertSettlement({
|
|
168
182
|
provider: PaymentProvider.Mollie,
|
|
169
183
|
externalId: settlement.id,
|
|
@@ -178,6 +192,7 @@ export class MollieSettlementSync {
|
|
|
178
192
|
const state: SettlementSyncState = {
|
|
179
193
|
settlementRow,
|
|
180
194
|
reported: new ReportedRows(),
|
|
195
|
+
abort,
|
|
181
196
|
};
|
|
182
197
|
|
|
183
198
|
try {
|
|
@@ -200,7 +215,13 @@ export class MollieSettlementSync {
|
|
|
200
215
|
transactionCount: state.reported.paymentLineExternalIds.size + state.reported.chargeExternalIds.size,
|
|
201
216
|
});
|
|
202
217
|
} catch (e) {
|
|
203
|
-
|
|
218
|
+
// A walk that was interrupted stored only part of the settlement: it has to be walked
|
|
219
|
+
// again, but it didn't fail
|
|
220
|
+
if (abort.isAborted) {
|
|
221
|
+
await SettlementService.markSyncInterrupted(settlementRow);
|
|
222
|
+
} else {
|
|
223
|
+
await SettlementService.markSyncFailed(settlementRow);
|
|
224
|
+
}
|
|
204
225
|
throw e;
|
|
205
226
|
}
|
|
206
227
|
}
|
|
@@ -265,6 +286,10 @@ export class MollieSettlementSync {
|
|
|
265
286
|
const entries = request.data._embedded[resource] as MollieSettlementEntryJSON[];
|
|
266
287
|
|
|
267
288
|
for (const entry of entries) {
|
|
289
|
+
// Between two entries is a safe point to stop: the settlement only claims to be
|
|
290
|
+
// synced after the sweep, so an interrupted walk is re-walked from the start
|
|
291
|
+
state.abort.throwIfAborted();
|
|
292
|
+
|
|
268
293
|
await this.#applySettlementToPayment(settlement, entry.id, state);
|
|
269
294
|
}
|
|
270
295
|
|