@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.
Files changed (29) 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 +19 -8
  5. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +1 -1
  6. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
  7. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
  8. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
  9. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  10. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
  11. package/src/helpers/MollieSettlementSync.test.ts +43 -0
  12. package/src/helpers/MollieSettlementSync.ts +29 -4
  13. package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
  14. package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
  15. package/src/helpers/SettlementSyncRunner.test.ts +53 -0
  16. package/src/helpers/SettlementSyncRunner.ts +20 -3
  17. package/src/helpers/StripeSettlementSync.test.ts +206 -1
  18. package/src/helpers/StripeSettlementSync.ts +106 -61
  19. package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
  20. package/src/helpers/StripeSettlementSyncRunner.ts +26 -9
  21. package/src/helpers/waitUntilDeadline.test.ts +48 -0
  22. package/src/helpers/waitUntilDeadline.ts +28 -0
  23. package/src/services/ApplicationFeeService.ts +20 -2
  24. package/src/services/BalanceItemService.ts +5 -0
  25. package/src/services/SettlementService.ts +26 -3
  26. package/src/services/WebshopCrowdfundingService.test.ts +433 -0
  27. package/src/services/WebshopCrowdfundingService.ts +98 -0
  28. package/tests/filters/orders.test.ts +24 -1
  29. 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.142.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.142.0",
69
- "@stamhoofd/backend-i18n": "2.142.0",
70
- "@stamhoofd/backend-middleware": "2.142.0",
71
- "@stamhoofd/crons": "2.142.0",
72
- "@stamhoofd/email": "2.142.0",
73
- "@stamhoofd/excel-writer": "2.142.0",
74
- "@stamhoofd/logging": "2.142.0",
75
- "@stamhoofd/models": "2.142.0",
76
- "@stamhoofd/object-differ": "2.142.0",
77
- "@stamhoofd/queues": "2.142.0",
78
- "@stamhoofd/sql": "2.142.0",
79
- "@stamhoofd/structures": "2.142.0",
80
- "@stamhoofd/types": "2.142.0",
81
- "@stamhoofd/utility": "2.142.0",
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.142.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": "2903821fe8196e3bb194da193aaf1cda2c3bf076"
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
 
@@ -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('Er zijn nog openstaande bedragen, betalingen, transactiekosten of facturen waarvoor {organization} moet betalen of betaald heeft. Die gegevens mogen niet verloren gaan.', { organization: organization.name }),
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
- QueueHandler.schedule('settlement-sync', async () => {
90
- try {
91
- const runner = new SettlementSyncRunner();
92
- runner.callback = (summary) => {
93
- item.count = summary.synced + summary.skipped + summary.failed;
94
- item.failed = summary.failed + summary.failedFeeMonths;
95
- };
96
- await runner.run({ start, end, providers, stripe: { force } });
97
- } finally {
98
- SettlementsSyncEndpoint.queue.splice(SettlementsSyncEndpoint.queue.indexOf(item), 1);
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, () => this.#syncSettlement(settlement));
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
- await SettlementService.markSyncFailed(settlementRow);
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