@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
@@ -1,9 +1,10 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { Payment, StripeAccount } from '@stamhoofd/models';
2
+ import { Organization, Payment, StripeAccount } from '@stamhoofd/models';
3
3
  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';
@@ -17,6 +18,27 @@ import { passthroughFetch } from './passthroughFetch.js';
17
18
  import { getPaymentIdForStripeCharge } from './getPaymentIdForStripeCharge.js';
18
19
  import { WebmasterReport } from './WebmasterReport.js';
19
20
 
21
+ /**
22
+ * Who paid an application fee: normally the organization of the Stripe account it was deducted
23
+ * from, and otherwise the organization our own charge metadata names.
24
+ */
25
+ type ApplicationFeePayer = {
26
+ organizationId: string;
27
+
28
+ /**
29
+ * NULL when we no longer have the stripe_accounts row the fee was deducted from.
30
+ */
31
+ stripeAccountId: string | null;
32
+
33
+ /**
34
+ * Whether our records of this payer are still complete. Only false when its stripe_accounts
35
+ * row is gone, which means its organization was deleted: what can't be resolved anymore is
36
+ * then the consequence of that deletion, not a problem to repair. A deleted account keeps its
37
+ * row, and its organization keeps its payments, so those stay strictly checked.
38
+ */
39
+ intact: boolean;
40
+ };
41
+
20
42
  /**
21
43
  * Walks paid payouts and stores every balance transaction in them: payments become
22
44
  * payment_settlements rows, everything else becomes settlement_charges rows. One walker for both
@@ -37,6 +59,38 @@ export class StripeSettlementSync {
37
59
  */
38
60
  private fetchedCharges = new Map<string, Stripe.Charge>();
39
61
 
62
+ /**
63
+ * Stripe accounts already reported as unattributable, so a month of their fees is one problem
64
+ * instead of thousands.
65
+ */
66
+ static #reportedUnattributedAccounts = new Set<string>();
67
+
68
+ /**
69
+ * For tests only.
70
+ */
71
+ static resetWarnings() {
72
+ this.#reportedUnattributedAccounts.clear();
73
+ }
74
+
75
+ /**
76
+ * A fee we can't fully attribute is stored anyway (it is our income), so it would otherwise
77
+ * only exist as a number nobody looks at: the invoicer skips it, and no payout of the payer
78
+ * links it. Reported once per account, so someone decides whether to repair or write it off.
79
+ */
80
+ static reportUnattributedFee(payingAccountId: string, payer: ApplicationFeePayer | null) {
81
+ if (this.#reportedUnattributedAccounts.has(payingAccountId)) {
82
+ return;
83
+ }
84
+ this.#reportedUnattributedAccounts.add(payingAccountId);
85
+
86
+ WebmasterReport.report(
87
+ 'Applicatiekosten van Stripe account ' + payingAccountId + ' worden niet aangerekend',
88
+ payer
89
+ ? 'Dat account staat niet meer in onze database. De kosten zijn wel opgeslagen op vereniging ' + payer.organizationId + ', maar worden niet automatisch gefactureerd.'
90
+ : 'Dat account en de vereniging erachter staan niet meer in onze database. De kosten zijn opgeslagen als niet-aanrekenbare inkomsten.',
91
+ );
92
+ }
93
+
40
94
  constructor({ secretKey, stripeAccount }: { secretKey: string; stripeAccount?: StripeAccount | null }) {
41
95
  this.stripeAccount = stripeAccount ?? null;
42
96
 
@@ -58,7 +112,7 @@ export class StripeSettlementSync {
58
112
  * Sync all paid payouts that arrived in the window. A failing payout is marked, reported and
59
113
  * skipped so the other payouts still sync; the summary tells the caller how bad it was.
60
114
  */
61
- 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 }> {
62
116
  const result = { synced: 0, skipped: 0, failed: 0 };
63
117
 
64
118
  // Fail once up front when no organization can own these payouts, instead of once per payout
@@ -74,14 +128,20 @@ export class StripeSettlementSync {
74
128
  },
75
129
  limit: 100,
76
130
  })) {
131
+ abort?.throwIfAborted();
132
+
77
133
  try {
78
- const { skipped } = await this.syncPayout(payout, { force });
134
+ const { skipped } = await this.syncPayout(payout, { force, abort });
79
135
  if (skipped) {
80
136
  result.skipped += 1;
81
137
  } else {
82
138
  result.synced += 1;
83
139
  }
84
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
+
85
145
  console.error('Failed to sync Stripe payout ' + payout.id, e);
86
146
  result.failed += 1;
87
147
 
@@ -97,7 +157,7 @@ export class StripeSettlementSync {
97
157
  /**
98
158
  * Re-sync one payout by its id, e.g. to retry a settlement that stayed unsynced.
99
159
  */
100
- 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 }> {
101
161
  const payout = await this.stripe.payouts.retrieve(externalId);
102
162
  return await this.syncPayout(payout, options);
103
163
  }
@@ -108,7 +168,7 @@ export class StripeSettlementSync {
108
168
  * in the settlement links later. A broken fee doesn't block storing the others, but the walk
109
169
  * still fails loudly at the end: a month is only invoiced after a run without errors.
110
170
  */
111
- async syncFees({ start, end }: { start: Date; end: Date }) {
171
+ async syncFees({ start, end, abort }: { start: Date; end: Date; abort?: AbortSignal }) {
112
172
  if (this.stripeAccount) {
113
173
  throw new SimpleError({
114
174
  code: 'invalid_scope',
@@ -122,26 +182,35 @@ export class StripeSettlementSync {
122
182
  // billed). When it is already paid out, its payout needs the derived line for it
123
183
  const invoicedFeeBalanceItemIds = new Set<string>();
124
184
 
125
- for await (const transaction of this.stripe.balanceTransactions.list({
126
- type: 'application_fee',
127
- created: {
128
- gte: Math.floor(start.getTime() / 1000),
129
- lte: Math.floor(end.getTime() / 1000),
130
- },
131
- expand: ['data.source', 'data.source.originating_transaction'],
132
- limit: 100,
133
- })) {
134
- try {
135
- const { fees } = await this.#handleApplicationFee(transaction);
136
- for (const fee of fees) {
137
- if (fee.balanceItemId && fee.settlementId) {
138
- invoicedFeeBalanceItemIds.add(fee.balanceItemId);
139
- }
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);
140
207
  }
141
- } catch (e) {
142
- console.error('Failed to sync application fee transaction ' + transaction.id, e);
143
- errors.push(e);
144
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;
145
214
  }
146
215
 
147
216
  await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
@@ -155,25 +224,21 @@ export class StripeSettlementSync {
155
224
  }
156
225
 
157
226
  /**
158
- * Stores one application_fee balance transaction: per non-zero part the payer's negative
159
- * deduction charge (its settlement link belongs to the payer's own payout walk) and the
160
- * application fee row. The platform payout walk passes `settlementId` to link the fee rows to
161
- * the payout that contains them. Throws instead of writing a guessed row: missing serviceFee
162
- * metadata, an unknown Stripe account or an unresolvable payment all mean someone has to look
163
- * at it first.
227
+ * Stores the SettlementCharge for the connected account (costs) and ApplicationFee for the platform account (revenue), related to an application fee in Stripe.
164
228
  */
165
- 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[] }> {
166
239
  const fee = transaction.source as Stripe.ApplicationFee;
167
240
  const payingAccountId = typeof fee.account === 'string' ? fee.account : fee.account.id;
168
241
 
169
- const payingStripeAccount = await StripeAccount.select().where('accountId', payingAccountId).first(false);
170
- if (!payingStripeAccount) {
171
- throw new SimpleError({
172
- code: 'stripe_account_not_found',
173
- message: 'No Stripe account found for ' + payingAccountId,
174
- });
175
- }
176
-
177
242
  const originatingTransaction = fee.originating_transaction;
178
243
  if (!originatingTransaction || typeof originatingTransaction === 'string') {
179
244
  throw new SimpleError({
@@ -181,28 +246,18 @@ export class StripeSettlementSync {
181
246
  message: 'Application fee ' + fee.id + ' has no expanded originating transaction',
182
247
  });
183
248
  }
184
-
249
+ const originatingCharge = originatingTransaction as Stripe.Charge;
185
250
  const details = ApplicationFeeDetails.fromStripe(transaction);
186
-
187
- const paymentId = await getPaymentIdForStripeCharge(originatingTransaction as Stripe.Charge, {
251
+ const resolvedPaymentId = await getPaymentIdForStripeCharge(originatingCharge, {
188
252
  stripePlatform: this.stripePlatform,
189
253
  });
254
+ const resolvedPayment = (resolvedPaymentId ? await Payment.getByID(resolvedPaymentId) : null) ?? null;
190
255
 
191
- if (!paymentId) {
192
- throw new SimpleError({
193
- code: 'payment_not_found',
194
- message: 'No payment found for application fee ' + fee.id,
195
- });
196
- }
256
+ const payer = await this.#resolveApplicationFeePayer(payingAccountId, originatingCharge, resolvedPayment);
257
+ const paymentId = this.#getApplicationFeePaymentId(fee, payer, { paymentId: resolvedPaymentId, payment: resolvedPayment });
197
258
 
198
- // Charge metadata is writable by the connected account's owner: the fee is deducted from
199
- // this account, so it can only be about a payment of its own organization
200
- const payment = await Payment.getByID(paymentId);
201
- if (!payment || payment.organizationId !== payingStripeAccount.organizationId) {
202
- throw new SimpleError({
203
- code: 'payment_scope_mismatch',
204
- message: 'Payment ' + paymentId + ' of application fee ' + fee.id + ' does not belong to organization ' + payingStripeAccount.organizationId,
205
- });
259
+ if (!payer?.stripeAccountId) {
260
+ StripeSettlementSync.reportUnattributedFee(payingAccountId, payer);
206
261
  }
207
262
 
208
263
  const occurredAt = new Date(transaction.created * 1000);
@@ -222,39 +277,122 @@ export class StripeSettlementSync {
222
277
  continue;
223
278
  }
224
279
 
225
- const charge = await SettlementService.upsertCharge({
226
- type: chargeType,
227
- externalId: fee.id + ':' + chargeType,
228
- amount: -amount,
229
- applicationFeeId: fee.id,
230
- paymentId,
231
-
232
- // The charge sits in the paying organization's payout
233
- organizationId: payingStripeAccount.organizationId,
234
- stripeAccountId: payingStripeAccount.id,
235
- occurredAt,
236
-
237
- // settlementId (settlement of the paying organization where the costs are deducted): still unknown, will be filled when looping the payouts of the paying organization
238
- });
239
- charges.push(charge);
280
+ const charge = payer
281
+ ? await SettlementService.upsertCharge({
282
+ type: chargeType,
283
+ externalId: fee.id + ':' + chargeType,
284
+ amount: -amount,
285
+ applicationFeeId: fee.id,
286
+
287
+ // Unresolvable stays undefined: a re-sync may not clear earlier stored links
288
+ paymentId: paymentId ?? undefined,
289
+ organizationId: payer.organizationId ?? undefined,
290
+ stripeAccountId: payer.stripeAccountId ?? undefined,
291
+ occurredAt,
292
+
293
+ // settlementId (settlement of the paying organization where the costs are deducted): still unknown, will be filled when looping the payouts of the paying organization
294
+ })
295
+ : null;
296
+
297
+ if (charge) {
298
+ charges.push(charge);
299
+ }
240
300
 
241
- fees.push(await ApplicationFeeService.upsertFee({
301
+ const storedFee = await ApplicationFeeService.upsertFee({
242
302
  externalId: fee.id,
243
303
  type: feeType,
244
304
  amount,
245
305
  organizationId: receivingOrganizationId,
246
- payingOrganizationId: payingStripeAccount.organizationId,
247
- payingStripeAccountId: payingStripeAccount.id,
248
- payingPaymentId: paymentId,
249
- settlementChargeId: charge.id,
306
+
307
+ // Unresolvable stays undefined: a re-sync may not clear earlier stored links
308
+ payingOrganizationId: payer?.organizationId ?? undefined,
309
+ payingStripeAccountId: payer?.stripeAccountId ?? undefined,
310
+ payingPaymentId: paymentId ?? undefined,
311
+ settlementChargeId: charge?.id ?? undefined,
312
+ settlementId: options.settlementId,
250
313
  occurredAt,
251
- ...(options.settlementId !== undefined ? { settlementId: options.settlementId } : {}),
252
- }));
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
+ }
253
322
  }
254
323
 
255
324
  return { fees, charges };
256
325
  }
257
326
 
327
+ /**
328
+ * The organization an application fee was deducted from. Its Stripe account is the first
329
+ * source. A deleted organization takes its stripe_accounts row with it, and accounts deleted
330
+ * before we started keeping deleted ones are gone too; what is left of the payment then still
331
+ * names the organization: our own payment row first, and otherwise the metadata we wrote on the
332
+ * charge. Only a destination charge has an originating transaction, and that charge sits on our
333
+ * platform account, so the connected account could not have changed either.
334
+ *
335
+ * NULL when even that organization no longer exists: the fee is then income without a payer.
336
+ */
337
+ async #resolveApplicationFeePayer(payingAccountId: string, originatingCharge: Stripe.Charge, payment: Payment | null): Promise<ApplicationFeePayer | null> {
338
+ const stripeAccount = await StripeAccount.select().where('accountId', payingAccountId).first(false);
339
+ if (stripeAccount) {
340
+ return {
341
+ organizationId: stripeAccount.organizationId,
342
+ stripeAccountId: stripeAccount.id,
343
+ intact: true,
344
+ };
345
+ }
346
+
347
+ if (payment?.organizationId) {
348
+ return { organizationId: payment.organizationId, stripeAccountId: null, intact: false };
349
+ }
350
+
351
+ const organizationId = originatingCharge.metadata?.organization;
352
+ if (!organizationId || !await Organization.getByID(organizationId)) {
353
+ return null;
354
+ }
355
+
356
+ return { organizationId, stripeAccountId: null, intact: false };
357
+ }
358
+
359
+ /**
360
+ * The payer's payment an application fee was charged on. Charge metadata is writable by the
361
+ * connected account's owner: the fee is deducted from this account, so it can only be about a
362
+ * payment of its own organization.
363
+ *
364
+ * A payer whose records are no longer intact keeps whatever still resolves: its payments may
365
+ * have been deleted with its account, so a missing one is expected instead of something to
366
+ * repair.
367
+ */
368
+ #getApplicationFeePaymentId(fee: Stripe.ApplicationFee, payer: ApplicationFeePayer | null, { paymentId, payment }: { paymentId: string | null; payment: Payment | null }): string | null {
369
+ if (!payer) {
370
+ return null;
371
+ }
372
+
373
+ if (!paymentId) {
374
+ if (!payer.intact) {
375
+ return null;
376
+ }
377
+ throw new SimpleError({
378
+ code: 'payment_not_found',
379
+ message: 'No payment found for application fee ' + fee.id,
380
+ });
381
+ }
382
+
383
+ if (!payment || payment.organizationId !== payer.organizationId) {
384
+ if (!payer.intact) {
385
+ return null;
386
+ }
387
+ throw new SimpleError({
388
+ code: 'payment_scope_mismatch',
389
+ message: 'Payment ' + paymentId + ' of application fee ' + fee.id + ' does not belong to organization ' + payer.organizationId,
390
+ });
391
+ }
392
+
393
+ return paymentId;
394
+ }
395
+
258
396
  /**
259
397
  * The organization that owns the walked account: the connected account's organization, or the
260
398
  * platform membership organization for our own platform account (resolved once per instance).
@@ -264,7 +402,7 @@ export class StripeSettlementSync {
264
402
  return this.#organizationId;
265
403
  }
266
404
 
267
- 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 }> {
268
406
  // All amounts are stored in the same unit: a payout in another currency would be stored as
269
407
  // a plausible but wrong number
270
408
  if (payout.currency && payout.currency.toUpperCase() !== 'EUR') {
@@ -274,7 +412,7 @@ export class StripeSettlementSync {
274
412
  });
275
413
  }
276
414
 
277
- return await SettlementService.lock(PaymentProvider.Stripe, payout.id, async () => {
415
+ return await SettlementService.lock(PaymentProvider.Stripe, payout.id, async (signal) => {
278
416
  const settlement = await SettlementService.upsertSettlement({
279
417
  provider: PaymentProvider.Stripe,
280
418
  externalId: payout.id,
@@ -312,17 +450,23 @@ export class StripeSettlementSync {
312
450
  }
313
451
 
314
452
  try {
315
- await this.#walkPayout(payout, settlement);
453
+ await this.#walkPayout(payout, settlement, signal);
316
454
  } catch (e) {
317
- 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
+ }
318
462
  throw e;
319
463
  }
320
464
 
321
465
  return { settlement, skipped: false };
322
- });
466
+ }, { abort });
323
467
  }
324
468
 
325
- async #walkPayout(payout: Stripe.Payout, settlement: Settlement) {
469
+ async #walkPayout(payout: Stripe.Payout, settlement: Settlement, abort: AbortSignal) {
326
470
  const reported = new ReportedRows();
327
471
  let transactionCount = 0;
328
472
 
@@ -330,31 +474,45 @@ export class StripeSettlementSync {
330
474
  // payments' derived lines must follow
331
475
  const invoicedFeeBalanceItemIds = new Set<string>();
332
476
 
333
- for await (const transaction of this.stripe.balanceTransactions.list({
334
- payout: payout.id,
335
- limit: 100,
336
- expand: this.stripeAccount
337
- ? ['data.source', 'data.source.application_fee', 'data.source.application_fee.originating_transaction', 'data.source.charge']
338
- : ['data.source', 'data.source.originating_transaction', 'data.source.charge'],
339
- })) {
340
- transactionCount += 1;
341
- await this.#handleTransaction(transaction, settlement, reported, invoicedFeeBalanceItemIds);
342
- }
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
+ }
343
492
 
344
- // Stripe reported nothing for money that did move: storing that as a complete sync would
345
- // silently hide the whole payout
346
- if (transactionCount === 0 && settlement.amount !== 0) {
347
- throw new SimpleError({
348
- code: 'empty_payout',
349
- message: 'Payout ' + payout.id + ' of ' + settlement.amount + ' has no balance transactions',
350
- });
351
- }
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
+ }
352
501
 
353
- const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
354
- for (const fee of unlinkedFees) {
355
- if (fee.balanceItemId) {
356
- 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
+ }
357
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;
358
516
  }
359
517
 
360
518
  await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
@@ -422,15 +580,8 @@ export class StripeSettlementSync {
422
580
 
423
581
  case 'application_fee': {
424
582
  // The fee rows, now linked to the platform payout that contains them
425
- const { fees } = await this.#handleApplicationFee(transaction, { settlementId: settlement.id });
583
+ const { fees } = await this.#handleApplicationFee(transaction, { settlementId: settlement.id, invoicedFeeBalanceItemIds });
426
584
  reported.applicationFees(fees);
427
- for (const fee of fees) {
428
- if (fee.balanceItemId) {
429
- // Make sure we update the AccountDeduction payments and settlements that are connected to this
430
- // application fee.
431
- invoicedFeeBalanceItemIds.add(fee.balanceItemId);
432
- }
433
- }
434
585
  await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: null });
435
586
  return;
436
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
  });