@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.
- package/package.json +17 -17
- package/src/boot.ts +31 -16
- package/src/crons/settlement-sync.test.ts +59 -1
- package/src/crons/settlement-sync.ts +20 -9
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- 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/ApplicationFeeInvoicer.test.ts +64 -3
- package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
- 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/SettlementExporter.test.ts +22 -0
- package/src/helpers/SettlementExporter.ts +13 -3
- package/src/helpers/SettlementSyncRunner.test.ts +53 -0
- package/src/helpers/SettlementSyncRunner.ts +20 -3
- package/src/helpers/StripeSettlementSync.test.ts +375 -1
- package/src/helpers/StripeSettlementSync.ts +264 -113
- package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
- package/src/helpers/waitUntilDeadline.test.ts +48 -0
- package/src/helpers/waitUntilDeadline.ts +28 -0
- package/src/services/ApplicationFeeService.ts +47 -11
- package/src/services/BalanceItemService.ts +5 -0
- package/src/services/SettlementService.test.ts +82 -9
- package/src/services/SettlementService.ts +72 -6
- 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
|
@@ -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
|
|
35
|
-
payingStripeAccountId
|
|
34
|
+
payingOrganizationId?: string | null;
|
|
35
|
+
payingStripeAccountId?: string | null;
|
|
36
36
|
payingPaymentId?: string | null;
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await
|
|
38
|
-
return
|
|
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:
|
|
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 =
|
|
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
|
|
114
|
-
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
|
+
});
|
|
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
|
|
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.
|
|
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
|
*/
|