@stamhoofd/backend 2.139.0 → 2.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/auth/MFA.security.test.ts +76 -1
  11. package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
  13. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  14. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +14 -14
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  17. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  22. package/src/excel-loaders/payments.ts +18 -1
  23. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  24. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  25. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  26. package/src/helpers/AuthenticatedStructures.ts +14 -1
  27. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  28. package/src/helpers/MollieSettlementSync.ts +323 -0
  29. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  30. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  31. package/src/helpers/SettlementExporter.test.ts +366 -0
  32. package/src/helpers/SettlementExporter.ts +583 -0
  33. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  34. package/src/helpers/SettlementSyncRunner.ts +64 -0
  35. package/src/helpers/StripeHelper.ts +2 -1
  36. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  37. package/src/helpers/StripeSettlementSync.ts +947 -0
  38. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  39. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  40. package/src/helpers/TwoFactorHelper.ts +1 -1
  41. package/src/helpers/WebmasterReport.test.ts +109 -0
  42. package/src/helpers/WebmasterReport.ts +115 -0
  43. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  45. package/src/helpers/streamForBreakdown.ts +2 -2
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +283 -0
  48. package/src/services/DocumentRenderService.test.ts +72 -1
  49. package/src/services/InvoiceService.ts +18 -1
  50. package/src/services/PaymentService.ts +8 -0
  51. package/src/services/SettlementService.test.ts +559 -0
  52. package/src/services/SettlementService.ts +607 -0
  53. package/src/sql-filters/orders.ts +6 -2
  54. package/src/sql-filters/payment-settlement.test.ts +2 -2
  55. package/src/sql-filters/payments.ts +73 -0
  56. package/tests/filters/orders.test.ts +635 -0
  57. package/tests/helpers/MollieMocker.ts +64 -6
  58. package/tests/helpers/StripeMocker.ts +209 -17
  59. package/src/crons/stripe-payout-reports.ts +0 -69
  60. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  61. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  62. package/src/helpers/CheckSettlements.test.ts +0 -190
  63. package/src/helpers/CheckSettlements.ts +0 -237
  64. package/src/helpers/StripeInvoicer.ts +0 -419
  65. package/src/helpers/StripePayoutChecker.ts +0 -193
  66. package/src/helpers/StripePayoutExportData.ts +0 -195
  67. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  68. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  69. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,947 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { Payment, StripeAccount } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
5
+ import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
6
+ import type { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
7
+ import { PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
8
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
9
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
10
+ import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
11
+ import Stripe from 'stripe';
12
+
13
+ import { ApplicationFeeService } from '../services/ApplicationFeeService.js';
14
+ import { ReportedRows, SettlementService } from '../services/SettlementService.js';
15
+ import { ApplicationFeeDetails } from './ApplicationFeeDetails.js';
16
+ import { passthroughFetch } from './passthroughFetch.js';
17
+ import { getPaymentIdForStripeCharge } from './getPaymentIdForStripeCharge.js';
18
+ import { WebmasterReport } from './WebmasterReport.js';
19
+
20
+ /**
21
+ * Walks paid payouts and stores every balance transaction in them: payments become
22
+ * payment_settlements rows, everything else becomes settlement_charges rows. One walker for both
23
+ * scopes: `stripeAccount === null` walks our own platform account, otherwise the connected
24
+ * account's payouts. The platform instance also ingests application fees before any payout
25
+ * contains them (syncFees).
26
+ *
27
+ * Fail loudly: a transaction that can't be attributed or an unknown transaction type fails the
28
+ * whole payout. The settlement stays unsynced (`syncedAt IS NULL`) and is retried later.
29
+ */
30
+ export class StripeSettlementSync {
31
+ private stripe: Stripe;
32
+ private stripePlatform: Stripe;
33
+ private stripeAccount: StripeAccount | null;
34
+
35
+ /**
36
+ * Explicitly fetched charges (when an expansion came back as an id), per run.
37
+ */
38
+ private fetchedCharges = new Map<string, Stripe.Charge>();
39
+
40
+ constructor({ secretKey, stripeAccount }: { secretKey: string; stripeAccount?: StripeAccount | null }) {
41
+ this.stripeAccount = stripeAccount ?? null;
42
+
43
+ const options: Stripe.StripeConfig = {
44
+ apiVersion: '2024-06-20',
45
+ typescript: true,
46
+ maxNetworkRetries: 1,
47
+ timeout: 10000,
48
+ httpClient: STAMHOOFD.environment === 'test'
49
+ ? Stripe.createFetchHttpClient(passthroughFetch)
50
+ : undefined,
51
+ };
52
+
53
+ this.stripe = new Stripe(secretKey, { ...options, stripeAccount: this.stripeAccount?.accountId });
54
+ this.stripePlatform = new Stripe(secretKey, options);
55
+ }
56
+
57
+ /**
58
+ * Sync all paid payouts that arrived in the window. A failing payout is marked, reported and
59
+ * skipped so the other payouts still sync; the summary tells the caller how bad it was.
60
+ */
61
+ async syncPayouts({ start, end, force = false }: { start: Date; end?: Date; force?: boolean }): Promise<{ synced: number; skipped: number; failed: number }> {
62
+ const result = { synced: 0, skipped: 0, failed: 0 };
63
+
64
+ // Fail once up front when no organization can own these payouts, instead of once per payout
65
+ // inside the per-payout error boundary
66
+ await this.#getOrganizationId();
67
+
68
+ // Not filtered on status: a payout can still flip from paid to failed within five business
69
+ // days, and only re-reading it keeps the stored status true
70
+ for await (const payout of this.stripe.payouts.list({
71
+ arrival_date: {
72
+ gte: Math.floor(start.getTime() / 1000),
73
+ ...(end ? { lte: Math.floor(end.getTime() / 1000) } : {}),
74
+ },
75
+ limit: 100,
76
+ })) {
77
+ try {
78
+ const { skipped } = await this.syncPayout(payout, { force });
79
+ if (skipped) {
80
+ result.skipped += 1;
81
+ } else {
82
+ result.synced += 1;
83
+ }
84
+ } catch (e) {
85
+ console.error('Failed to sync Stripe payout ' + payout.id, e);
86
+ result.failed += 1;
87
+
88
+ WebmasterReport.report('Synchroniseren Stripe uitbetaling ' + payout.id + (this.stripeAccount ? (' van account ' + this.stripeAccount.accountId) : '') + ' mislukt', e);
89
+ }
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ #organizationId: string | null = null;
96
+
97
+ /**
98
+ * Re-sync one payout by its id, e.g. to retry a settlement that stayed unsynced.
99
+ */
100
+ async syncPayoutById(externalId: string, options: { force?: boolean } = {}): Promise<{ settlement: Settlement; skipped: boolean }> {
101
+ const payout = await this.stripe.payouts.retrieve(externalId);
102
+ return await this.syncPayout(payout, options);
103
+ }
104
+
105
+ /**
106
+ * Walks all application_fee balance transactions in the window, storing the payer's deduction
107
+ * charges and the application fee rows before any payout contains them; the payout walks fill
108
+ * in the settlement links later. A broken fee doesn't block storing the others, but the walk
109
+ * still fails loudly at the end: a month is only invoiced after a run without errors.
110
+ */
111
+ async syncFees({ start, end }: { start: Date; end: Date }) {
112
+ if (this.stripeAccount) {
113
+ throw new SimpleError({
114
+ code: 'invalid_scope',
115
+ message: 'Application fees live on the platform account, not on connected account ' + this.stripeAccount.accountId,
116
+ });
117
+ }
118
+
119
+ const errors: unknown[] = [];
120
+
121
+ // Storing a fee can link it to a balance item right away (a month the legacy invoicer
122
+ // billed). When it is already paid out, its payout needs the derived line for it
123
+ const invoicedFeeBalanceItemIds = new Set<string>();
124
+
125
+ for await (const transaction of this.stripe.balanceTransactions.list({
126
+ type: 'application_fee',
127
+ created: {
128
+ gte: Math.floor(start.getTime() / 1000),
129
+ lte: Math.floor(end.getTime() / 1000),
130
+ },
131
+ expand: ['data.source', 'data.source.originating_transaction'],
132
+ limit: 100,
133
+ })) {
134
+ try {
135
+ const { fees } = await this.#handleApplicationFee(transaction);
136
+ for (const fee of fees) {
137
+ if (fee.balanceItemId && fee.settlementId) {
138
+ invoicedFeeBalanceItemIds.add(fee.balanceItemId);
139
+ }
140
+ }
141
+ } catch (e) {
142
+ console.error('Failed to sync application fee transaction ' + transaction.id, e);
143
+ errors.push(e);
144
+ }
145
+ }
146
+
147
+ await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
148
+
149
+ if (errors.length > 0) {
150
+ throw new SimpleError({
151
+ code: 'stripe_fee_sync_failed',
152
+ message: 'Fee sync failed for ' + errors.length + ' transaction(s): ' + errors.map(e => e instanceof Error ? e.message : String(e)).join('; '),
153
+ });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Stores one application_fee balance transaction: per non-zero part the payer's negative
159
+ * deduction charge (its settlement link belongs to the payer's own payout walk) and the
160
+ * application fee row. The platform payout walk passes `settlementId` to link the fee rows to
161
+ * the payout that contains them. Throws instead of writing a guessed row: missing serviceFee
162
+ * metadata, an unknown Stripe account or an unresolvable payment all mean someone has to look
163
+ * at it first.
164
+ */
165
+ async #handleApplicationFee(transaction: Stripe.BalanceTransaction, options: { settlementId?: string } = {}): Promise<{ fees: ApplicationFee[]; charges: SettlementCharge[] }> {
166
+ const fee = transaction.source as Stripe.ApplicationFee;
167
+ const payingAccountId = typeof fee.account === 'string' ? fee.account : fee.account.id;
168
+
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
+ const originatingTransaction = fee.originating_transaction;
178
+ if (!originatingTransaction || typeof originatingTransaction === 'string') {
179
+ throw new SimpleError({
180
+ code: 'missing_originating_transaction',
181
+ message: 'Application fee ' + fee.id + ' has no expanded originating transaction',
182
+ });
183
+ }
184
+
185
+ const details = ApplicationFeeDetails.fromStripe(transaction);
186
+
187
+ const paymentId = await getPaymentIdForStripeCharge(originatingTransaction as Stripe.Charge, {
188
+ stripePlatform: this.stripePlatform,
189
+ });
190
+
191
+ if (!paymentId) {
192
+ throw new SimpleError({
193
+ code: 'payment_not_found',
194
+ message: 'No payment found for application fee ' + fee.id,
195
+ });
196
+ }
197
+
198
+ // Charge metadata is writable by the connected account's owner: the fee is deducted from
199
+ // this account, so it can only be about a payment of its own organization
200
+ const payment = await Payment.getByID(paymentId);
201
+ if (!payment || payment.organizationId !== payingStripeAccount.organizationId) {
202
+ throw new SimpleError({
203
+ code: 'payment_scope_mismatch',
204
+ message: 'Payment ' + paymentId + ' of application fee ' + fee.id + ' does not belong to organization ' + payingStripeAccount.organizationId,
205
+ });
206
+ }
207
+
208
+ const occurredAt = new Date(transaction.created * 1000);
209
+
210
+ const fees: ApplicationFee[] = [];
211
+ const charges: SettlementCharge[] = [];
212
+
213
+ // Application fees are charged by the platform organization: it receives them, and bills
214
+ // them to the paying organization
215
+ const receivingOrganizationId = await SettlementService.getPlatformOrganizationId();
216
+
217
+ for (const [chargeType, feeType, amount] of [
218
+ [SettlementChargeType.ApplicationFeeService, ApplicationFeeType.Service, details.serviceFee],
219
+ [SettlementChargeType.ApplicationFeeTransfer, ApplicationFeeType.Transfer, details.transferFee],
220
+ ] as const) {
221
+ if (amount === 0) {
222
+ continue;
223
+ }
224
+
225
+ const charge = await SettlementService.upsertCharge({
226
+ type: chargeType,
227
+ externalId: fee.id + ':' + chargeType,
228
+ amount: -amount,
229
+ applicationFeeId: fee.id,
230
+ paymentId,
231
+
232
+ // The charge sits in the paying organization's payout
233
+ organizationId: payingStripeAccount.organizationId,
234
+ stripeAccountId: payingStripeAccount.id,
235
+ occurredAt,
236
+
237
+ // settlementId (settlement of the paying organization where the costs are deducted): still unknown, will be filled when looping the payouts of the paying organization
238
+ });
239
+ charges.push(charge);
240
+
241
+ fees.push(await ApplicationFeeService.upsertFee({
242
+ externalId: fee.id,
243
+ type: feeType,
244
+ amount,
245
+ organizationId: receivingOrganizationId,
246
+ payingOrganizationId: payingStripeAccount.organizationId,
247
+ payingStripeAccountId: payingStripeAccount.id,
248
+ payingPaymentId: paymentId,
249
+ settlementChargeId: charge.id,
250
+ occurredAt,
251
+ ...(options.settlementId !== undefined ? { settlementId: options.settlementId } : {}),
252
+ }));
253
+ }
254
+
255
+ return { fees, charges };
256
+ }
257
+
258
+ /**
259
+ * The organization that owns the walked account: the connected account's organization, or the
260
+ * platform membership organization for our own platform account (resolved once per instance).
261
+ */
262
+ async #getOrganizationId(): Promise<string> {
263
+ this.#organizationId ??= this.stripeAccount?.organizationId ?? await SettlementService.getPlatformOrganizationId();
264
+ return this.#organizationId;
265
+ }
266
+
267
+ async syncPayout(payout: Stripe.Payout, { force = false }: { force?: boolean } = {}): Promise<{ settlement: Settlement; skipped: boolean }> {
268
+ // All amounts are stored in the same unit: a payout in another currency would be stored as
269
+ // a plausible but wrong number
270
+ if (payout.currency && payout.currency.toUpperCase() !== 'EUR') {
271
+ throw new SimpleError({
272
+ code: 'unsupported_payout_currency',
273
+ message: 'Payout ' + payout.id + ' is in ' + payout.currency + ', only EUR is supported',
274
+ });
275
+ }
276
+
277
+ return await SettlementService.lock(PaymentProvider.Stripe, payout.id, async () => {
278
+ const settlement = await SettlementService.upsertSettlement({
279
+ provider: PaymentProvider.Stripe,
280
+ externalId: payout.id,
281
+ stripeAccountId: this.stripeAccount?.id ?? null,
282
+ organizationId: await this.#getOrganizationId(),
283
+ reference: payout.statement_descriptor ?? '',
284
+ amount: payout.amount * 100,
285
+ currency: payout.currency?.toUpperCase() ?? 'EUR',
286
+ status: getSettlementStatus(payout.status),
287
+ settledAt: new Date(payout.arrival_date * 1000),
288
+ });
289
+
290
+ // Money that never arrived holds no transactions to walk. The status above is still
291
+ // refreshed, so a payout that flips to failed stops claiming it was paid out
292
+ if (payout.status !== 'paid') {
293
+ return { settlement, skipped: true };
294
+ }
295
+
296
+ // Stripe only lists the transactions of a payout once it finished reconciling it, and
297
+ // never for manual payouts. Walking anyway would store an empty payout and mark it
298
+ // synced, which no later run would ever revisit
299
+ if (payout.reconciliation_status !== 'completed') {
300
+ if (payout.reconciliation_status === 'not_applicable') {
301
+ throw new SimpleError({
302
+ code: 'unsupported_payout',
303
+ message: 'Stripe does not report the transactions of payout ' + payout.id + ' (' + payout.reconciliation_status + '), which only happens for manual payouts',
304
+ });
305
+ }
306
+ // Still reconciling at Stripe: it stays unsynced and the next run picks it up
307
+ return { settlement, skipped: true };
308
+ }
309
+
310
+ if (settlement.syncedAt && !force) {
311
+ return { settlement, skipped: true };
312
+ }
313
+
314
+ try {
315
+ await this.#walkPayout(payout, settlement);
316
+ } catch (e) {
317
+ await SettlementService.markSyncFailed(settlement);
318
+ throw e;
319
+ }
320
+
321
+ return { settlement, skipped: false };
322
+ });
323
+ }
324
+
325
+ async #walkPayout(payout: Stripe.Payout, settlement: Settlement) {
326
+ const reported = new ReportedRows();
327
+ let transactionCount = 0;
328
+
329
+ // Invoiced fees linked to or unlinked from this settlement during the walk: their fee
330
+ // payments' derived lines must follow
331
+ const invoicedFeeBalanceItemIds = new Set<string>();
332
+
333
+ for await (const transaction of this.stripe.balanceTransactions.list({
334
+ payout: payout.id,
335
+ limit: 100,
336
+ expand: this.stripeAccount
337
+ ? ['data.source', 'data.source.application_fee', 'data.source.application_fee.originating_transaction', 'data.source.charge']
338
+ : ['data.source', 'data.source.originating_transaction', 'data.source.charge'],
339
+ })) {
340
+ transactionCount += 1;
341
+ await this.#handleTransaction(transaction, settlement, reported, invoicedFeeBalanceItemIds);
342
+ }
343
+
344
+ // Stripe reported nothing for money that did move: storing that as a complete sync would
345
+ // silently hide the whole payout
346
+ if (transactionCount === 0 && settlement.amount !== 0) {
347
+ throw new SimpleError({
348
+ code: 'empty_payout',
349
+ message: 'Payout ' + payout.id + ' of ' + settlement.amount + ' has no balance transactions',
350
+ });
351
+ }
352
+
353
+ const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
354
+ for (const fee of unlinkedFees) {
355
+ if (fee.balanceItemId) {
356
+ invoicedFeeBalanceItemIds.add(fee.balanceItemId);
357
+ }
358
+ }
359
+
360
+ await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
361
+ await SettlementService.finishSync(settlement, { transactionCount });
362
+ }
363
+
364
+ async #handleTransaction(transaction: Stripe.BalanceTransaction, settlement: Settlement, reported: ReportedRows, invoicedFeeBalanceItemIds: Set<string>) {
365
+ const occurredAt = new Date(transaction.created * 1000);
366
+
367
+ // A plain string switch: the pinned SDK's type union misses some real-world types
368
+ // (e.g. network_cost)
369
+ switch (transaction.type as string) {
370
+ case 'charge':
371
+ case 'payment': {
372
+ const payment = await this.#resolvePayment(transaction);
373
+
374
+ // A destination charge only passes through our balance on its way to the
375
+ // organization: its own payout settles it, ours stays out of it
376
+ if (payment.organizationId !== settlement.organizationId) {
377
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: null });
378
+ return;
379
+ }
380
+
381
+ reported.paymentLine(await SettlementService.upsertPaymentLine(settlement, {
382
+ paymentId: payment.id,
383
+ amount: transaction.amount * 100,
384
+ externalId: transaction.id,
385
+ occurredAt,
386
+ }));
387
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: payment.id });
388
+ await this.#updateTransferFee(transaction, settlement, payment);
389
+ await SettlementService.updateLegacySettlementReference(payment);
390
+ return;
391
+ }
392
+
393
+ case 'refund':
394
+ case 'payment_refund':
395
+ case 'payment_failure_refund':
396
+ case 'refund_failure': {
397
+ // payment_failure_refund is a SEPA debit that failed after settling: locally a
398
+ // Chargeback payment, linked the same way as a refund. refund_failure is the
399
+ // opposite: a refund that never reached the customer, so the money comes back and
400
+ // its transaction is positive while the reversing payment stays negative
401
+ const isReturned = transaction.type === 'refund_failure';
402
+ const refunded = await this.#resolveRefundedPayment(transaction);
403
+
404
+ // The reverse of the pass-through above: refunding another organization's payment
405
+ // only moves the money back through our balance
406
+ if (refunded.organizationId !== settlement.organizationId) {
407
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: null });
408
+ return;
409
+ }
410
+
411
+ const payment = await this.#resolveReversingPayment(transaction, refunded, { negated: isReturned });
412
+ reported.paymentLine(await SettlementService.upsertPaymentLine(settlement, {
413
+ paymentId: payment.id,
414
+ amount: transaction.amount * 100,
415
+ externalId: transaction.id,
416
+ occurredAt,
417
+ }));
418
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: payment.id });
419
+ await SettlementService.updateLegacySettlementReference(payment);
420
+ return;
421
+ }
422
+
423
+ case 'application_fee': {
424
+ // The fee rows, now linked to the platform payout that contains them
425
+ const { fees } = await this.#handleApplicationFee(transaction, { settlementId: settlement.id });
426
+ 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
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: null });
435
+ return;
436
+ }
437
+
438
+ case 'application_fee_refund': {
439
+ const source = transaction.source as Stripe.FeeRefund;
440
+ const applicationFeeId = typeof source.fee === 'string' ? source.fee : source.fee.id;
441
+
442
+ // We never refund application fees ourselves, so this can only come from the Stripe
443
+ // dashboard. Storing just the charge would leave the fee billed in full to the
444
+ // organization: what to give back is a decision someone has to make first
445
+ throw new SimpleError({
446
+ code: 'unsupported_application_fee_refund',
447
+ message: 'Application fee ' + applicationFeeId + ' was refunded in transaction ' + transaction.id + ', which is not billed back to the organization automatically',
448
+ });
449
+ }
450
+
451
+ case 'transfer':
452
+ case 'transfer_cancel':
453
+ case 'transfer_failure':
454
+ case 'transfer_refund':
455
+ // The other half of a pass-through: it moves the same gross back out of our
456
+ // balance, so ignoring both keeps the payout explained. We never transfer money
457
+ // ourselves, so a transfer without its charge can only be a real problem, and it
458
+ // surfaces as an unexplained amount
459
+ return;
460
+
461
+ case 'stripe_fee':
462
+ case 'network_cost':
463
+ case 'tax_fee': {
464
+ const charge = await SettlementService.upsertCharge({
465
+ type: transaction.type === 'tax_fee' ? SettlementChargeType.Tax : SettlementChargeType.ProviderAccountFee,
466
+ externalId: transaction.id,
467
+ amount: transaction.amount * 100,
468
+ settlementId: settlement.id,
469
+ organizationId: settlement.organizationId,
470
+ providerInvoiceId: getStripeInvoiceId(occurredAt),
471
+ description: transaction.description ?? '',
472
+ occurredAt,
473
+ });
474
+ reported.charge(charge);
475
+ return;
476
+ }
477
+
478
+ case 'reserve_transaction':
479
+ case 'reserved_funds':
480
+ case 'reserve_hold':
481
+ case 'reserve_release': {
482
+ const charge = await SettlementService.upsertCharge({
483
+ type: SettlementChargeType.Reserve,
484
+ externalId: transaction.id,
485
+ amount: transaction.amount * 100,
486
+ settlementId: settlement.id,
487
+ organizationId: settlement.organizationId,
488
+ description: transaction.description ?? '',
489
+ occurredAt,
490
+ });
491
+ reported.charge(charge);
492
+ return;
493
+ }
494
+
495
+ case 'adjustment':
496
+ case 'payment_reversal': {
497
+ const paymentId = await this.#tryResolveAdjustmentPayment(transaction, settlement);
498
+ const charge = await SettlementService.upsertCharge({
499
+ type: SettlementChargeType.Adjustment,
500
+ externalId: transaction.id,
501
+ amount: transaction.amount * 100,
502
+ settlementId: settlement.id,
503
+ // Unresolvable stays undefined: a re-sync may not clear an earlier stored link
504
+ ...(paymentId ? { paymentId } : {}),
505
+ organizationId: settlement.organizationId,
506
+ description: transaction.description ?? '',
507
+ occurredAt,
508
+ });
509
+ reported.charge(charge);
510
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId });
511
+ return;
512
+ }
513
+
514
+ // Money moving in or out of the balance around the payouts themselves: a returned
515
+ // payout, a top-up, or Stripe settling something against the balance. None of them
516
+ // belong to a payment, but they do change what a later payout holds
517
+ case 'payout_failure':
518
+ case 'payout_cancel':
519
+ case 'topup':
520
+ case 'topup_reversal':
521
+ case 'connect_collection_transfer':
522
+ case 'stripe_balance_payment_debit':
523
+ case 'stripe_balance_payment_debit_reversal': {
524
+ const charge = await SettlementService.upsertCharge({
525
+ type: SettlementChargeType.BalanceMovement,
526
+ externalId: transaction.id,
527
+ amount: transaction.amount * 100,
528
+ settlementId: settlement.id,
529
+ organizationId: settlement.organizationId,
530
+ description: transaction.description ?? transaction.type,
531
+ occurredAt,
532
+ });
533
+ reported.charge(charge);
534
+ await this.#storePaidFeesForTransaction(transaction, settlement, reported, { paymentId: null });
535
+ return;
536
+ }
537
+
538
+ case 'payout':
539
+ // The payout transaction is the payout itself
540
+ return;
541
+
542
+ default:
543
+ throw new SimpleError({
544
+ code: 'unknown_balance_transaction_type',
545
+ message: 'Unknown balance transaction type ' + transaction.type + ' for transaction ' + transaction.id,
546
+ });
547
+ }
548
+ }
549
+
550
+ /**
551
+ * The fees the walked account paid inside this transaction: the provider's own fees, and on a
552
+ * connected account also the application fee we charged. The amounts always come from the
553
+ * balance transaction itself.
554
+ */
555
+ async #storePaidFeesForTransaction(transaction: Stripe.BalanceTransaction, settlement: Settlement, reported: ReportedRows, { paymentId }: { paymentId: string | null }) {
556
+ const occurredAt = new Date(transaction.created * 1000);
557
+
558
+ for (const [index, detail] of (transaction.fee_details ?? []).entries()) {
559
+ if (detail.amount === 0) {
560
+ continue;
561
+ }
562
+
563
+ if (detail.type === 'application_fee') {
564
+ await this.#storePaidApplicationFeeForTransaction(transaction, detail, settlement, reported, { paymentId });
565
+ continue;
566
+ }
567
+
568
+ const charge = await SettlementService.upsertCharge({
569
+ type: getFeeDetailType(detail, transaction),
570
+ externalId: transaction.id + ':fee:' + index,
571
+ amount: -detail.amount * 100,
572
+ settlementId: settlement.id,
573
+ // Unresolvable stays undefined: a re-sync may not clear an earlier stored link
574
+ ...(paymentId ? { paymentId } : {}),
575
+ organizationId: settlement.organizationId,
576
+ stripeAccountId: this.stripeAccount?.id ?? null,
577
+ providerInvoiceId: getStripeInvoiceId(occurredAt),
578
+ description: detail.description ?? '',
579
+ occurredAt,
580
+ });
581
+ reported.charge(charge);
582
+ }
583
+ }
584
+
585
+ /**
586
+ * On the connected account the application fee is not a separate transaction: it sits inside
587
+ * the payment's fee_details, and the fee id comes from the charge's application_fee. The two
588
+ * negative deduction rows mirror the application fee rows of the platform side, so per
589
+ * applicationFeeId both sides of one kind sum to zero. This walk fills their settlementId; the
590
+ * rows themselves usually already exist (created by the fee walk).
591
+ */
592
+ async #storePaidApplicationFeeForTransaction(transaction: Stripe.BalanceTransaction, detail: Stripe.BalanceTransaction.FeeDetail, settlement: Settlement, reported: ReportedRows, { paymentId }: { paymentId: string | null }) {
593
+ if (!this.stripeAccount) {
594
+ throw new SimpleError({
595
+ code: 'unexpected_application_fee_detail',
596
+ message: 'Transaction ' + transaction.id + ' on the platform account has an application_fee fee detail',
597
+ });
598
+ }
599
+
600
+ if (detail.amount < 0) {
601
+ // A refunded fee inside an organization payout walk: give it its own mirrored type
602
+ // first instead of writing wrong rows
603
+ throw new SimpleError({
604
+ code: 'negative_application_fee_detail',
605
+ message: 'Transaction ' + transaction.id + ' has a negative application_fee fee detail',
606
+ });
607
+ }
608
+
609
+ const source = transaction.source;
610
+ if (!source || typeof source === 'string' || source.object !== 'charge') {
611
+ throw new SimpleError({
612
+ code: 'missing_charge',
613
+ message: 'Transaction ' + transaction.id + ' with an application fee has no expanded charge source',
614
+ });
615
+ }
616
+
617
+ const applicationFee = source.application_fee;
618
+ if (!applicationFee || typeof applicationFee === 'string') {
619
+ throw new SimpleError({
620
+ code: 'missing_application_fee',
621
+ message: 'Charge ' + source.id + ' of transaction ' + transaction.id + ' has no expanded application fee',
622
+ });
623
+ }
624
+
625
+ // Reuse the platform-side split verbatim, so both sides of the fee (and its serviceFee
626
+ // metadata errors) can never diverge
627
+ const details = ApplicationFeeDetails.fromStripe({
628
+ source: applicationFee,
629
+ amount: detail.amount,
630
+ created: transaction.created,
631
+ });
632
+
633
+ const occurredAt = new Date(transaction.created * 1000);
634
+
635
+ for (const [type, amount] of [
636
+ [SettlementChargeType.ApplicationFeeService, details.serviceFee],
637
+ [SettlementChargeType.ApplicationFeeTransfer, details.transferFee],
638
+ ] as const) {
639
+ if (amount === 0) {
640
+ continue;
641
+ }
642
+
643
+ const charge = await SettlementService.upsertCharge({
644
+ type,
645
+ externalId: applicationFee.id + ':' + type,
646
+ amount: -amount,
647
+ settlementId: settlement.id,
648
+ applicationFeeId: applicationFee.id,
649
+ // Unresolvable stays undefined: it may not clear the link the fee walk stored
650
+ ...(paymentId ? { paymentId } : {}),
651
+ organizationId: this.stripeAccount.organizationId,
652
+ stripeAccountId: this.stripeAccount.id,
653
+ occurredAt,
654
+ });
655
+ reported.charge(charge);
656
+
657
+ // What the organization pays here is what we receive on the other side. The two are
658
+ // written from different Stripe transactions, so a divergence would silently bill the
659
+ // wrong amount
660
+ const fee = await ApplicationFee.select().where('settlementChargeId', charge.id).first(false);
661
+ if (fee && fee.amount !== -charge.amount) {
662
+ throw new SimpleError({
663
+ code: 'application_fee_mismatched',
664
+ message: 'Application fee ' + applicationFee.id + ' is stored as ' + fee.amount + ' but deducted as ' + charge.amount + ' in transaction ' + transaction.id,
665
+ });
666
+ }
667
+ }
668
+ }
669
+
670
+ /**
671
+ * The payout-time actual replaces the upfront estimate stored at payment time. A destination
672
+ * charge appears gross in both the organization payout and the platform payout: only the
673
+ * payout of the payment's own account may correct the fee, the same scoping rule as the
674
+ * legacy blob.
675
+ */
676
+ async #updateTransferFee(transaction: Stripe.BalanceTransaction, settlement: Settlement, payment: Payment) {
677
+ if (payment.stripeAccountId !== settlement.stripeAccountId) {
678
+ return;
679
+ }
680
+
681
+ // A charge that doesn't cover the payment's full price 1:1 can't attribute its fees to
682
+ // the payment as-is
683
+ if (payment.price !== transaction.amount * 100) {
684
+ return;
685
+ }
686
+
687
+ const source = transaction.source;
688
+ if (!source || typeof source === 'string' || source.object !== 'charge') {
689
+ return;
690
+ }
691
+
692
+ // What the organization actually paid on this transaction. On a destination charge that is
693
+ // only our application fee; on a direct charge the transaction's fee also holds Stripe's
694
+ // own processing fee, which the application fee detail separates out
695
+ const applicationFeeDetail = (transaction.fee_details ?? []).find(detail => detail.type === 'application_fee');
696
+ const totalFees = applicationFeeDetail
697
+ ? applicationFeeDetail.amount
698
+ : Math.max(transaction.fee, source.application_fee_amount ?? 0);
699
+
700
+ payment.transferFee = totalFees * 100 - payment.serviceFeePayout;
701
+ await payment.save();
702
+ }
703
+
704
+ async #resolvePayment(transaction: Stripe.BalanceTransaction): Promise<Payment> {
705
+ const source = transaction.source;
706
+ if (!source || typeof source === 'string' || source.object !== 'charge') {
707
+ throw new SimpleError({
708
+ code: 'missing_charge',
709
+ message: 'Balance transaction ' + transaction.id + ' has no expanded charge source',
710
+ });
711
+ }
712
+
713
+ const paymentId = await getPaymentIdForStripeCharge(source, {
714
+ stripePlatform: this.stripePlatform,
715
+ });
716
+
717
+ if (!paymentId) {
718
+ throw new SimpleError({
719
+ code: 'payment_not_found',
720
+ message: 'No payment found for charge ' + source.id + ' in transaction ' + transaction.id,
721
+ });
722
+ }
723
+
724
+ const payment = await Payment.getByID(paymentId);
725
+ if (!payment) {
726
+ throw new SimpleError({
727
+ code: 'payment_not_found',
728
+ message: 'Payment ' + paymentId + ' referenced by charge ' + source.id + ' does not exist',
729
+ });
730
+ }
731
+
732
+ this.#assertPaymentScope(payment, source.id);
733
+ return payment;
734
+ }
735
+
736
+ /**
737
+ * Charge metadata is writable by the connected account's owner: a walked payment must belong
738
+ * to the walked account, or spoofed metadata could attach another organization's payment.
739
+ */
740
+ #assertPaymentScope(payment: Payment, chargeId: string) {
741
+ if (this.stripeAccount && payment.stripeAccountId !== this.stripeAccount.id) {
742
+ throw new SimpleError({
743
+ code: 'payment_scope_mismatch',
744
+ message: 'Payment ' + payment.id + ' referenced by charge ' + chargeId + ' does not belong to Stripe account ' + this.stripeAccount.accountId,
745
+ });
746
+ }
747
+ }
748
+
749
+ /**
750
+ * The payment a refund transaction reverses: Stripe's refund points at the original charge.
751
+ */
752
+ async #resolveRefundedPayment(transaction: Stripe.BalanceTransaction): Promise<Payment> {
753
+ const source = transaction.source;
754
+ if (!source || typeof source === 'string' || source.object !== 'refund') {
755
+ throw new SimpleError({
756
+ code: 'missing_refund',
757
+ message: 'Balance transaction ' + transaction.id + ' has no expanded refund source',
758
+ });
759
+ }
760
+
761
+ if (!source.charge) {
762
+ throw new SimpleError({
763
+ code: 'missing_refund_charge',
764
+ message: 'Refund ' + source.id + ' in transaction ' + transaction.id + ' has no charge',
765
+ });
766
+ }
767
+
768
+ const charge = await this.#getCharge(source.charge, transaction);
769
+ const originalPaymentId = await getPaymentIdForStripeCharge(charge, {
770
+ stripePlatform: this.stripePlatform,
771
+ });
772
+
773
+ if (!originalPaymentId) {
774
+ throw new SimpleError({
775
+ code: 'payment_not_found',
776
+ message: 'No payment found for refunded charge ' + charge.id + ' in transaction ' + transaction.id,
777
+ });
778
+ }
779
+
780
+ const payment = await Payment.getByID(originalPaymentId);
781
+ if (!payment) {
782
+ throw new SimpleError({
783
+ code: 'payment_not_found',
784
+ message: 'Payment ' + originalPaymentId + ' referenced by refunded charge ' + charge.id + ' does not exist',
785
+ });
786
+ }
787
+
788
+ this.#assertPaymentScope(payment, charge.id);
789
+ return payment;
790
+ }
791
+
792
+ /**
793
+ * Locally a refund is its own Payment linked through reversingPaymentId, matched on amount. No
794
+ * match means someone refunded outside Stamhoofd: fix the data, don't paper over it.
795
+ *
796
+ * `negated` matches a transaction that moves the money the other way (a refund that came back)
797
+ * against the same reversing payment.
798
+ */
799
+ async #resolveReversingPayment(transaction: Stripe.BalanceTransaction, refunded: Payment, { negated = false }: { negated?: boolean } = {}): Promise<Payment> {
800
+ let candidates = await Payment.select()
801
+ .where('reversingPaymentId', refunded.id)
802
+ .where('price', (negated ? -transaction.amount : transaction.amount) * 100)
803
+ // A refund that failed locally keeps its payment: it never moved money, so it can't be
804
+ // the one this transaction settles
805
+ .where('status', '!=', PaymentStatus.Failed)
806
+ .fetch();
807
+
808
+ if (candidates.length > 1) {
809
+ // Two refunds of the same amount are interchangeable (same original payment, same
810
+ // price): drop the ones already matched to a different provider transaction, then pair
811
+ // deterministically so re-syncs keep the same pairing
812
+ const lines = await PaymentSettlement.select()
813
+ .where('paymentId', candidates.map(c => c.id))
814
+ .fetch();
815
+ const claimed = new Set(lines.filter(l => l.externalId !== transaction.id).map(l => l.paymentId));
816
+ candidates = candidates
817
+ .filter(c => !claimed.has(c.id))
818
+ .sort((a, b) => (a.createdAt.getTime() - b.createdAt.getTime()) || a.id.localeCompare(b.id))
819
+ .slice(0, 1);
820
+ }
821
+
822
+ if (candidates.length !== 1) {
823
+ throw new SimpleError({
824
+ code: 'reversing_payment_not_found',
825
+ message: 'Found ' + candidates.length + ' reversing payments for payment ' + refunded.id + ' with amount ' + (transaction.amount * 100) + ' (transaction ' + transaction.id + ')',
826
+ });
827
+ }
828
+
829
+ this.#assertPaymentScope(candidates[0], refunded.id);
830
+ return candidates[0];
831
+ }
832
+
833
+ /**
834
+ * Returns the payment id associated with a dispute, dispute reversal, failed refund.
835
+ *
836
+ * Only when this payout's organization owns that payment: the liability for a dispute on a
837
+ * destination charge lands on our platform balance, while the payment itself belongs to the
838
+ * connected organization. The cost is still stored (it is deducted here), just without a link
839
+ * to another organization's payment.
840
+ */
841
+ async #tryResolveAdjustmentPayment(transaction: Stripe.BalanceTransaction, settlement: Settlement): Promise<string | null> {
842
+ const source = transaction.source;
843
+ if (!source || typeof source === 'string') {
844
+ return null;
845
+ }
846
+
847
+ const charge = (source as { charge?: string | Stripe.Charge }).charge;
848
+ if (!charge) {
849
+ return null;
850
+ }
851
+
852
+ let paymentId: string | null;
853
+ try {
854
+ paymentId = await getPaymentIdForStripeCharge(await this.#getCharge(charge, transaction), {
855
+ stripePlatform: this.stripePlatform,
856
+ });
857
+ } catch (e) {
858
+ // A charge that no longer exists is "not resolvable"; transient errors must still fail
859
+ // the payout
860
+ if (e instanceof SimpleError && e.code === 'charge_not_found') {
861
+ return null;
862
+ }
863
+ throw e;
864
+ }
865
+
866
+ if (!paymentId) {
867
+ return null;
868
+ }
869
+
870
+ const payment = await Payment.getByID(paymentId);
871
+ if (!payment || payment.organizationId !== settlement.organizationId) {
872
+ return null;
873
+ }
874
+ return paymentId;
875
+ }
876
+
877
+ /**
878
+ * Stripe caps `expand` at 4 paths, so a nested object can still come back as an id: fetch it
879
+ * explicitly then (cached per run).
880
+ */
881
+ async #getCharge(charge: string | Stripe.Charge, transaction: Stripe.BalanceTransaction): Promise<Stripe.Charge> {
882
+ if (typeof charge !== 'string') {
883
+ return charge;
884
+ }
885
+
886
+ const cached = this.fetchedCharges.get(charge);
887
+ if (cached) {
888
+ return cached;
889
+ }
890
+
891
+ try {
892
+ const fetched = await this.stripe.charges.retrieve(charge);
893
+ this.fetchedCharges.set(charge, fetched);
894
+ return fetched;
895
+ } catch (e) {
896
+ if ((e as { statusCode?: number }).statusCode === 404) {
897
+ throw new SimpleError({
898
+ code: 'charge_not_found',
899
+ message: 'Charge ' + charge + ' of transaction ' + transaction.id + ' does not exist',
900
+ });
901
+ }
902
+ throw e;
903
+ }
904
+ }
905
+ }
906
+
907
+ function getSettlementStatus(status: string): SettlementStatus {
908
+ switch (status) {
909
+ case 'paid': return SettlementStatus.Paid;
910
+ case 'pending':
911
+ case 'in_transit': return SettlementStatus.Pending;
912
+ case 'failed': return SettlementStatus.Failed;
913
+ case 'canceled': return SettlementStatus.Canceled;
914
+ default:
915
+ throw new SimpleError({
916
+ code: 'unknown_payout_status',
917
+ message: 'Unknown payout status ' + status,
918
+ });
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Stripe has no invoice id in the API, so the derived monthly id groups fee rows per invoice
924
+ * document.
925
+ */
926
+ function getStripeInvoiceId(occurredAt: Date): string {
927
+ return 'stripe-' + occurredAt.getFullYear() + '-' + (occurredAt.getMonth() + 1).toString().padStart(2, '0');
928
+ }
929
+
930
+ function getFeeDetailType(detail: Stripe.BalanceTransaction.FeeDetail, transaction: Stripe.BalanceTransaction): SettlementChargeType {
931
+ switch (detail.type as string) {
932
+ // payment_method_passthrough_fee: network costs itemized on top of the processing fee
933
+ case 'stripe_fee':
934
+ case 'payment_method_passthrough_fee':
935
+ return SettlementChargeType.ProviderTransactionFee;
936
+
937
+ // withheld_tax: tax the provider withholds and remits itself
938
+ case 'tax':
939
+ case 'withheld_tax':
940
+ return SettlementChargeType.Tax;
941
+ default:
942
+ throw new SimpleError({
943
+ code: 'unknown_fee_detail_type',
944
+ message: 'Unknown fee detail type ' + detail.type + ' on transaction ' + transaction.id,
945
+ });
946
+ }
947
+ }