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