@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
@@ -4,6 +4,7 @@ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
4
  import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
5
5
  import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
6
6
  import type { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
7
+ import type { AbortSignal } from '@stamhoofd/queues';
7
8
  import { PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
8
9
  import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
9
10
  import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
@@ -111,7 +112,7 @@ export class StripeSettlementSync {
111
112
  * Sync all paid payouts that arrived in the window. A failing payout is marked, reported and
112
113
  * skipped so the other payouts still sync; the summary tells the caller how bad it was.
113
114
  */
114
- async syncPayouts({ start, end, force = false }: { start: Date; end?: Date; force?: boolean }): Promise<{ synced: number; skipped: number; failed: number }> {
115
+ async syncPayouts({ start, end, force = false, abort }: { start: Date; end?: Date; force?: boolean; abort?: AbortSignal }): Promise<{ synced: number; skipped: number; failed: number }> {
115
116
  const result = { synced: 0, skipped: 0, failed: 0 };
116
117
 
117
118
  // Fail once up front when no organization can own these payouts, instead of once per payout
@@ -127,14 +128,20 @@ export class StripeSettlementSync {
127
128
  },
128
129
  limit: 100,
129
130
  })) {
131
+ abort?.throwIfAborted();
132
+
130
133
  try {
131
- const { skipped } = await this.syncPayout(payout, { force });
134
+ const { skipped } = await this.syncPayout(payout, { force, abort });
132
135
  if (skipped) {
133
136
  result.skipped += 1;
134
137
  } else {
135
138
  result.synced += 1;
136
139
  }
137
140
  } catch (e) {
141
+ // An interrupted payout is not a failing payout: counting or reporting it would
142
+ // turn every restart into a wave of problems to look into
143
+ abort?.throwIfAborted();
144
+
138
145
  console.error('Failed to sync Stripe payout ' + payout.id, e);
139
146
  result.failed += 1;
140
147
 
@@ -150,7 +157,7 @@ export class StripeSettlementSync {
150
157
  /**
151
158
  * Re-sync one payout by its id, e.g. to retry a settlement that stayed unsynced.
152
159
  */
153
- async syncPayoutById(externalId: string, options: { force?: boolean } = {}): Promise<{ settlement: Settlement; skipped: boolean }> {
160
+ async syncPayoutById(externalId: string, options: { force?: boolean; abort?: AbortSignal } = {}): Promise<{ settlement: Settlement; skipped: boolean }> {
154
161
  const payout = await this.stripe.payouts.retrieve(externalId);
155
162
  return await this.syncPayout(payout, options);
156
163
  }
@@ -161,7 +168,7 @@ export class StripeSettlementSync {
161
168
  * in the settlement links later. A broken fee doesn't block storing the others, but the walk
162
169
  * still fails loudly at the end: a month is only invoiced after a run without errors.
163
170
  */
164
- async syncFees({ start, end }: { start: Date; end: Date }) {
171
+ async syncFees({ start, end, abort }: { start: Date; end: Date; abort?: AbortSignal }) {
165
172
  if (this.stripeAccount) {
166
173
  throw new SimpleError({
167
174
  code: 'invalid_scope',
@@ -175,26 +182,35 @@ export class StripeSettlementSync {
175
182
  // billed). When it is already paid out, its payout needs the derived line for it
176
183
  const invoicedFeeBalanceItemIds = new Set<string>();
177
184
 
178
- for await (const transaction of this.stripe.balanceTransactions.list({
179
- type: 'application_fee',
180
- created: {
181
- gte: Math.floor(start.getTime() / 1000),
182
- lte: Math.floor(end.getTime() / 1000),
183
- },
184
- expand: ['data.source', 'data.source.originating_transaction'],
185
- limit: 100,
186
- })) {
187
- try {
188
- const { fees } = await this.#handleApplicationFee(transaction);
189
- for (const fee of fees) {
190
- if (fee.balanceItemId && fee.settlementId) {
191
- invoicedFeeBalanceItemIds.add(fee.balanceItemId);
192
- }
185
+ try {
186
+ for await (const transaction of this.stripe.balanceTransactions.list({
187
+ type: 'application_fee',
188
+ created: {
189
+ gte: Math.floor(start.getTime() / 1000),
190
+ lte: Math.floor(end.getTime() / 1000),
191
+ },
192
+ expand: ['data.source', 'data.source.originating_transaction'],
193
+ limit: 100,
194
+ })) {
195
+ abort?.throwIfAborted();
196
+
197
+ try {
198
+ await this.#handleApplicationFee(transaction, { invoicedFeeBalanceItemIds });
199
+ } catch (e) {
200
+ // The month is only invoiced after a walk without errors, so an interrupted
201
+ // walk has to stop the walk itself instead of joining the errors of its
202
+ // transactions
203
+ abort?.throwIfAborted();
204
+
205
+ console.error('Failed to sync application fee transaction ' + transaction.id, e);
206
+ errors.push(e);
193
207
  }
194
- } catch (e) {
195
- console.error('Failed to sync application fee transaction ' + transaction.id, e);
196
- errors.push(e);
197
208
  }
209
+ } catch (e) {
210
+ // The payouts of the fees stored so far still need their derived lines, but failing to
211
+ // update them may not hide what broke the walk
212
+ await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]).catch(console.error);
213
+ throw e;
198
214
  }
199
215
 
200
216
  await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
@@ -210,7 +226,16 @@ export class StripeSettlementSync {
210
226
  /**
211
227
  * Stores the SettlementCharge for the connected account (costs) and ApplicationFee for the platform account (revenue), related to an application fee in Stripe.
212
228
  */
213
- async #handleApplicationFee(transaction: Stripe.BalanceTransaction, options: { settlementId?: string } = {}): Promise<{ fees: ApplicationFee[]; charges: SettlementCharge[] }> {
229
+ async #handleApplicationFee(transaction: Stripe.BalanceTransaction, options: {
230
+ settlementId?: string;
231
+
232
+ /**
233
+ * Collects the balance items of the invoiced fees stored here, so their fee payments can
234
+ * be updated afterwards. Filled while storing, not from the return value: a fee that is
235
+ * stored before a later one throws still needs its derived line.
236
+ */
237
+ invoicedFeeBalanceItemIds?: Set<string>;
238
+ } = {}): Promise<{ fees: ApplicationFee[]; charges: SettlementCharge[] }> {
214
239
  const fee = transaction.source as Stripe.ApplicationFee;
215
240
  const payingAccountId = typeof fee.account === 'string' ? fee.account : fee.account.id;
216
241
 
@@ -273,7 +298,7 @@ export class StripeSettlementSync {
273
298
  charges.push(charge);
274
299
  }
275
300
 
276
- fees.push(await ApplicationFeeService.upsertFee({
301
+ const storedFee = await ApplicationFeeService.upsertFee({
277
302
  externalId: fee.id,
278
303
  type: feeType,
279
304
  amount,
@@ -286,7 +311,14 @@ export class StripeSettlementSync {
286
311
  settlementChargeId: charge?.id ?? undefined,
287
312
  settlementId: options.settlementId,
288
313
  occurredAt,
289
- }));
314
+ });
315
+ fees.push(storedFee);
316
+
317
+ // An invoiced fee is explained by the derived line of its fee payment instead of by
318
+ // itself, so the payouts it sits in have to rebuild those lines
319
+ if (storedFee.balanceItemId && storedFee.settlementId) {
320
+ options.invoicedFeeBalanceItemIds?.add(storedFee.balanceItemId);
321
+ }
290
322
  }
291
323
 
292
324
  return { fees, charges };
@@ -370,7 +402,7 @@ export class StripeSettlementSync {
370
402
  return this.#organizationId;
371
403
  }
372
404
 
373
- async syncPayout(payout: Stripe.Payout, { force = false }: { force?: boolean } = {}): Promise<{ settlement: Settlement; skipped: boolean }> {
405
+ async syncPayout(payout: Stripe.Payout, { force = false, abort }: { force?: boolean; abort?: AbortSignal } = {}): Promise<{ settlement: Settlement; skipped: boolean }> {
374
406
  // All amounts are stored in the same unit: a payout in another currency would be stored as
375
407
  // a plausible but wrong number
376
408
  if (payout.currency && payout.currency.toUpperCase() !== 'EUR') {
@@ -380,7 +412,7 @@ export class StripeSettlementSync {
380
412
  });
381
413
  }
382
414
 
383
- return await SettlementService.lock(PaymentProvider.Stripe, payout.id, async () => {
415
+ return await SettlementService.lock(PaymentProvider.Stripe, payout.id, async (signal) => {
384
416
  const settlement = await SettlementService.upsertSettlement({
385
417
  provider: PaymentProvider.Stripe,
386
418
  externalId: payout.id,
@@ -418,17 +450,23 @@ export class StripeSettlementSync {
418
450
  }
419
451
 
420
452
  try {
421
- await this.#walkPayout(payout, settlement);
453
+ await this.#walkPayout(payout, settlement, signal);
422
454
  } catch (e) {
423
- await SettlementService.markSyncFailed(settlement);
455
+ // A walk that was interrupted stored only part of the payout: it has to be walked
456
+ // again, but it didn't fail
457
+ if (signal.isAborted) {
458
+ await SettlementService.markSyncInterrupted(settlement);
459
+ } else {
460
+ await SettlementService.markSyncFailed(settlement);
461
+ }
424
462
  throw e;
425
463
  }
426
464
 
427
465
  return { settlement, skipped: false };
428
- });
466
+ }, { abort });
429
467
  }
430
468
 
431
- async #walkPayout(payout: Stripe.Payout, settlement: Settlement) {
469
+ async #walkPayout(payout: Stripe.Payout, settlement: Settlement, abort: AbortSignal) {
432
470
  const reported = new ReportedRows();
433
471
  let transactionCount = 0;
434
472
 
@@ -436,31 +474,45 @@ export class StripeSettlementSync {
436
474
  // payments' derived lines must follow
437
475
  const invoicedFeeBalanceItemIds = new Set<string>();
438
476
 
439
- for await (const transaction of this.stripe.balanceTransactions.list({
440
- payout: payout.id,
441
- limit: 100,
442
- expand: this.stripeAccount
443
- ? ['data.source', 'data.source.application_fee', 'data.source.application_fee.originating_transaction', 'data.source.charge']
444
- : ['data.source', 'data.source.originating_transaction', 'data.source.charge'],
445
- })) {
446
- transactionCount += 1;
447
- await this.#handleTransaction(transaction, settlement, reported, invoicedFeeBalanceItemIds);
448
- }
477
+ try {
478
+ for await (const transaction of this.stripe.balanceTransactions.list({
479
+ payout: payout.id,
480
+ limit: 100,
481
+ expand: this.stripeAccount
482
+ ? ['data.source', 'data.source.application_fee', 'data.source.application_fee.originating_transaction', 'data.source.charge']
483
+ : ['data.source', 'data.source.originating_transaction', 'data.source.charge'],
484
+ })) {
485
+ // Between two transactions is a safe point to stop: only a complete walk sweeps
486
+ // and marks the settlement synced, so an interrupted one is re-walked from scratch
487
+ abort.throwIfAborted();
488
+
489
+ transactionCount += 1;
490
+ await this.#handleTransaction(transaction, settlement, reported, invoicedFeeBalanceItemIds);
491
+ }
449
492
 
450
- // Stripe reported nothing for money that did move: storing that as a complete sync would
451
- // silently hide the whole payout
452
- if (transactionCount === 0 && settlement.amount !== 0) {
453
- throw new SimpleError({
454
- code: 'empty_payout',
455
- message: 'Payout ' + payout.id + ' of ' + settlement.amount + ' has no balance transactions',
456
- });
457
- }
493
+ // Stripe reported nothing for money that did move: storing that as a complete sync
494
+ // would silently hide the whole payout
495
+ if (transactionCount === 0 && settlement.amount !== 0) {
496
+ throw new SimpleError({
497
+ code: 'empty_payout',
498
+ message: 'Payout ' + payout.id + ' of ' + settlement.amount + ' has no balance transactions',
499
+ });
500
+ }
458
501
 
459
- const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
460
- for (const fee of unlinkedFees) {
461
- if (fee.balanceItemId) {
462
- invoicedFeeBalanceItemIds.add(fee.balanceItemId);
502
+ // Only a complete walk may sweep: it can't tell a row that moved away from one this
503
+ // walk never reached
504
+ const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
505
+ for (const fee of unlinkedFees) {
506
+ if (fee.balanceItemId) {
507
+ invoicedFeeBalanceItemIds.add(fee.balanceItemId);
508
+ }
463
509
  }
510
+ } catch (e) {
511
+ // The fee payments still follow the fees this walk linked before it broke (a fee may
512
+ // never sit in a payout that has no line for it), but failing to update them may not
513
+ // hide what broke the walk
514
+ await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]).catch(console.error);
515
+ throw e;
464
516
  }
465
517
 
466
518
  await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
@@ -528,15 +580,8 @@ export class StripeSettlementSync {
528
580
 
529
581
  case 'application_fee': {
530
582
  // The fee rows, now linked to the platform payout that contains them
531
- const { fees } = await this.#handleApplicationFee(transaction, { settlementId: settlement.id });
583
+ const { fees } = await this.#handleApplicationFee(transaction, { settlementId: settlement.id, invoicedFeeBalanceItemIds });
532
584
  reported.applicationFees(fees);
533
- for (const fee of fees) {
534
- if (fee.balanceItemId) {
535
- // Make sure we update the AccountDeduction payments and settlements that are connected to this
536
- // application fee.
537
- invoicedFeeBalanceItemIds.add(fee.balanceItemId);
538
- }
539
- }
540
585
  await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: null });
541
586
  return;
542
587
  }
@@ -1,7 +1,9 @@
1
1
  import type { Organization } from '@stamhoofd/models';
2
2
  import { OrganizationFactory } from '@stamhoofd/models';
3
3
  import { Settlement } from '@stamhoofd/models/models/Settlement.js';
4
+ import { AbortSignal } from '@stamhoofd/queues';
4
5
  import { PaymentProvider } from '@stamhoofd/structures';
6
+ import { STExpect } from '@stamhoofd/test-utils';
5
7
  import { v4 as uuidv4 } from 'uuid';
6
8
 
7
9
  import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
@@ -63,4 +65,20 @@ describe('Helper.StripeSettlementSyncRunner', () => {
63
65
  const fresh = await Settlement.getByID(settlement.id);
64
66
  expect(fresh!.syncFailureCount).toBe(5);
65
67
  });
68
+
69
+ test('an interrupted retry does not count towards the cap', async () => {
70
+ const settlement = await createUnsyncedSettlement(1);
71
+
72
+ const abort = new AbortSignal();
73
+ abort.abort();
74
+
75
+ const runner = new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
76
+ await expect(runner.retryUnsyncedSettlements({ windowStart, abort })).rejects.toThrow(
77
+ STExpect.simpleError({ code: 'queue-aborted' }),
78
+ );
79
+
80
+ // A restart is not an attempt: the payout is still waiting for a real one
81
+ const fresh = await Settlement.getByID(settlement.id);
82
+ expect(fresh!.syncFailureCount).toBe(1);
83
+ });
66
84
  });
@@ -1,5 +1,6 @@
1
1
  import { StripeAccount } from '@stamhoofd/models';
2
2
  import { Settlement } from '@stamhoofd/models/models/Settlement.js';
3
+ import { AbortSignal } from '@stamhoofd/queues';
3
4
  import { PaymentProvider } from '@stamhoofd/structures';
4
5
  import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
5
6
 
@@ -44,12 +45,14 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
44
45
  * Walks the window month by month: fees first, then our platform payouts, then the connected
45
46
  * accounts.
46
47
  */
47
- async run({ start, end, summary, onProgress }: ProviderSyncRunOptions): Promise<void> {
48
+ async run({ start, end, summary, onProgress, abort }: ProviderSyncRunOptions): Promise<void> {
48
49
  const platformSync = new StripeSettlementSync({ secretKey: this.#secretKey });
49
50
 
50
51
  let currentMonth = new Date(start.getFullYear(), start.getMonth(), 1);
51
52
 
52
53
  while (true) {
54
+ abort.throwIfAborted();
55
+
53
56
  const { start: monthStartUnix, end: monthEndUnix } = SettlementService.getMonthUnixStartEnd(currentMonth);
54
57
  if (monthStartUnix * 1000 > end.getTime()) {
55
58
  break;
@@ -59,21 +62,23 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
59
62
  const windowEnd = new Date(Math.min(monthEndUnix * 1000, end.getTime()));
60
63
 
61
64
  try {
62
- await platformSync.syncFees({ start: windowStart, end: windowEnd });
65
+ await platformSync.syncFees({ start: windowStart, end: windowEnd, abort });
63
66
  summary.feeMonths += 1;
64
67
  } catch (e) {
68
+ abort.throwIfAborted();
69
+
65
70
  // syncFees already emailed nothing: it throws an aggregate, the month is retried by
66
71
  // the next run and the month is not invoiced until it completes
67
72
  console.error('Fee sync failed for month ' + currentMonth.toISOString(), e);
68
73
  summary.failedFeeMonths += 1;
69
74
  }
70
75
 
71
- const platformResult = await platformSync.syncPayouts({ start: windowStart, end: windowEnd, force: this.#force });
76
+ const platformResult = await platformSync.syncPayouts({ start: windowStart, end: windowEnd, force: this.#force, abort });
72
77
  summary.synced += platformResult.synced;
73
78
  summary.skipped += platformResult.skipped;
74
79
  summary.failed += platformResult.failed;
75
80
 
76
- const connectedResult = await this.syncConnectedPayouts({ start: windowStart, end: windowEnd });
81
+ const connectedResult = await this.syncConnectedPayouts({ start: windowStart, end: windowEnd, abort });
77
82
  summary.synced += connectedResult.synced;
78
83
  summary.skipped += connectedResult.skipped;
79
84
  summary.failed += connectedResult.failed;
@@ -83,26 +88,32 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
83
88
  }
84
89
 
85
90
  if (this.#retryUnsynced) {
86
- await this.retryUnsyncedSettlements({ windowStart: start });
91
+ await this.retryUnsyncedSettlements({ windowStart: start, abort });
87
92
  }
88
93
  }
89
94
 
90
95
  /**
91
96
  * Walks the payouts of every active connected account.
92
97
  */
93
- async syncConnectedPayouts({ start, end }: { start: Date; end?: Date }): Promise<{ synced: number; skipped: number; failed: number }> {
98
+ async syncConnectedPayouts({ start, end, abort = new AbortSignal() }: { start: Date; end?: Date; abort?: AbortSignal }): Promise<{ synced: number; skipped: number; failed: number }> {
94
99
  const totals = { synced: 0, skipped: 0, failed: 0 };
95
100
 
96
101
  const accounts = await StripeAccount.select().where('status', 'active').fetch();
97
102
 
98
103
  for (const account of accounts) {
104
+ abort.throwIfAborted();
105
+
99
106
  try {
100
107
  const sync = new StripeSettlementSync({ secretKey: this.#secretKey, stripeAccount: account });
101
- const result = await sync.syncPayouts({ start, end, force: this.#force });
108
+ const result = await sync.syncPayouts({ start, end, force: this.#force, abort });
102
109
  totals.synced += result.synced;
103
110
  totals.skipped += result.skipped;
104
111
  totals.failed += result.failed;
105
112
  } catch (e) {
113
+ // An interrupted account is not an account with a problem: it may not be marked
114
+ // inaccessible, counted or reported
115
+ abort.throwIfAborted();
116
+
106
117
  if (e !== null && typeof e === 'object' && 'type' in e && e.type === 'StripePermissionError' && e.message.includes(account.accountId) && e.message.includes('does not have access to account')) {
107
118
  // Stripe account no longer in active use
108
119
  console.error(e, 'marking stripe account', account.id, account.accountId, 'as inaccessible because we do not seem to have access to it any longer');
@@ -127,7 +138,7 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
127
138
  * after MAXIMUM_FAILURE_COUNT attempts and waits in the problem report instead. Always forced,
128
139
  * regardless of the force configuration.
129
140
  */
130
- async retryUnsyncedSettlements({ windowStart }: { windowStart: Date }): Promise<void> {
141
+ async retryUnsyncedSettlements({ windowStart, abort = new AbortSignal() }: { windowStart: Date; abort?: AbortSignal }): Promise<void> {
131
142
  const settlements = await Settlement.select()
132
143
  .where('provider', PaymentProvider.Stripe)
133
144
  .where('syncedAt', null)
@@ -140,12 +151,18 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
140
151
  .fetch();
141
152
 
142
153
  for (const settlement of settlements) {
154
+ abort.throwIfAborted();
155
+
143
156
  const failureCountBefore = settlement.syncFailureCount;
144
157
  try {
145
158
  const stripeAccount = settlement.stripeAccountId ? await StripeAccount.getByID(settlement.stripeAccountId) : null;
146
159
  const sync = new StripeSettlementSync({ secretKey: this.#secretKey, stripeAccount: stripeAccount ?? null });
147
- await sync.syncPayoutById(settlement.externalId, { force: true });
160
+ await sync.syncPayoutById(settlement.externalId, { force: true, abort });
148
161
  } catch (e) {
162
+ // An interrupted retry may not count towards the cap: the payout is still waiting
163
+ // for a real attempt
164
+ abort.throwIfAborted();
165
+
149
166
  console.error('Retry of settlement ' + settlement.externalId + ' failed', e);
150
167
 
151
168
  // Errors before the walk (e.g. the payout can't be retrieved anymore) don't pass
@@ -0,0 +1,48 @@
1
+ import { sleep } from '@stamhoofd/utility';
2
+ import { vi } from 'vitest';
3
+
4
+ import { waitUntilDeadline } from './waitUntilDeadline.js';
5
+
6
+ describe('Helper.waitUntilDeadline', () => {
7
+ let errors: string[];
8
+
9
+ beforeEach(() => {
10
+ errors = [];
11
+ vi.spyOn(console, 'error').mockImplementation((message: unknown) => {
12
+ errors.push(typeof message === 'string' ? message : String(message));
13
+ });
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ const deadlineIn = (ms: number) => new Date(Date.now() + ms);
21
+
22
+ test('returns as soon as the work finishes', async () => {
23
+ const started = Date.now();
24
+ await waitUntilDeadline(sleep(10), { deadline: deadlineIn(60_000), description: 'work' });
25
+
26
+ expect(Date.now() - started).toBeLessThan(1000);
27
+ expect(errors).toHaveLength(0);
28
+ });
29
+
30
+ test('gives up on work that never finishes', async () => {
31
+ await waitUntilDeadline(new Promise(() => {}), { deadline: deadlineIn(20), description: 'work' });
32
+
33
+ expect(errors).toContain('Gave up waiting for work to finish');
34
+ });
35
+
36
+ test('a deadline that already passed does not wait', async () => {
37
+ await waitUntilDeadline(sleep(60_000), { deadline: deadlineIn(-1000), description: 'work' });
38
+
39
+ expect(errors).toContain('Gave up waiting for work to finish');
40
+ });
41
+
42
+ test('a failure in the work is logged instead of thrown', async () => {
43
+ await waitUntilDeadline(Promise.reject(new Error('Broken')), { deadline: deadlineIn(60_000), description: 'work' });
44
+
45
+ expect(errors).toContain('Failed to wait for work:');
46
+ expect(errors).not.toContain('Gave up waiting for work to finish');
47
+ });
48
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Waits for `promise`, but gives up at `deadline`: a shutdown may not hang on work that never
3
+ * checks its abort signal, or that is stuck on a network call. What is left behind runs again after
4
+ * the restart.
5
+ *
6
+ * A failure in the awaited work is logged instead of thrown: it may not stop the shutdown either.
7
+ */
8
+ export async function waitUntilDeadline(promise: Promise<unknown>, { deadline, description }: { deadline: Date; description: string }): Promise<void> {
9
+ let timer: NodeJS.Timeout | undefined;
10
+
11
+ const timedOut = await Promise.race([
12
+ promise.then(() => false).catch((error) => {
13
+ console.error('Failed to wait for ' + description + ':');
14
+ console.error(error);
15
+ return false;
16
+ }),
17
+ new Promise<boolean>((resolve) => {
18
+ timer = setTimeout(() => resolve(true), Math.max(deadline.getTime() - Date.now(), 0));
19
+ }),
20
+ ]);
21
+
22
+ // Or the timer keeps the process alive until the deadline it no longer needs
23
+ clearTimeout(timer);
24
+
25
+ if (timedOut) {
26
+ console.error('Gave up waiting for ' + description + ' to finish');
27
+ }
28
+ }
@@ -142,14 +142,32 @@ export class ApplicationFeeService {
142
142
  periodStart: Date;
143
143
  }): Promise<Payment[]> {
144
144
  const reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + Formatter.dateIso(periodStart);
145
- const key = organizationId + ':' + payingStripeAccountId + ':' + reference;
145
+ const key = organizationId + ':' + payingOrganizationId + ':' + payingStripeAccountId + ':' + reference;
146
146
 
147
147
  const cached = this.legacyFeePayments.get(key);
148
148
  if (cached && cached.length > 0) {
149
149
  return cached;
150
150
  }
151
151
 
152
- const payments = await Payment.select()
152
+ // Prefer matching stripeAccountId
153
+ let payments = await Payment.select()
154
+ .where('organizationId', organizationId)
155
+ .where('payingOrganizationId', payingOrganizationId)
156
+ .where(
157
+ SQL.where('stripeAccountId', payingStripeAccountId),
158
+ )
159
+ .where('reference', reference)
160
+ .where('method', PaymentMethod.AccountDeductions)
161
+ .where('provider', PaymentProvider.Stripe)
162
+ .where('status', PaymentStatus.Succeeded)
163
+ .fetch();
164
+
165
+ if (payments.length) {
166
+ this.legacyFeePayments.set(key, payments);
167
+ return payments;
168
+ }
169
+
170
+ payments = await Payment.select()
153
171
  .where('organizationId', organizationId)
154
172
  .where('payingOrganizationId', payingOrganizationId)
155
173
  .where(
@@ -11,6 +11,7 @@ import { OrderService } from './OrderService.js';
11
11
  import { PaymentReallocationService } from './PaymentReallocationService.js';
12
12
  import { RegistrationService } from './RegistrationService.js';
13
13
  import { STPackageService } from './STPackageService.js';
14
+ import { WebshopCrowdfundingService } from './WebshopCrowdfundingService.js';
14
15
 
15
16
  const memberUpdateQueue = new GroupedThrottledQueue(async (organizationId: string, memberIds: string[]) => {
16
17
  await CachedBalance.updateForMembers(organizationId, memberIds);
@@ -183,6 +184,10 @@ export class BalanceItemService {
183
184
  if (item.registrationId) {
184
185
  registrationUpdateQueue.addItem(item.organizationId, item.registrationId);
185
186
  }
187
+
188
+ if (item.orderId) {
189
+ WebshopCrowdfundingService.scheduleUpdateForOrder(item.orderId);
190
+ }
186
191
  }
187
192
  }
188
193
 
@@ -6,7 +6,7 @@ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
6
6
  import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
7
7
  import { Settlement } from '@stamhoofd/models/models/Settlement.js';
8
8
  import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
9
- import { QueueHandler } from '@stamhoofd/queues';
9
+ import { AbortSignal, QueueHandler } from '@stamhoofd/queues';
10
10
  import { SQL } from '@stamhoofd/sql';
11
11
  import type { PaymentProvider } from '@stamhoofd/structures';
12
12
  import { PaymentMethod, SettlementReference } from '@stamhoofd/structures';
@@ -114,9 +114,20 @@ export class SettlementService {
114
114
  * Serializes syncs of the same settlement, so cron and manual backfill can't race. In-process
115
115
  * only: across multiple API instances the tables' unique keys are the real guard, so a
116
116
  * concurrent sync surfaces as a duplicate-key error and is retryable.
117
+ *
118
+ * Interrupting a walk is opt-in: the caller's signal governs the whole sync (so it stops as
119
+ * one, and every caller in it recognizes the abort), and a caller without one walks to the end.
117
120
  */
118
- static lock<T>(provider: PaymentProvider, externalId: string, handler: () => Promise<T>): Promise<T> {
119
- return QueueHandler.schedule('settlement-sync-' + provider + '-' + externalId, handler);
121
+ static lock<T>(provider: PaymentProvider, externalId: string, handler: (abort: AbortSignal) => Promise<T>, { abort }: { abort?: AbortSignal } = {}): Promise<T> {
122
+ return QueueHandler.schedule('settlement-sync-' + provider + '-' + externalId, async (o) => {
123
+ const signal = abort ?? o.abort;
124
+
125
+ // Waiting behind another settlement may have taken a while: don't start a walk that is
126
+ // interrupted at its first step anyway
127
+ signal.throwIfAborted();
128
+
129
+ return await handler(signal);
130
+ });
120
131
  }
121
132
 
122
133
  /**
@@ -575,6 +586,18 @@ export class SettlementService {
575
586
  }
576
587
  }
577
588
 
589
+ /**
590
+ * A walk that was interrupted halfway (a restart aborted it) stored only part of what the
591
+ * provider reports, so the settlement may not keep claiming it is synced: the next run walks it
592
+ * again. Unlike a failed sync it doesn't count towards the retry cap — nothing is wrong with
593
+ * this settlement.
594
+ */
595
+ static async markSyncInterrupted(settlement: Settlement): Promise<Settlement> {
596
+ settlement.syncedAt = null;
597
+ await settlement.save();
598
+ return settlement;
599
+ }
600
+
578
601
  /**
579
602
  * A failed sync leaves syncedAt NULL: that is the whole error queue.
580
603
  */