@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,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,40 @@ 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
+
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')) {
118
+ // Stripe account no longer in active use
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');
120
+ account.status = 'inaccessible';
121
+ await account.save();
122
+ totals.skipped += 1;
123
+ continue;
124
+ }
106
125
  console.error('Failed to sync payouts of Stripe account ' + account.accountId, e);
107
126
  totals.failed += 1;
108
127
 
@@ -119,7 +138,7 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
119
138
  * after MAXIMUM_FAILURE_COUNT attempts and waits in the problem report instead. Always forced,
120
139
  * regardless of the force configuration.
121
140
  */
122
- async retryUnsyncedSettlements({ windowStart }: { windowStart: Date }): Promise<void> {
141
+ async retryUnsyncedSettlements({ windowStart, abort = new AbortSignal() }: { windowStart: Date; abort?: AbortSignal }): Promise<void> {
123
142
  const settlements = await Settlement.select()
124
143
  .where('provider', PaymentProvider.Stripe)
125
144
  .where('syncedAt', null)
@@ -132,12 +151,18 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
132
151
  .fetch();
133
152
 
134
153
  for (const settlement of settlements) {
154
+ abort.throwIfAborted();
155
+
135
156
  const failureCountBefore = settlement.syncFailureCount;
136
157
  try {
137
158
  const stripeAccount = settlement.stripeAccountId ? await StripeAccount.getByID(settlement.stripeAccountId) : null;
138
159
  const sync = new StripeSettlementSync({ secretKey: this.#secretKey, stripeAccount: stripeAccount ?? null });
139
- await sync.syncPayoutById(settlement.externalId, { force: true });
160
+ await sync.syncPayoutById(settlement.externalId, { force: true, abort });
140
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
+
141
166
  console.error('Retry of settlement ' + settlement.externalId + ' failed', e);
142
167
 
143
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
+ }
@@ -31,10 +31,11 @@ export type ApplicationFeeData = {
31
31
  */
32
32
  organizationId: string;
33
33
 
34
- payingOrganizationId: string;
35
- payingStripeAccountId: string;
34
+ payingOrganizationId?: string | null;
35
+ payingStripeAccountId?: string | null;
36
36
  payingPaymentId?: string | null;
37
- settlementChargeId: string;
37
+
38
+ settlementChargeId?: string | null;
38
39
  settlementId?: string | null;
39
40
  occurredAt: Date;
40
41
  };
@@ -85,17 +86,28 @@ export class ApplicationFeeService {
85
86
  fee.type = data.type;
86
87
  fee.amount = data.amount;
87
88
  fee.organizationId = data.organizationId;
88
- fee.payingOrganizationId = data.payingOrganizationId;
89
- fee.payingStripeAccountId = data.payingStripeAccountId;
90
- fee.settlementChargeId = data.settlementChargeId;
91
89
  fee.occurredAt = data.occurredAt;
92
90
 
93
91
  if (data.settlementId !== undefined) {
94
92
  fee.settlementId = data.settlementId;
95
93
  }
94
+
96
95
  if (data.payingPaymentId !== undefined) {
97
96
  fee.payingPaymentId = data.payingPaymentId;
98
97
  }
98
+
99
+ if (data.settlementChargeId !== undefined) {
100
+ fee.settlementChargeId = data.settlementChargeId;
101
+ }
102
+
103
+ if (data.payingOrganizationId !== undefined) {
104
+ fee.payingOrganizationId = data.payingOrganizationId;
105
+ }
106
+
107
+ if (data.payingStripeAccountId !== undefined) {
108
+ fee.payingStripeAccountId = data.payingStripeAccountId;
109
+ }
110
+
99
111
  await fee.save();
100
112
 
101
113
  if (fee.balanceItemId === null) {
@@ -130,14 +142,32 @@ export class ApplicationFeeService {
130
142
  periodStart: Date;
131
143
  }): Promise<Payment[]> {
132
144
  const reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + Formatter.dateIso(periodStart);
133
- const key = organizationId + ':' + payingStripeAccountId + ':' + reference;
145
+ const key = organizationId + ':' + payingOrganizationId + ':' + payingStripeAccountId + ':' + reference;
134
146
 
135
147
  const cached = this.legacyFeePayments.get(key);
136
148
  if (cached && cached.length > 0) {
137
149
  return cached;
138
150
  }
139
151
 
140
- 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()
141
171
  .where('organizationId', organizationId)
142
172
  .where('payingOrganizationId', payingOrganizationId)
143
173
  .where(
@@ -162,7 +192,7 @@ export class ApplicationFeeService {
162
192
  * from being billed twice.
163
193
  */
164
194
  private static async linkLegacyInvoicedFee(fee: ApplicationFee) {
165
- if (!fee.payingStripeAccountId) {
195
+ if (!fee.payingStripeAccountId || !fee.payingOrganizationId) {
166
196
  return;
167
197
  }
168
198
 
@@ -217,7 +247,9 @@ export class ApplicationFeeService {
217
247
  .where('id', balanceItemPayments.map(b => b.balanceItemId))
218
248
  .fetch();
219
249
  const balanceItemType = type === ApplicationFeeType.Service ? BalanceItemType.ServiceFee : BalanceItemType.TransferFee;
220
- return balanceItems.find(item => item.type === balanceItemType) ?? null;
250
+
251
+ // In legacy migrations, the type has been set to 'Other' - fallback to that (contains both service fees and transfer fees in one balance item)
252
+ return balanceItems.find(item => item.type === balanceItemType) ?? (balanceItems.length === 1 ? balanceItems.find(item => item.type === BalanceItemType.Other) : null) ?? null;
221
253
  }
222
254
 
223
255
  /**
@@ -226,6 +258,10 @@ export class ApplicationFeeService {
226
258
  * no stamp: stampInvoicedPayments runs when the invoice is created later.
227
259
  */
228
260
  private static async stampProviderInvoiceId(fee: ApplicationFee, { payment }: { payment?: Payment } = {}) {
261
+ if (!fee.settlementChargeId) {
262
+ return;
263
+ }
264
+
229
265
  if (!payment) {
230
266
  if (!fee.balanceItemId) {
231
267
  return;
@@ -275,7 +311,7 @@ export class ApplicationFeeService {
275
311
  .where('balanceItemId', balanceItemPayments.map(b => b.balanceItemId))
276
312
  .limit(FEE_BATCH_SIZE)
277
313
  .allBatched()) {
278
- settlementChargeIds.push(...fees.map(fee => fee.settlementChargeId));
314
+ settlementChargeIds.push(...fees.map(fee => fee.settlementChargeId).filter((id): id is string => id !== null));
279
315
  }
280
316
 
281
317
  await SettlementService.setChargeProviderInvoiceIds(settlementChargeIds, invoice?.number ?? null);
@@ -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
 
@@ -13,9 +13,11 @@ import { ReportedRows, SettlementService } from './SettlementService.js';
13
13
 
14
14
  describe('SettlementService', () => {
15
15
  let organization: Organization;
16
+ let stripeAccount: StripeAccount;
16
17
 
17
18
  beforeAll(async () => {
18
19
  organization = await new OrganizationFactory({}).create();
20
+ stripeAccount = await createStripeAccount();
19
21
  });
20
22
 
21
23
  async function createPayment(price = 50_00_00, method = PaymentMethod.Bancontact, organizationId = organization.id) {
@@ -30,25 +32,25 @@ describe('SettlementService', () => {
30
32
  return payment;
31
33
  }
32
34
 
33
- async function createStripeAccount() {
34
- const stripeAccount = new StripeAccount();
35
- stripeAccount.organizationId = organization.id;
36
- stripeAccount.accountId = 'acct_' + uuidv4();
37
- await stripeAccount.save();
38
- return stripeAccount;
35
+ async function createStripeAccount(organizationId = organization.id) {
36
+ const account = new StripeAccount();
37
+ account.organizationId = organizationId;
38
+ account.accountId = 'acct_' + uuidv4();
39
+ await account.save();
40
+ return account;
39
41
  }
40
42
 
41
43
  /**
42
44
  * A fee with its deduction charge, as the sync stores them.
43
45
  */
44
- async function createApplicationFee({ amount = 1_00_00, type = ApplicationFeeType.Service, settlement = null as SettlementModel | null, balanceItemId = null as string | null, occurredAt = new Date(2026, 0, 14) } = {}) {
46
+ async function createApplicationFee({ amount = 1_00_00, type = ApplicationFeeType.Service, settlement = null as SettlementModel | null, balanceItemId = null as string | null, occurredAt = new Date(2026, 0, 14), payingOrganizationId = organization.id, payingStripeAccountId = null as string | null } = {}) {
45
47
  const externalId = 'fee_' + uuidv4();
46
48
  const charge = await SettlementService.upsertCharge({
47
49
  type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
48
50
  externalId: externalId + ':' + type,
49
51
  amount: -amount,
50
52
  applicationFeeId: externalId,
51
- organizationId: organization.id,
53
+ organizationId: payingOrganizationId,
52
54
  occurredAt,
53
55
  });
54
56
 
@@ -57,7 +59,8 @@ describe('SettlementService', () => {
57
59
  fee.type = type;
58
60
  fee.amount = amount;
59
61
  fee.organizationId = organization.id;
60
- fee.payingOrganizationId = organization.id;
62
+ fee.payingOrganizationId = payingOrganizationId;
63
+ fee.payingStripeAccountId = payingStripeAccountId ?? stripeAccount.id;
61
64
  fee.settlementChargeId = charge.id;
62
65
  fee.settlementId = settlement?.id ?? null;
63
66
  fee.balanceItemId = balanceItemId;
@@ -66,6 +69,22 @@ describe('SettlementService', () => {
66
69
  return { fee, charge };
67
70
  }
68
71
 
72
+ /**
73
+ * A fee the invoicer will never bill: its payer is gone, so there is no payout of theirs to
74
+ * deduct it from either.
75
+ */
76
+ async function createUncollectibleApplicationFee({ amount = 1_00_00, settlement = null as SettlementModel | null, occurredAt = new Date(2026, 0, 14) } = {}) {
77
+ const fee = new ApplicationFee();
78
+ fee.externalId = 'fee_' + uuidv4();
79
+ fee.type = ApplicationFeeType.Service;
80
+ fee.amount = amount;
81
+ fee.organizationId = organization.id;
82
+ fee.settlementId = settlement?.id ?? null;
83
+ fee.occurredAt = occurredAt;
84
+ await fee.save();
85
+ return fee;
86
+ }
87
+
69
88
  /**
70
89
  * A fee payment with one balance item, like the invoicer creates.
71
90
  */
@@ -471,6 +490,60 @@ describe('SettlementService', () => {
471
490
  expect(unlinked!.pendingFees).toBe(0);
472
491
  expect(unlinked!.unexplainedAmount).toBe(1_00_00);
473
492
  });
493
+
494
+ test('fees the invoicer will never bill explain the payout without ever being invoiced', async () => {
495
+ const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 3_00_00 }));
496
+ await createApplicationFee({ settlement, amount: 1_00_00 });
497
+ await createUncollectibleApplicationFee({ settlement, amount: 1_00_00 });
498
+
499
+ // A payer we know, but not the account the fee was deducted from: the invoicer skips it
500
+ const accountless = await createApplicationFee({ settlement, amount: 1_00_00 });
501
+ accountless.fee.payingStripeAccountId = null;
502
+ await accountless.fee.save();
503
+
504
+ await SettlementService.finishSync(settlement, { transactionCount: 3 });
505
+
506
+ // Only the fee that can still be billed is worth waiting for
507
+ expect(settlement.pendingFees).toBe(1_00_00);
508
+ expect(settlement.uncollectibleFees).toBe(2_00_00);
509
+ expect(settlement.unexplainedAmount).toBe(0);
510
+ });
511
+ });
512
+
513
+ describe('getApplicationFeeSettlementIdsForPayingOrganization', () => {
514
+ test('deleting the paying organization keeps its fees as uncollectible income', async () => {
515
+ const payer = await new OrganizationFactory({}).create();
516
+ const payerAccount = await createStripeAccount(payer.id);
517
+ const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 1_00_00 }));
518
+ const { fee, charge } = await createApplicationFee({ settlement, amount: 1_00_00, payingOrganizationId: payer.id, payingStripeAccountId: payerAccount.id });
519
+
520
+ // An organization that took Stripe payments is the only kind that owes fees: its
521
+ // payments reference the account the delete has to cascade through
522
+ const payerPayment = await createPayment(10_00_00, PaymentMethod.Bancontact, payer.id);
523
+ payerPayment.stripeAccountId = payerAccount.id;
524
+ await payerPayment.save();
525
+
526
+ await SettlementService.finishSync(settlement, { transactionCount: 1 });
527
+ expect(settlement.pendingFees).toBe(1_00_00);
528
+
529
+ const settlementIds = await SettlementService.getApplicationFeeSettlementIdsForPayingOrganization(payer.id);
530
+ expect(settlementIds).toEqual([settlement.id]);
531
+
532
+ await payer.delete();
533
+ await SettlementService.refreshTotalsForIds(settlementIds);
534
+
535
+ // The deduction charge went with the organization; the fee itself is our income and stays
536
+ expect(await SettlementCharge.getByID(charge.id)).toBeUndefined();
537
+ const stored = await ApplicationFee.getByID(fee.id);
538
+ expect(stored).toBeDefined();
539
+ expect(stored!.payingOrganizationId).toBeNull();
540
+ expect(stored!.settlementChargeId).toBeNull();
541
+
542
+ const after = await Settlement.getByID(settlement.id);
543
+ expect(after!.pendingFees).toBe(0);
544
+ expect(after!.uncollectibleFees).toBe(1_00_00);
545
+ expect(after!.unexplainedAmount).toBe(0);
546
+ });
474
547
  });
475
548
 
476
549
  describe('updateLegacySettlementReference', () => {
@@ -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';
@@ -62,6 +62,11 @@ export type ChargeData = {
62
62
  */
63
63
  const CHARGE_UPDATE_BATCH_SIZE = 500;
64
64
 
65
+ /**
66
+ * Fees read per batch when a whole organization's fees are walked.
67
+ */
68
+ const FEE_BATCH_SIZE = 500;
69
+
65
70
  /**
66
71
  * Collects which rows the provider still reports in a settlement while a sync walks it. A stored
67
72
  * row of the settlement that is not in here after the walk has moved or disappeared at the
@@ -109,9 +114,20 @@ export class SettlementService {
109
114
  * Serializes syncs of the same settlement, so cron and manual backfill can't race. In-process
110
115
  * only: across multiple API instances the tables' unique keys are the real guard, so a
111
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.
112
120
  */
113
- static lock<T>(provider: PaymentProvider, externalId: string, handler: () => Promise<T>): Promise<T> {
114
- 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
+ });
115
131
  }
116
132
 
117
133
  /**
@@ -306,7 +322,7 @@ export class SettlementService {
306
322
  * settlement (a transaction can move to another payout). Rows that only exist because of the
307
323
  * payout are deleted; rows that outlive the payout link are only unlinked: derived fee lines
308
324
  * (owned by updatePaymentSettlementsForAccountDeductionPayment), deduction charges referenced
309
- * by an application fee (RESTRICT FK), and the application fee rows themselves.
325
+ * by an application fee, and the application fee rows themselves.
310
326
  *
311
327
  * Returns the fees unlinked from this settlement, so the caller can refresh the derived lines
312
328
  * of their fee payments.
@@ -376,7 +392,9 @@ export class SettlementService {
376
392
  * Recomputes the cached reconciliation columns from the stored rows: `unexplainedAmount` should
377
393
  * be 0 — a non-zero value is a real question to answer — and `pendingFees` holds what is
378
394
  * received but not invoiced yet, which takes up to a month and only becomes a problem when it
379
- * stays non-zero too long.
395
+ * stays non-zero too long. Fees the invoicer can never bill land in `uncollectibleFees`
396
+ * instead: they explain their part of the payout, but waiting for them to be invoiced is
397
+ * waiting forever.
380
398
  *
381
399
  * Every write that changes what a payout holds ends here, or the export and the problem report
382
400
  * keep reading numbers from the last sync.
@@ -405,10 +423,46 @@ export class SettlementService {
405
423
  const pendingFees = await ApplicationFee.select()
406
424
  .where('settlementId', settlement.id)
407
425
  .where('balanceItemId', null)
426
+ .where('payingOrganizationId', '!=', null)
427
+ .where('payingStripeAccountId', '!=', null)
428
+ .sum(SQL.column('amount')) ?? 0;
429
+
430
+ // The negation of what the invoicer bills (ApplicationFeeInvoicer#selectBillableFees), so
431
+ // every uninvoiced fee sits in exactly one of the two sums
432
+ const uncollectibleFees = await ApplicationFee.select()
433
+ .where('settlementId', settlement.id)
434
+ .where('balanceItemId', null)
435
+ .where(
436
+ SQL.where('payingOrganizationId', null)
437
+ .or('payingStripeAccountId', null),
438
+ )
408
439
  .sum(SQL.column('amount')) ?? 0;
409
440
 
410
441
  settlement.pendingFees = pendingFees;
411
- settlement.unexplainedAmount = settlement.amount - paymentSum - chargeSum - pendingFees;
442
+ settlement.uncollectibleFees = uncollectibleFees;
443
+ settlement.unexplainedAmount = settlement.amount - paymentSum - chargeSum - pendingFees - uncollectibleFees;
444
+ }
445
+
446
+ /**
447
+ * The payouts holding application fees this organization paid: after deleting it, they have to
448
+ * recount, because those fees moved from pending to uncollectible.
449
+ */
450
+ static async getApplicationFeeSettlementIdsForPayingOrganization(organizationId: string): Promise<string[]> {
451
+ // An organization has one fee row per payment per type, so they are never all loaded at
452
+ // once just to collect the handful of payouts behind them
453
+ const settlementIds = new Set<string>();
454
+
455
+ for await (const fees of ApplicationFee.select()
456
+ .where('payingOrganizationId', organizationId)
457
+ .where('settlementId', '!=', null)
458
+ .limit(FEE_BATCH_SIZE)
459
+ .allBatched()) {
460
+ for (const fee of fees) {
461
+ settlementIds.add(fee.settlementId!);
462
+ }
463
+ }
464
+
465
+ return [...settlementIds];
412
466
  }
413
467
 
414
468
  /**
@@ -532,6 +586,18 @@ export class SettlementService {
532
586
  }
533
587
  }
534
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
+
535
601
  /**
536
602
  * A failed sync leaves syncedAt NULL: that is the whole error queue.
537
603
  */