@stamhoofd/backend 2.142.0 → 2.143.1
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.
- package/package.json +17 -17
- package/src/boot.ts +32 -16
- package/src/crons/settlement-sync.test.ts +59 -1
- package/src/crons/settlement-sync.ts +19 -8
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
- package/src/helpers/MollieSettlementSync.test.ts +43 -0
- package/src/helpers/MollieSettlementSync.ts +29 -4
- package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
- package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
- package/src/helpers/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +206 -1
- package/src/helpers/StripeSettlementSync.ts +106 -61
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +26 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +20 -2
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.ts +26 -3
- package/src/services/WebshopCrowdfundingService.test.ts +433 -0
- package/src/services/WebshopCrowdfundingService.ts +98 -0
- package/tests/filters/orders.test.ts +24 -1
- 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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
return QueueHandler.schedule('settlement-sync-' + provider + '-' + externalId,
|
|
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
|
*/
|