@stamhoofd/backend 2.141.0 → 2.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +17 -17
  2. package/src/boot.ts +31 -16
  3. package/src/crons/settlement-sync.test.ts +59 -1
  4. package/src/crons/settlement-sync.ts +20 -9
  5. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  6. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  7. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  8. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  9. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
  10. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
  11. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
  12. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  13. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
  14. package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
  15. package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
  16. package/src/helpers/MollieSettlementSync.test.ts +43 -0
  17. package/src/helpers/MollieSettlementSync.ts +29 -4
  18. package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
  19. package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
  20. package/src/helpers/SettlementExporter.test.ts +22 -0
  21. package/src/helpers/SettlementExporter.ts +13 -3
  22. package/src/helpers/SettlementSyncRunner.test.ts +53 -0
  23. package/src/helpers/SettlementSyncRunner.ts +20 -3
  24. package/src/helpers/StripeSettlementSync.test.ts +375 -1
  25. package/src/helpers/StripeSettlementSync.ts +264 -113
  26. package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
  27. package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
  28. package/src/helpers/waitUntilDeadline.test.ts +48 -0
  29. package/src/helpers/waitUntilDeadline.ts +28 -0
  30. package/src/services/ApplicationFeeService.ts +47 -11
  31. package/src/services/BalanceItemService.ts +5 -0
  32. package/src/services/SettlementService.test.ts +82 -9
  33. package/src/services/SettlementService.ts +72 -6
  34. package/src/services/WebshopCrowdfundingService.test.ts +433 -0
  35. package/src/services/WebshopCrowdfundingService.ts +98 -0
  36. package/tests/filters/orders.test.ts +24 -1
  37. package/tests/vitest.setup.ts +5 -2
@@ -35,11 +35,15 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
35
35
  }
36
36
 
37
37
  async handle(request: DecodedRequest<Params, Query, Body>) {
38
- const organization = await Context.setOrganizationScope();
38
+ const organization = await Context.setOptionalOrganizationScope();
39
39
  await Context.authenticate();
40
40
 
41
41
  // Fast throw first (more in depth checking for patches later)
42
- if (!await Context.auth.hasSomeAccess(organization.id)) {
42
+ if (organization) {
43
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
44
+ throw Context.auth.error();
45
+ }
46
+ } else if (!Context.auth.hasSomePlatformAccess()) {
43
47
  throw Context.auth.error();
44
48
  }
45
49
 
@@ -48,11 +52,11 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
48
52
 
49
53
  const puts = request.body.getPuts();
50
54
  for (const { put } of puts) {
51
- await this.checkCanCreateRegistrationInvitation(put, organization.id);
55
+ const group = await this.checkCanCreateRegistrationInvitation(put, organization?.id ?? null);
52
56
 
53
57
  const invitation = new RegistrationInvitation();
54
58
  invitation.id = put.id;
55
- invitation.organizationId = organization.id;
59
+ invitation.organizationId = group.organizationId;
56
60
  invitation.groupId = put.groupId;
57
61
  invitation.memberId = put.memberId;
58
62
 
@@ -125,12 +129,13 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
125
129
  /**
126
130
  * Will throw if not allowed to invite.
127
131
  * @param invitation
128
- * @param organizationId id of organization to invite for, should match the organizationId in the invitation
132
+ * @param organizationId organization scope of the request; when set, the group must belong to it (platform admins can invite without a scope)
133
+ * @returns the group to invite for
129
134
  */
130
- private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string) {
135
+ private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string | null): Promise<Group> {
131
136
  const group = await Group.getByID(invitation.groupId);
132
137
 
133
- if (!group || group.organizationId !== organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
138
+ if (!group || (organizationId !== null && group.organizationId !== organizationId) || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
134
139
  throw Context.auth.error($t(`%1ST`));
135
140
  }
136
141
 
@@ -147,7 +152,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
147
152
 
148
153
  if (!member
149
154
  // in userMode 'organization' we can only invite members from the same organization
150
- || (STAMHOOFD.userMode === 'organization' && member.organizationId !== organizationId)
155
+ || (STAMHOOFD.userMode === 'organization' && member.organizationId !== group.organizationId)
151
156
  // read access is suficient
152
157
  || !await Context.auth.canAccessMember(member, PermissionLevel.Read)
153
158
  ) {
@@ -163,5 +168,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
163
168
  human: $t('%1S2'),
164
169
  });
165
170
  }
171
+
172
+ return group;
166
173
  }
167
174
  }
@@ -1,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,
@@ -1,3 +1,4 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
1
2
  import type { StripeAccount } from '@stamhoofd/models';
2
3
  import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
3
4
  import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
@@ -15,6 +16,7 @@ import { initMembershipOrganization } from '../../tests/init/initMembershipOrgan
15
16
  import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
16
17
  import { SettlementService } from '../services/SettlementService.js';
17
18
  import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
19
+ import { WebmasterReport } from './WebmasterReport.js';
18
20
 
19
21
  describe('ApplicationFeeInvoicer', () => {
20
22
  const stripeMocker = new StripeMocker();
@@ -262,8 +264,18 @@ describe('ApplicationFeeInvoicer', () => {
262
264
  await createFee(organization, stripeAccount, { amount: 30_00 });
263
265
  await createFee(organization, broken, { amount: 40_00 });
264
266
 
265
- // Deleting the account clears payingStripeAccountId: that group can't be billed anymore
266
- await broken.delete();
267
+ // A month the legacy invoicer billed may never be billed again: that group throws
268
+ const legacy = new Payment();
269
+ legacy.organizationId = membershipOrganization.id;
270
+ legacy.payingOrganizationId = organization.id;
271
+ legacy.stripeAccountId = broken.id;
272
+ legacy.method = PaymentMethod.AccountDeductions;
273
+ legacy.provider = PaymentProvider.Stripe;
274
+ legacy.status = PaymentStatus.Succeeded;
275
+ legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
276
+ legacy.price = 40_00;
277
+ legacy.paidAt = occurredAt;
278
+ await legacy.save();
267
279
 
268
280
  await invoiceMonth();
269
281
 
@@ -272,14 +284,63 @@ describe('ApplicationFeeInvoicer', () => {
272
284
  expect(payments[0].price).toBe(30_00);
273
285
  });
274
286
 
287
+ test('fees without the Stripe account they were deducted from are skipped, not retried forever', async () => {
288
+ const { organization, stripeAccount } = await init();
289
+ const removed = await stripeMocker.createStripeAccount(organization.id);
290
+
291
+ await createFee(organization, stripeAccount, { amount: 30_00 });
292
+ const orphaned = await createFee(organization, removed, { amount: 40_00 });
293
+
294
+ // Deleting the account row clears payingStripeAccountId: that fee can no longer be checked
295
+ // against what the legacy invoicer billed per account, so it is not billed at all
296
+ await removed.delete();
297
+
298
+ await WebmasterReport.group('Overslaan applicatiekosten', async () => {
299
+ await invoiceMonth();
300
+ });
301
+
302
+ const payments = await getFeePayments(organization);
303
+ expect(payments).toHaveLength(1);
304
+ expect(payments[0].price).toBe(30_00);
305
+ expect((await ApplicationFee.getByID(orphaned.id))!.balanceItemId).toBeNull();
306
+
307
+ // Skipping is silent here: the sync that stored the fee already reported it, and this run
308
+ // repeats every night
309
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Overslaan applicatiekosten'));
310
+ expect(emails).toHaveLength(0);
311
+ });
312
+
275
313
  test('charges of a billed fee keep pointing at the deduction row', async () => {
276
314
  const { organization, stripeAccount } = await init();
277
315
  const fee = await createFee(organization, stripeAccount);
278
316
 
279
317
  await invoiceMonth();
280
318
 
281
- const charge = await SettlementCharge.getByID(fee.settlementChargeId);
319
+ const charge = await SettlementCharge.getByID(fee.settlementChargeId!);
282
320
  expect(charge).toBeDefined();
283
321
  expect(charge!.amount).toBe(-30_00);
284
322
  });
323
+
324
+ test('fees of a deleted organization are never billed, and do not block the other accounts', async () => {
325
+ const { organization, stripeAccount } = await init();
326
+ const { organization: other, stripeAccount: otherAccount } = await init();
327
+
328
+ const orphaned = await createFee(organization, stripeAccount, { amount: 30_00 });
329
+ await createFee(other, otherAccount, { amount: 40_00 });
330
+
331
+ // Takes the Stripe account and the deduction charge with it, but not our income
332
+ await organization.delete();
333
+
334
+ await invoiceMonth();
335
+
336
+ const payments = await getFeePayments(other);
337
+ expect(payments).toHaveLength(1);
338
+ expect(payments[0].price).toBe(40_00);
339
+
340
+ const stored = await ApplicationFee.getByID(orphaned.id);
341
+ expect(stored).toBeDefined();
342
+ expect(stored!.payingOrganizationId).toBeNull();
343
+ expect(stored!.settlementChargeId).toBeNull();
344
+ expect(stored!.balanceItemId).toBeNull();
345
+ });
285
346
  });
@@ -84,9 +84,7 @@ export class ApplicationFeeInvoicer {
84
84
  const snapshot = truncateToSecond(new Date());
85
85
 
86
86
  const currentPeriodStart = SettlementService.getPeriodStart(new Date());
87
- const oldest = await ApplicationFee.select()
88
- .where('organizationId', sellingOrganization.id)
89
- .where('balanceItemId', null)
87
+ const oldest = await this.#selectBillableFees(sellingOrganization)
90
88
  .where('occurredAt', '<', currentPeriodStart)
91
89
  .where('createdAt', '<', snapshot)
92
90
  .orderBy('occurredAt', 'ASC')
@@ -149,15 +147,16 @@ export class ApplicationFeeInvoicer {
149
147
  const reference = ApplicationFeeInvoicer.reference(periodStart);
150
148
 
151
149
  await QueueHandler.schedule(reference, async () => {
152
- const totalsPerAccount = new Map<string | null, AccountTotals>();
150
+ const totalsPerAccount = new Map<string, AccountTotals>();
153
151
 
152
+ // Both ids are non-null: #selectUninvoicedFees only returns fees that can be billed
154
153
  for await (const fee of this.#selectUninvoicedFees(sellingOrganization, periodStart, nextPeriodStart, snapshot).all()) {
155
- const totals = totalsPerAccount.get(fee.payingStripeAccountId) ?? {
156
- payingOrganizationId: fee.payingOrganizationId,
154
+ const totals = totalsPerAccount.get(fee.payingStripeAccountId!) ?? {
155
+ payingOrganizationId: fee.payingOrganizationId!,
157
156
  amountPerType: new Map<ApplicationFeeType, number>(),
158
157
  };
159
158
  totals.amountPerType.set(fee.type, (totals.amountPerType.get(fee.type) ?? 0) + fee.amount);
160
- totalsPerAccount.set(fee.payingStripeAccountId, totals);
159
+ totalsPerAccount.set(fee.payingStripeAccountId!, totals);
161
160
  }
162
161
 
163
162
  for (const [payingStripeAccountId, totals] of totalsPerAccount) {
@@ -171,19 +170,32 @@ export class ApplicationFeeInvoicer {
171
170
  });
172
171
  }
173
172
 
173
+ /**
174
+ * A fee this invoicer cannot bill is left out everywhere, or every run would walk its month at
175
+ * Stripe and report it again: without a paying organization there is nobody left to bill, and
176
+ * without its Stripe account a month cannot be checked against what the legacy invoicer billed
177
+ * per account — billing it anyway risks charging it twice. Both are reported by the sync that
178
+ * stored them (StripeSettlementSync.reportUnattributedFee).
179
+ */
174
180
  #selectUninvoicedFees(sellingOrganization: Organization, periodStart: Date, nextPeriodStart: Date, snapshot: Date) {
175
- return ApplicationFee.select()
176
- .where('organizationId', sellingOrganization.id)
177
- .where('balanceItemId', null)
181
+ return this.#selectBillableFees(sellingOrganization)
178
182
  .where('occurredAt', '>=', periodStart)
179
183
  .where('occurredAt', '<', nextPeriodStart)
180
184
  .where('createdAt', '<', snapshot)
181
185
  .limit(FEE_BATCH_SIZE);
182
186
  }
183
187
 
188
+ #selectBillableFees(sellingOrganization: Organization) {
189
+ return ApplicationFee.select()
190
+ .where('organizationId', sellingOrganization.id)
191
+ .where('balanceItemId', null)
192
+ .where('payingOrganizationId', '!=', null)
193
+ .where('payingStripeAccountId', '!=', null);
194
+ }
195
+
184
196
  async #invoiceGroup({ sellingOrganization, payingStripeAccountId, totals, periodStart, nextPeriodStart, snapshot }: {
185
197
  sellingOrganization: Organization;
186
- payingStripeAccountId: string | null;
198
+ payingStripeAccountId: string;
187
199
  totals: AccountTotals;
188
200
  periodStart: Date;
189
201
  nextPeriodStart: Date;
@@ -194,13 +206,6 @@ export class ApplicationFeeInvoicer {
194
206
  return;
195
207
  }
196
208
 
197
- if (!payingStripeAccountId) {
198
- throw new SimpleError({
199
- code: 'missing_stripe_account',
200
- message: 'Uninvoiced application fees without a Stripe account',
201
- });
202
- }
203
-
204
209
  const stripeAccount = await StripeAccount.getByID(payingStripeAccountId);
205
210
  if (!stripeAccount) {
206
211
  throw new SimpleError({
@@ -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
 
@@ -10,7 +10,7 @@ import type { ProviderSettlementSyncRunner, ProviderSyncRunOptions } from './Pro
10
10
  * (STAMHOOFD.MOLLIE_ORGANIZATION_TOKEN for the platform, a MollieToken row per organization).
11
11
  */
12
12
  export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner {
13
- async run({ start, end, summary, onProgress }: ProviderSyncRunOptions): Promise<void> {
13
+ async run({ start, end, summary, onProgress, abort }: ProviderSyncRunOptions): Promise<void> {
14
14
  const accessToken = STAMHOOFD.MOLLIE_ORGANIZATION_TOKEN;
15
15
  if (!accessToken) {
16
16
  console.error('Missing mollie organization token');
@@ -24,8 +24,11 @@ export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner
24
24
  // A plain access token without a refresh flow: the future expiry keeps
25
25
  // refreshIfNeeded from attempting one
26
26
  token.expiresOn = new Date(new Date().getTime() + 24 * 60 * 60 * 1000);
27
- await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary });
27
+ await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary, abort });
28
28
  } catch (e) {
29
+ // An interrupted account is not a failing account
30
+ abort.throwIfAborted();
31
+
29
32
  console.error(e);
30
33
  summary.failed += 1;
31
34
  }
@@ -34,6 +37,8 @@ export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner
34
37
 
35
38
  const mollieTokens = await MollieToken.all();
36
39
  for (const token of mollieTokens) {
40
+ abort.throwIfAborted();
41
+
37
42
  // Tokens created before the settlements permission was added to the OAuth scope
38
43
  // cannot read settlements
39
44
  if (token.createdAt < new Date(2021, 8 /* september! */, 8)) {
@@ -42,8 +47,10 @@ export class MollieSettlementSyncRunner implements ProviderSettlementSyncRunner
42
47
  }
43
48
 
44
49
  try {
45
- await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary });
50
+ await new MollieSettlementSync({ token }).syncSettlements({ start, end, summary, abort });
46
51
  } catch (e) {
52
+ abort.throwIfAborted();
53
+
47
54
  console.error(e);
48
55
  summary.failed += 1;
49
56
  }