@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.
- package/package.json +17 -17
- package/src/crons/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +388 -0
- package/src/helpers/SettlementExporter.ts +593 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +997 -0
- package/src/helpers/StripeSettlementSync.ts +1053 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
- package/src/helpers/WebmasterReport.test.ts +109 -0
- package/src/helpers/WebmasterReport.ts +115 -0
- package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
- package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +301 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +632 -0
- package/src/services/SettlementService.ts +650 -0
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/helpers/MollieMocker.ts +64 -6
- package/tests/helpers/StripeMocker.ts +209 -17
- package/src/crons/stripe-payout-reports.ts +0 -69
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
- package/src/helpers/CheckSettlements.test.ts +0 -190
- package/src/helpers/CheckSettlements.ts +0 -237
- package/src/helpers/StripeInvoicer.ts +0 -419
- package/src/helpers/StripePayoutChecker.ts +0 -193
- package/src/helpers/StripePayoutExportData.ts +0 -195
- package/src/helpers/StripePayoutExportExcel.ts +0 -280
- package/src/helpers/StripePayoutReporter.test.ts +0 -419
- 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
|
+
}
|