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