@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,997 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import type { Organization, StripeAccount } from '@stamhoofd/models';
3
+ import { OrganizationFactory, Payment, Platform } from '@stamhoofd/models';
4
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
5
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
6
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
7
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
8
+ import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
9
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
10
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
11
+ import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+
14
+ import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
15
+ import type { StripeObject } from '../../tests/helpers/StripeMocker.js';
16
+ import { StripeSettlementSync } from './StripeSettlementSync.js';
17
+ import { StripeSettlementSyncRunner } from './StripeSettlementSyncRunner.js';
18
+ import { WebmasterReport } from './WebmasterReport.js';
19
+
20
+ describe('StripeSettlementSync', () => {
21
+ const stripeMocker = new StripeMocker();
22
+ let organization: Organization;
23
+ let stripeAccount: StripeAccount;
24
+
25
+ const start = new Date(2026, 0, 1);
26
+ const created = new Date(2026, 0, 15);
27
+ const arrivalDate = new Date(2026, 0, 20);
28
+
29
+ let membershipOrganization: Organization;
30
+
31
+ beforeAll(async () => {
32
+ stripeMocker.start();
33
+ organization = await new OrganizationFactory({}).create();
34
+ stripeAccount = await stripeMocker.createStripeAccount(organization.id);
35
+
36
+ // Platform payouts belong to the membership organization
37
+ membershipOrganization = await new OrganizationFactory({}).create();
38
+ const platform = await Platform.getForEditing();
39
+ platform.membershipOrganizationId = membershipOrganization.id;
40
+ await platform.save();
41
+ });
42
+
43
+ afterAll(() => {
44
+ stripeMocker.stop();
45
+ });
46
+
47
+ beforeEach(() => {
48
+ stripeMocker.clear();
49
+ StripeSettlementSync.resetWarnings();
50
+ });
51
+
52
+ const createSync = () => new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
53
+ const createConnectedSync = () => new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY!, stripeAccount });
54
+
55
+ const createPayment = async ({ price = 100_00_00, type = PaymentType.Payment, reversingPaymentId = null as string | null } = {}) => {
56
+ const payment = new Payment();
57
+ payment.organizationId = organization.id;
58
+ payment.stripeAccountId = stripeAccount.id;
59
+ payment.method = PaymentMethod.Bancontact;
60
+ payment.provider = PaymentProvider.Stripe;
61
+ payment.status = PaymentStatus.Succeeded;
62
+ payment.type = type;
63
+ payment.price = price;
64
+ payment.reversingPaymentId = reversingPaymentId;
65
+ payment.paidAt = created;
66
+ await payment.save();
67
+ return payment;
68
+ };
69
+
70
+ const getSettlement = async (payout: StripeObject) => {
71
+ return await Settlement.select().where('externalId', payout.id).first(true);
72
+ };
73
+
74
+ test('a full destination-charge payout reconciles to zero', async () => {
75
+ const payment = await createPayment({ price: 100_00_00 });
76
+ const payout = stripeMocker.createPayout({ amount: 225, arrivalDate });
77
+
78
+ const chargeTxn = stripeMocker.createBalanceTransaction({
79
+ type: 'charge',
80
+ amount: 10000,
81
+ fee: 25,
82
+ fee_details: [{ type: 'stripe_fee', amount: 25, description: 'Stripe processing fees' }],
83
+ created,
84
+ payout: payout.id,
85
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
86
+ });
87
+
88
+ stripeMocker.createBalanceTransaction({
89
+ type: 'transfer',
90
+ amount: -10000,
91
+ created,
92
+ payout: payout.id,
93
+ source: { object: 'transfer', id: stripeMocker.createId('tr'), destination: stripeAccount.accountId },
94
+ });
95
+
96
+ stripeMocker.createBalanceTransaction({
97
+ type: 'application_fee',
98
+ amount: 250,
99
+ created,
100
+ payout: payout.id,
101
+ source: stripeMocker.createApplicationFee({
102
+ amount: 250,
103
+ account: stripeAccount.accountId,
104
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payment.id, serviceFee: '30' } }),
105
+ }),
106
+ });
107
+
108
+ stripeMocker.createBalanceTransaction({
109
+ type: 'payout',
110
+ amount: -225,
111
+ created,
112
+ payout: payout.id,
113
+ source: null,
114
+ });
115
+
116
+ const result = await createSync().syncPayouts({ start });
117
+ expect(result).toEqual({ synced: 1, skipped: 0, failed: 0 });
118
+
119
+ const settlement = await getSettlement(payout);
120
+ expect(settlement).toMatchObject({
121
+ provider: PaymentProvider.Stripe,
122
+ stripeAccountId: null,
123
+ organizationId: membershipOrganization.id,
124
+ amount: 2_25_00,
125
+ unexplainedAmount: 0,
126
+ transactionCount: 4,
127
+ });
128
+ expect(settlement.syncedAt).not.toBeNull();
129
+
130
+ // The payment belongs to the connected organization: it only passes through our balance on
131
+ // its way there, so nothing of it is stored here. What remains is what Stripe charged us
132
+ const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
133
+ expect(lines).toHaveLength(0);
134
+
135
+ const charges = await SettlementCharge.select().where('settlementId', settlement.id).fetch();
136
+ expect(charges).toHaveLength(1);
137
+ expect(charges[0]).toMatchObject({
138
+ type: SettlementChargeType.ProviderTransactionFee,
139
+ amount: -25_00,
140
+ paymentId: null,
141
+ providerInvoiceId: 'stripe-2026-01',
142
+ });
143
+
144
+ // The received fees are application fee rows, waiting to be invoiced
145
+ const fees = await ApplicationFee.select().where('settlementId', settlement.id).fetch();
146
+ const feeByType = new Map(fees.map(f => [f.type, f]));
147
+ expect(feeByType.get(ApplicationFeeType.Service)).toMatchObject({
148
+ amount: 30_00,
149
+ organizationId: membershipOrganization.id,
150
+ payingOrganizationId: organization.id,
151
+ payingStripeAccountId: stripeAccount.id,
152
+ payingPaymentId: payment.id,
153
+ balanceItemId: null,
154
+ });
155
+ expect(feeByType.get(ApplicationFeeType.Transfer)!.amount).toBe(2_20_00);
156
+ expect(settlement.pendingFees).toBe(2_50_00);
157
+
158
+ // The legacy blob only ever shows the payout of the payment's own account: after just the
159
+ // platform walk it stays untouched (the connected walk writes it)
160
+ const updated = await Payment.getByID(payment.id);
161
+ expect(updated!.settlement).toBeNull();
162
+ });
163
+
164
+ test('a refund is stored on the reversing payment', async () => {
165
+ const original = await createPayment({ price: 100_00_00 });
166
+ const refund = await createPayment({ price: -20_00_00, type: PaymentType.Refund, reversingPaymentId: original.id });
167
+
168
+ // The organization's own payout settles its payments
169
+ const payout = stripeMocker.createPayout({ amount: 8000, arrivalDate, stripeAccount: stripeAccount.accountId });
170
+ stripeMocker.createBalanceTransaction({
171
+ type: 'payment',
172
+ amount: 10000,
173
+ created,
174
+ payout: payout.id,
175
+ stripeAccount: stripeAccount.accountId,
176
+ source: stripeMocker.createChargeObject({ metadata: { payment: original.id } }),
177
+ });
178
+ stripeMocker.createBalanceTransaction({
179
+ type: 'payment_refund',
180
+ amount: -2000,
181
+ created,
182
+ payout: payout.id,
183
+ stripeAccount: stripeAccount.accountId,
184
+ source: {
185
+ object: 'refund',
186
+ id: stripeMocker.createId('re'),
187
+ charge: stripeMocker.createChargeObject({ metadata: { payment: original.id } }),
188
+ },
189
+ });
190
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -8000, created, payout: payout.id, stripeAccount: stripeAccount.accountId, source: null });
191
+
192
+ const result = await createConnectedSync().syncPayouts({ start });
193
+ expect(result).toEqual({ synced: 1, skipped: 0, failed: 0 });
194
+
195
+ const settlement = await getSettlement(payout);
196
+ expect(settlement.unexplainedAmount).toBe(0);
197
+
198
+ const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
199
+ expect(lines.map(l => [l.paymentId, l.amount]).sort()).toEqual([
200
+ [original.id, 100_00_00],
201
+ [refund.id, -20_00_00],
202
+ ].sort());
203
+ });
204
+
205
+ test('a refund without reversing payment fails the payout', async () => {
206
+ const original = await createPayment({ price: 100_00_00 });
207
+
208
+ const payout = stripeMocker.createPayout({ amount: -2000, arrivalDate, stripeAccount: stripeAccount.accountId });
209
+ stripeMocker.createBalanceTransaction({
210
+ type: 'payment_refund',
211
+ amount: -2000,
212
+ created,
213
+ payout: payout.id,
214
+ stripeAccount: stripeAccount.accountId,
215
+ source: {
216
+ object: 'refund',
217
+ id: stripeMocker.createId('re'),
218
+ charge: stripeMocker.createChargeObject({ metadata: { payment: original.id } }),
219
+ },
220
+ });
221
+
222
+ const result = await createConnectedSync().syncPayouts({ start });
223
+ expect(result).toEqual({ synced: 0, skipped: 0, failed: 1 });
224
+
225
+ const settlement = await getSettlement(payout);
226
+ expect(settlement.syncedAt).toBeNull();
227
+ expect(settlement.syncFailureCount).toBe(1);
228
+ expect(await PaymentSettlement.select().where('settlementId', settlement.id).count()).toBe(0);
229
+ });
230
+
231
+ test('two identical refunds pair deterministically with the two chargebacks', async () => {
232
+ const original = await createPayment({ price: 100_00_00 });
233
+ const first = await createPayment({ price: -20_00_00, type: PaymentType.Refund, reversingPaymentId: original.id });
234
+ const second = await createPayment({ price: -20_00_00, type: PaymentType.Refund, reversingPaymentId: original.id });
235
+
236
+ const payout = stripeMocker.createPayout({ amount: -4000, arrivalDate, stripeAccount: stripeAccount.accountId });
237
+ for (const _ of [first, second]) {
238
+ stripeMocker.createBalanceTransaction({
239
+ type: 'payment_refund',
240
+ amount: -2000,
241
+ created,
242
+ payout: payout.id,
243
+ stripeAccount: stripeAccount.accountId,
244
+ source: {
245
+ object: 'refund',
246
+ id: stripeMocker.createId('re'),
247
+ charge: stripeMocker.createChargeObject({ metadata: { payment: original.id } }),
248
+ },
249
+ });
250
+ }
251
+
252
+ const sync = createConnectedSync();
253
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
254
+
255
+ const settlement = await getSettlement(payout);
256
+ const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
257
+ expect(lines.map(l => l.paymentId).sort()).toEqual([first.id, second.id].sort());
258
+
259
+ // Re-running keeps the same pairing
260
+ const pairing = new Map(lines.map(l => [l.externalId, l.paymentId]));
261
+ expect(await sync.syncPayouts({ start, force: true })).toEqual({ synced: 1, skipped: 0, failed: 0 });
262
+ const after = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
263
+ for (const line of after) {
264
+ expect(line.paymentId).toBe(pairing.get(line.externalId));
265
+ }
266
+ });
267
+
268
+ test('an unknown fee detail type fails the payout', async () => {
269
+ const payment = await createPayment();
270
+ const payout = stripeMocker.createPayout({ amount: 9975, arrivalDate });
271
+ stripeMocker.createBalanceTransaction({
272
+ type: 'charge',
273
+ amount: 10000,
274
+ fee: 25,
275
+ fee_details: [{ type: 'weird_fee', amount: 25 }],
276
+ created,
277
+ payout: payout.id,
278
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
279
+ });
280
+
281
+ const result = await createSync().syncPayouts({ start });
282
+ expect(result).toEqual({ synced: 0, skipped: 0, failed: 1 });
283
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
284
+ });
285
+
286
+ test('an unknown transaction type fails the payout instead of inventing a row', async () => {
287
+ const payout = stripeMocker.createPayout({ amount: 1000, arrivalDate });
288
+ stripeMocker.createBalanceTransaction({
289
+ type: 'issuing_authorization_hold',
290
+ amount: 1000,
291
+ created,
292
+ payout: payout.id,
293
+ source: null,
294
+ });
295
+
296
+ const result = await createSync().syncPayouts({ start });
297
+ expect(result).toEqual({ synced: 0, skipped: 0, failed: 1 });
298
+
299
+ const settlement = await getSettlement(payout);
300
+ expect(settlement.syncedAt).toBeNull();
301
+ });
302
+
303
+ test('balance movements around the payouts themselves are stored, not refused', async () => {
304
+ const payout = stripeMocker.createPayout({ amount: 5000, arrivalDate });
305
+ for (const type of ['payout_failure', 'topup', 'connect_collection_transfer']) {
306
+ stripeMocker.createBalanceTransaction({
307
+ type,
308
+ amount: type === 'connect_collection_transfer' ? 1000 : 2000,
309
+ created,
310
+ payout: payout.id,
311
+ source: null,
312
+ });
313
+ }
314
+
315
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
316
+
317
+ const settlement = await getSettlement(payout);
318
+ const charges = await SettlementCharge.select().where('settlementId', settlement.id).fetch();
319
+ expect(charges).toHaveLength(3);
320
+ expect(charges.every(c => c.type === SettlementChargeType.BalanceMovement)).toBe(true);
321
+ expect(settlement.unexplainedAmount).toBe(0);
322
+ });
323
+
324
+ test('a payout Stripe has not reconciled yet is left for the next run', async () => {
325
+ const payout = stripeMocker.createPayout({ amount: 1000, arrivalDate, reconciliation_status: 'in_progress' });
326
+ stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: payout.id, source: null });
327
+
328
+ // Stripe would return no transactions for it: syncing anyway would store an empty payout
329
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 1, failed: 0 });
330
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
331
+ });
332
+
333
+ test('a payout Stripe never reports the transactions of fails loudly', async () => {
334
+ const payout = stripeMocker.createPayout({ amount: 1000, arrivalDate, reconciliation_status: 'not_applicable' });
335
+
336
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
337
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
338
+ });
339
+
340
+ test('a payout without transactions is not stored as a complete sync', async () => {
341
+ const payout = stripeMocker.createPayout({ amount: 1000, arrivalDate });
342
+
343
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
344
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
345
+ });
346
+
347
+ test('failing payouts are reported in one email', async () => {
348
+ const first = stripeMocker.createPayout({ amount: 1000, arrivalDate });
349
+ const second = stripeMocker.createPayout({ amount: 2000, arrivalDate });
350
+
351
+ await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
352
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 2 });
353
+ });
354
+
355
+ // Emails of the other tests in this file can still be in the queue: only this group counts
356
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Synchroniseren uitbetalingen'));
357
+ expect(emails).toHaveLength(1);
358
+ expect(emails[0].html).toContain(first.id);
359
+ expect(emails[0].html).toContain(second.id);
360
+ });
361
+
362
+ test('a payout that failed after being paid keeps its status current', async () => {
363
+ const payment = await createPayment();
364
+ const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate, stripeAccount: stripeAccount.accountId });
365
+ stripeMocker.createBalanceTransaction({
366
+ type: 'payment',
367
+ amount: 10000,
368
+ created,
369
+ payout: payout.id,
370
+ stripeAccount: stripeAccount.accountId,
371
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
372
+ });
373
+
374
+ const sync = createConnectedSync();
375
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
376
+ expect((await getSettlement(payout)).status).toBe(SettlementStatus.Paid);
377
+
378
+ // Stripe can still flip a paid payout to failed within five business days
379
+ payout.status = 'failed';
380
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 0, skipped: 1, failed: 0 });
381
+ expect((await getSettlement(payout)).status).toBe(SettlementStatus.Failed);
382
+ });
383
+
384
+ test('a payout with more transactions than one page is walked completely', async () => {
385
+ const payout = stripeMocker.createPayout({ amount: -101 * 100, arrivalDate });
386
+ for (let i = 0; i < 101; i++) {
387
+ stripeMocker.createBalanceTransaction({
388
+ type: 'stripe_fee',
389
+ amount: -100,
390
+ description: 'Billing - Usage Fee',
391
+ created,
392
+ payout: payout.id,
393
+ source: null,
394
+ });
395
+ }
396
+
397
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
398
+
399
+ const settlement = await getSettlement(payout);
400
+ expect(settlement.transactionCount).toBe(101);
401
+ expect(settlement.unexplainedAmount).toBe(0);
402
+ });
403
+
404
+ test('a payout in another currency is refused instead of stored as euros', async () => {
405
+ const payout = stripeMocker.createPayout({ amount: 1000, arrivalDate, currency: 'usd' });
406
+
407
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
408
+ expect(await Settlement.select().where('externalId', payout.id).first(false)).toBeNull();
409
+ });
410
+
411
+ test('a refunded application fee fails the payout instead of billing it in full', async () => {
412
+ const payout = stripeMocker.createPayout({ amount: -100, arrivalDate });
413
+ stripeMocker.createBalanceTransaction({
414
+ type: 'application_fee_refund',
415
+ amount: -100,
416
+ created,
417
+ payout: payout.id,
418
+ source: { object: 'fee_refund', id: stripeMocker.createId('fr'), fee: 'fee_original' },
419
+ });
420
+
421
+ // We never refund application fees ourselves: what to give back to the organization is a
422
+ // decision someone has to make, so the payout waits instead of silently over-billing
423
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
424
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
425
+ });
426
+
427
+ test('account fees, reserves and disputes become their own rows', async () => {
428
+ const payment = await createPayment();
429
+
430
+ const payout = stripeMocker.createPayout({ amount: -3500, arrivalDate });
431
+ stripeMocker.createBalanceTransaction({
432
+ type: 'stripe_fee',
433
+ amount: -500,
434
+ description: 'Billing - Usage Fee',
435
+ created,
436
+ payout: payout.id,
437
+ source: null,
438
+ });
439
+ stripeMocker.createBalanceTransaction({
440
+ type: 'reserve_transaction',
441
+ amount: -1000,
442
+ created,
443
+ payout: payout.id,
444
+ source: null,
445
+ });
446
+ stripeMocker.createBalanceTransaction({
447
+ type: 'adjustment',
448
+ amount: -2000,
449
+ created,
450
+ payout: payout.id,
451
+ source: { object: 'dispute', id: stripeMocker.createId('dp'), charge: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }) },
452
+ });
453
+
454
+ const result = await createSync().syncPayouts({ start });
455
+ expect(result).toEqual({ synced: 1, skipped: 0, failed: 0 });
456
+
457
+ const settlement = await getSettlement(payout);
458
+ expect(settlement.unexplainedAmount).toBe(0);
459
+
460
+ const charges = await SettlementCharge.select().where('settlementId', settlement.id).fetch();
461
+ const byType = new Map(charges.map(c => [c.type, c]));
462
+
463
+ expect(byType.get(SettlementChargeType.ProviderAccountFee)).toMatchObject({
464
+ amount: -5_00_00,
465
+ description: 'Billing - Usage Fee',
466
+ providerInvoiceId: 'stripe-2026-01',
467
+ });
468
+ expect(byType.get(SettlementChargeType.Reserve)).toMatchObject({ amount: -10_00_00 });
469
+
470
+ // The dispute is deducted from our balance, but the disputed payment belongs to the
471
+ // connected organization: the cost is ours, the payment link would cross organizations
472
+ expect(byType.get(SettlementChargeType.Adjustment)).toMatchObject({
473
+ amount: -20_00_00,
474
+ paymentId: null,
475
+ organizationId: membershipOrganization.id,
476
+ });
477
+ });
478
+
479
+ test('a dispute on the organization\'s own payout stays linked to its payment', async () => {
480
+ const payment = await createPayment();
481
+
482
+ const payout = stripeMocker.createPayout({ amount: -2000, arrivalDate, stripeAccount: stripeAccount.accountId });
483
+ stripeMocker.createBalanceTransaction({
484
+ type: 'adjustment',
485
+ amount: -2000,
486
+ created,
487
+ payout: payout.id,
488
+ stripeAccount: stripeAccount.accountId,
489
+ source: { object: 'dispute', id: stripeMocker.createId('dp'), charge: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }) },
490
+ });
491
+
492
+ expect(await createConnectedSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
493
+
494
+ const settlement = await getSettlement(payout);
495
+ const charges = await SettlementCharge.select().where('settlementId', settlement.id).fetch();
496
+ expect(charges).toHaveLength(1);
497
+ expect(charges[0]).toMatchObject({
498
+ type: SettlementChargeType.Adjustment,
499
+ amount: -20_00_00,
500
+ paymentId: payment.id,
501
+ organizationId: organization.id,
502
+ });
503
+ });
504
+
505
+ test('re-running skips synced payouts and stays idempotent under force', async () => {
506
+ const payment = await createPayment();
507
+ const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate, stripeAccount: stripeAccount.accountId });
508
+ stripeMocker.createBalanceTransaction({
509
+ type: 'payment',
510
+ amount: 10000,
511
+ created,
512
+ payout: payout.id,
513
+ stripeAccount: stripeAccount.accountId,
514
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
515
+ });
516
+
517
+ const sync = createConnectedSync();
518
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
519
+
520
+ const settlement = await getSettlement(payout);
521
+ const rowIds = (await PaymentSettlement.select().where('settlementId', settlement.id).fetch()).map(r => r.id);
522
+
523
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 0, skipped: 1, failed: 0 });
524
+ expect(await createConnectedSync().syncPayouts({ start, force: true })).toEqual({ synced: 1, skipped: 0, failed: 0 });
525
+
526
+ const after = (await PaymentSettlement.select().where('settlementId', settlement.id).fetch()).map(r => r.id);
527
+ expect(after).toEqual(rowIds);
528
+ });
529
+
530
+ test('a payment that moved to another payout is swept', async () => {
531
+ const payment = await createPayment();
532
+ const otherPayment = await createPayment({ price: 50_00_00 });
533
+ const payout = stripeMocker.createPayout({ amount: 15000, arrivalDate, stripeAccount: stripeAccount.accountId });
534
+
535
+ stripeMocker.createBalanceTransaction({
536
+ type: 'payment',
537
+ amount: 10000,
538
+ created,
539
+ payout: payout.id,
540
+ stripeAccount: stripeAccount.accountId,
541
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
542
+ });
543
+ const moved = stripeMocker.createBalanceTransaction({
544
+ type: 'payment',
545
+ amount: 5000,
546
+ created,
547
+ payout: payout.id,
548
+ stripeAccount: stripeAccount.accountId,
549
+ source: stripeMocker.createChargeObject({ metadata: { payment: otherPayment.id } }),
550
+ });
551
+
552
+ const sync = createConnectedSync();
553
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
554
+
555
+ const settlement = await getSettlement(payout);
556
+ expect(await PaymentSettlement.select().where('settlementId', settlement.id).count()).toBe(2);
557
+
558
+ // Stripe moved the second payment to a later payout
559
+ moved.payout = stripeMocker.createId('po');
560
+ payout.amount = 10000;
561
+
562
+ expect(await sync.syncPayouts({ start, force: true })).toEqual({ synced: 1, skipped: 0, failed: 0 });
563
+
564
+ const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
565
+ expect(lines).toHaveLength(1);
566
+ expect(lines[0].paymentId).toBe(payment.id);
567
+ expect((await getSettlement(payout)).unexplainedAmount).toBe(0);
568
+ });
569
+
570
+ test('an application fee that moved to another payout is only unlinked', async () => {
571
+ const payment = await createPayment();
572
+ const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
573
+ const movedFee = stripeMocker.createBalanceTransaction({
574
+ type: 'application_fee',
575
+ amount: 250,
576
+ created,
577
+ payout: payout.id,
578
+ source: stripeMocker.createApplicationFee({
579
+ amount: 250,
580
+ account: stripeAccount.accountId,
581
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payment.id, serviceFee: '30' } }),
582
+ }),
583
+ });
584
+
585
+ const sync = createSync();
586
+ expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
587
+
588
+ const settlement = await getSettlement(payout);
589
+ const applicationFeeId = ((movedFee.source as StripeObject)).id;
590
+ expect(await ApplicationFee.select().where('settlementId', settlement.id).count()).toBe(2);
591
+
592
+ movedFee.payout = stripeMocker.createId('po');
593
+ payout.amount = 0;
594
+
595
+ expect(await sync.syncPayouts({ start, force: true })).toEqual({ synced: 1, skipped: 0, failed: 0 });
596
+
597
+ // The fee rows outlive the payout: they are only unlinked, and so are the deduction
598
+ // charges they point at (a foreign key forbids deleting those)
599
+ const fees = await ApplicationFee.select().where('externalId', applicationFeeId).fetch();
600
+ expect(fees).toHaveLength(2);
601
+ for (const fee of fees) {
602
+ expect(fee.settlementId).toBeNull();
603
+ }
604
+ const deductions = await SettlementCharge.select().where('applicationFeeId', applicationFeeId).fetch();
605
+ expect(deductions).toHaveLength(2);
606
+ for (const row of deductions) {
607
+ expect(row.settlementId).toBeNull();
608
+ }
609
+
610
+ const after = await getSettlement(payout);
611
+ expect(after.unexplainedAmount).toBe(0);
612
+ expect(after.pendingFees).toBe(0);
613
+ });
614
+
615
+ describe('Payers we can no longer reach', () => {
616
+ /**
617
+ * A platform payout that only receives one application fee, plus the payout transaction.
618
+ * The metadata defaults to a payment that no longer exists.
619
+ */
620
+ const createFeePayout = (account: string, metadata: Record<string, string> = {}) => {
621
+ const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
622
+ const fee = stripeMocker.createApplicationFee({
623
+ amount: 250,
624
+ account,
625
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: uuidv4(), serviceFee: '30', ...metadata } }),
626
+ });
627
+ stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, payout: payout.id, source: fee });
628
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -250, created, payout: payout.id, source: null });
629
+ return { payout, fee };
630
+ };
631
+
632
+ test('a fee of an organization that no longer exists is stored as income without a payer', async () => {
633
+ const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { organization: uuidv4() });
634
+
635
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
636
+
637
+ const settlement = await getSettlement(payout);
638
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
639
+ expect(fees).toHaveLength(2);
640
+ for (const row of fees) {
641
+ expect(row.organizationId).toBe(membershipOrganization.id);
642
+ expect(row.settlementId).toBe(settlement.id);
643
+ expect(row.payingOrganizationId).toBeNull();
644
+ expect(row.payingStripeAccountId).toBeNull();
645
+ expect(row.payingPaymentId).toBeNull();
646
+ expect(row.settlementChargeId).toBeNull();
647
+ }
648
+
649
+ // There is no payout of theirs left to deduct it from, but ours still adds up
650
+ expect(await SettlementCharge.select().where('applicationFeeId', fee.id).count()).toBe(0);
651
+ expect(settlement.unexplainedAmount).toBe(0);
652
+ expect(settlement.pendingFees).toBe(0);
653
+ expect(settlement.uncollectibleFees).toBe(2_50_00);
654
+ });
655
+
656
+ test('a fee of a Stripe account we no longer have keeps the payer of its payment', async () => {
657
+ const payment = await createPayment();
658
+ const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { payment: payment.id });
659
+
660
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
661
+
662
+ // The cost is still attributed to the organization, so its own export shows it
663
+ const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
664
+ expect(charges).toHaveLength(2);
665
+ for (const charge of charges) {
666
+ expect(charge.organizationId).toBe(organization.id);
667
+ expect(charge.stripeAccountId).toBeNull();
668
+ expect(charge.paymentId).toBe(payment.id);
669
+ }
670
+
671
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
672
+ for (const row of fees) {
673
+ expect(row.payingOrganizationId).toBe(organization.id);
674
+ expect(row.payingStripeAccountId).toBeNull();
675
+ expect(row.payingPaymentId).toBe(payment.id);
676
+ expect(row.settlementChargeId).not.toBeNull();
677
+ }
678
+
679
+ // Without the account it was deducted from, the invoicer can't bill it per account
680
+ const settlement = await getSettlement(payout);
681
+ expect(settlement.pendingFees).toBe(0);
682
+ expect(settlement.uncollectibleFees).toBe(2_50_00);
683
+ expect(settlement.unexplainedAmount).toBe(0);
684
+ });
685
+
686
+ test('a fee whose payment is gone falls back to the organization in our charge metadata', async () => {
687
+ const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { organization: organization.id });
688
+
689
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
690
+
691
+ const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
692
+ expect(charges).toHaveLength(2);
693
+ for (const charge of charges) {
694
+ expect(charge.organizationId).toBe(organization.id);
695
+ expect(charge.paymentId).toBeNull();
696
+ }
697
+
698
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
699
+ for (const row of fees) {
700
+ expect(row.payingOrganizationId).toBe(organization.id);
701
+ expect(row.payingPaymentId).toBeNull();
702
+ }
703
+
704
+ expect((await getSettlement(payout)).uncollectibleFees).toBe(2_50_00);
705
+ });
706
+
707
+ test('every unattributable account is reported once, not once per fee', async () => {
708
+ const unknownAccount = stripeMocker.createId('acct');
709
+ createFeePayout(unknownAccount, { organization: uuidv4() });
710
+ createFeePayout(unknownAccount, { organization: uuidv4() });
711
+
712
+ await WebmasterReport.group('Onaanrekenbare applicatiekosten', async () => {
713
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 2, skipped: 0, failed: 0 });
714
+ });
715
+
716
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Onaanrekenbare applicatiekosten'));
717
+ expect(emails).toHaveLength(1);
718
+ expect(emails[0].html).toContain(unknownAccount);
719
+ expect(emails[0].subject).toContain('1 probleem');
720
+ });
721
+
722
+ test('a deleted Stripe account keeps its organization, its account and its payment', async () => {
723
+ const deletedOrganization = await new OrganizationFactory({}).create();
724
+ const deletedAccount = await stripeMocker.createStripeAccount(deletedOrganization.id);
725
+ deletedAccount.status = 'deleted';
726
+ await deletedAccount.save();
727
+
728
+ // Deleting a Stripe account is a soft delete: the organization and its payments stay
729
+ const payment = new Payment();
730
+ payment.organizationId = deletedOrganization.id;
731
+ payment.stripeAccountId = deletedAccount.id;
732
+ payment.method = PaymentMethod.Bancontact;
733
+ payment.provider = PaymentProvider.Stripe;
734
+ payment.status = PaymentStatus.Succeeded;
735
+ payment.type = PaymentType.Payment;
736
+ payment.price = 100_00_00;
737
+ payment.paidAt = created;
738
+ await payment.save();
739
+
740
+ const { payout, fee } = createFeePayout(deletedAccount.accountId, { payment: payment.id });
741
+
742
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
743
+
744
+ const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
745
+ expect(charges).toHaveLength(2);
746
+ for (const charge of charges) {
747
+ expect(charge.organizationId).toBe(deletedOrganization.id);
748
+ expect(charge.stripeAccountId).toBe(deletedAccount.id);
749
+ expect(charge.paymentId).toBe(payment.id);
750
+ }
751
+
752
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
753
+ for (const row of fees) {
754
+ expect(row.payingOrganizationId).toBe(deletedOrganization.id);
755
+ expect(row.payingStripeAccountId).toBe(deletedAccount.id);
756
+ expect(row.payingPaymentId).toBe(payment.id);
757
+ }
758
+
759
+ // Nothing was lost, so it is billed like any other month
760
+ const settlement = await getSettlement(payout);
761
+ expect(settlement.pendingFees).toBe(2_50_00);
762
+ expect(settlement.uncollectibleFees).toBe(0);
763
+ });
764
+
765
+ test('an account still in our database keeps failing the payout on an unresolvable payment', async () => {
766
+ const deletedAccount = await stripeMocker.createStripeAccount(organization.id);
767
+ deletedAccount.status = 'deleted';
768
+ await deletedAccount.save();
769
+
770
+ // A deleted account is no reason to stop checking: an organization may not attach
771
+ // another organization's payment to its fees by deleting its Stripe account first
772
+ for (const account of [stripeAccount, deletedAccount]) {
773
+ const { payout } = createFeePayout(account.accountId);
774
+
775
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
776
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
777
+ stripeMocker.clear();
778
+ }
779
+ });
780
+ });
781
+
782
+ describe('Connected accounts', () => {
783
+ test('an organization payout writes the mirrored deduction rows, summing to zero against the Received rows', async () => {
784
+ const payment = await createPayment();
785
+ const fee = stripeMocker.createApplicationFee({
786
+ amount: 250,
787
+ account: stripeAccount.accountId,
788
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payment.id, serviceFee: '30' } }),
789
+ });
790
+
791
+ // Our platform payout receives the fee
792
+ const platformPayout = stripeMocker.createPayout({ amount: 250, arrivalDate });
793
+ stripeMocker.createBalanceTransaction({
794
+ type: 'application_fee',
795
+ amount: 250,
796
+ created,
797
+ payout: platformPayout.id,
798
+ source: fee,
799
+ });
800
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -250, created, payout: platformPayout.id, source: null });
801
+
802
+ // The organization payout holds the payment, the fee sits inside its fee_details
803
+ const payout = stripeMocker.createPayout({ amount: 9725, arrivalDate, stripeAccount: stripeAccount.accountId });
804
+ stripeMocker.createBalanceTransaction({
805
+ type: 'payment',
806
+ amount: 10000,
807
+ fee: 275,
808
+ fee_details: [
809
+ { type: 'stripe_fee', amount: 25, description: 'Stripe processing fees' },
810
+ { type: 'application_fee', amount: 250, description: 'Application fee' },
811
+ ],
812
+ created,
813
+ payout: payout.id,
814
+ stripeAccount: stripeAccount.accountId,
815
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id }, application_fee: fee }),
816
+ });
817
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -9725, created, payout: payout.id, stripeAccount: stripeAccount.accountId, source: null });
818
+
819
+ expect(await new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! }).syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
820
+ expect(await new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! }).syncConnectedPayouts({ start })).toMatchObject({ synced: 1, failed: 0 });
821
+
822
+ const settlement = await getSettlement(payout);
823
+ expect(settlement).toMatchObject({
824
+ stripeAccountId: stripeAccount.id,
825
+ organizationId: organization.id,
826
+ amount: 97_25_00,
827
+ unexplainedAmount: 0,
828
+ });
829
+
830
+ const rows = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
831
+ const byType = new Map(rows.map(r => [r.type, r]));
832
+
833
+ expect(byType.get(SettlementChargeType.ApplicationFeeService)).toMatchObject({
834
+ amount: -30_00,
835
+ settlementId: settlement.id,
836
+ paymentId: payment.id,
837
+ stripeAccountId: stripeAccount.id,
838
+ });
839
+ expect(byType.get(SettlementChargeType.ApplicationFeeTransfer)).toMatchObject({ amount: -2_20_00, settlementId: settlement.id });
840
+
841
+ // Both sides of the same fee cancel out per kind: what the organization pays is what we
842
+ // receive
843
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
844
+ const feeByType = new Map(fees.map(f => [f.type, f]));
845
+ expect(feeByType.get(ApplicationFeeType.Service)!.amount + byType.get(SettlementChargeType.ApplicationFeeService)!.amount).toBe(0);
846
+ expect(feeByType.get(ApplicationFeeType.Transfer)!.amount + byType.get(SettlementChargeType.ApplicationFeeTransfer)!.amount).toBe(0);
847
+
848
+ // Each fee points at the deduction charge of its own kind
849
+ expect(feeByType.get(ApplicationFeeType.Service)!.settlementChargeId).toBe(byType.get(SettlementChargeType.ApplicationFeeService)!.id);
850
+ expect(feeByType.get(ApplicationFeeType.Transfer)!.settlementChargeId).toBe(byType.get(SettlementChargeType.ApplicationFeeTransfer)!.id);
851
+
852
+ // The legacy blob is dual-written with the payment's own payout, never our platform one
853
+ const updated = await Payment.getByID(payment.id);
854
+ expect(updated!.settlement?.id).toBe(payout.id);
855
+ });
856
+
857
+ test('the payout of the payment\'s own account corrects the transfer fee, the platform payout does not', async () => {
858
+ const payment = await createPayment();
859
+ payment.transferFee = 12_34; // the upfront estimate stored at payment time
860
+ await payment.save();
861
+
862
+ const fee = stripeMocker.createApplicationFee({
863
+ amount: 250,
864
+ account: stripeAccount.accountId,
865
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payment.id, serviceFee: '30' } }),
866
+ });
867
+
868
+ // The platform payout sees the destination charge gross, but must not touch the fee
869
+ const platformPayout = stripeMocker.createPayout({ amount: 10000, arrivalDate });
870
+ stripeMocker.createBalanceTransaction({
871
+ type: 'charge',
872
+ amount: 10000,
873
+ created,
874
+ payout: platformPayout.id,
875
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
876
+ });
877
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -10000, created, payout: platformPayout.id, source: null });
878
+
879
+ expect(await new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! }).syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
880
+ expect((await Payment.getByID(payment.id))!.transferFee).toBe(12_34);
881
+
882
+ // The organization payout holds the payment with the application fee withheld
883
+ const payout = stripeMocker.createPayout({ amount: 9750, arrivalDate, stripeAccount: stripeAccount.accountId });
884
+ stripeMocker.createBalanceTransaction({
885
+ type: 'payment',
886
+ amount: 10000,
887
+ fee: 250,
888
+ fee_details: [{ type: 'application_fee', amount: 250, description: 'Application fee' }],
889
+ created,
890
+ payout: payout.id,
891
+ stripeAccount: stripeAccount.accountId,
892
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id }, application_fee: fee }),
893
+ });
894
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -9750, created, payout: payout.id, stripeAccount: stripeAccount.accountId, source: null });
895
+
896
+ expect(await new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! }).syncConnectedPayouts({ start })).toMatchObject({ synced: 1, failed: 0 });
897
+ expect((await Payment.getByID(payment.id))!.transferFee).toBe(2_50_00);
898
+ });
899
+
900
+ test('an amount-mismatched charge does not correct the transfer fee', async () => {
901
+ const payment = await createPayment({ price: 100_00_00 });
902
+ payment.transferFee = 12_34;
903
+ await payment.save();
904
+
905
+ // The charge covers only half the payment's price, so its fees can't be attributed
906
+ const payout = stripeMocker.createPayout({ amount: 4975, arrivalDate, stripeAccount: stripeAccount.accountId });
907
+ stripeMocker.createBalanceTransaction({
908
+ type: 'payment',
909
+ amount: 5000,
910
+ fee: 25,
911
+ fee_details: [{ type: 'stripe_fee', amount: 25, description: 'Stripe processing fees' }],
912
+ created,
913
+ payout: payout.id,
914
+ stripeAccount: stripeAccount.accountId,
915
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
916
+ });
917
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -4975, created, payout: payout.id, stripeAccount: stripeAccount.accountId, source: null });
918
+
919
+ expect(await new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! }).syncConnectedPayouts({ start })).toMatchObject({ synced: 1, failed: 0 });
920
+ expect((await Payment.getByID(payment.id))!.transferFee).toBe(12_34);
921
+ });
922
+
923
+ test('spoofed charge metadata pointing at another account\'s payment fails the payout', async () => {
924
+ const otherOrganization = await new OrganizationFactory({}).create();
925
+ const otherAccount = await stripeMocker.createStripeAccount(otherOrganization.id);
926
+ const foreignPayment = new Payment();
927
+ foreignPayment.organizationId = otherOrganization.id;
928
+ foreignPayment.stripeAccountId = otherAccount.id;
929
+ foreignPayment.method = PaymentMethod.Bancontact;
930
+ foreignPayment.provider = PaymentProvider.Stripe;
931
+ foreignPayment.status = PaymentStatus.Succeeded;
932
+ foreignPayment.price = 100_00_00;
933
+ foreignPayment.paidAt = created;
934
+ await foreignPayment.save();
935
+
936
+ const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate, stripeAccount: stripeAccount.accountId });
937
+ stripeMocker.createBalanceTransaction({
938
+ type: 'payment',
939
+ amount: 10000,
940
+ created,
941
+ payout: payout.id,
942
+ stripeAccount: stripeAccount.accountId,
943
+ source: stripeMocker.createChargeObject({ metadata: { payment: foreignPayment.id } }),
944
+ });
945
+
946
+ const result = await new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY!, stripeAccount }).syncPayouts({ start });
947
+ expect(result).toEqual({ synced: 0, skipped: 0, failed: 1 });
948
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
949
+ expect(await PaymentSettlement.select().where('paymentId', foreignPayment.id).count()).toBe(0);
950
+ });
951
+
952
+ test('an application fee detail on the platform account fails the payout', async () => {
953
+ const payment = await createPayment();
954
+ const payout = stripeMocker.createPayout({ amount: 9750, arrivalDate });
955
+ stripeMocker.createBalanceTransaction({
956
+ type: 'charge',
957
+ amount: 10000,
958
+ fee: 250,
959
+ fee_details: [{ type: 'application_fee', amount: 250 }],
960
+ created,
961
+ payout: payout.id,
962
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
963
+ });
964
+
965
+ const result = await createSync().syncPayouts({ start });
966
+ expect(result).toEqual({ synced: 0, skipped: 0, failed: 1 });
967
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
968
+ });
969
+
970
+ test('a negative application fee detail fails the payout', async () => {
971
+ const payment = await createPayment();
972
+ const fee = stripeMocker.createApplicationFee({
973
+ amount: 250,
974
+ account: stripeAccount.accountId,
975
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payment.id, serviceFee: '30' } }),
976
+ });
977
+
978
+ const payout = stripeMocker.createPayout({ amount: 10250, arrivalDate, stripeAccount: stripeAccount.accountId });
979
+ stripeMocker.createBalanceTransaction({
980
+ type: 'payment',
981
+ amount: 10000,
982
+ fee: -250,
983
+ fee_details: [{ type: 'application_fee', amount: -250 }],
984
+ created,
985
+ payout: payout.id,
986
+ stripeAccount: stripeAccount.accountId,
987
+ source: stripeMocker.createChargeObject({ metadata: { payment: payment.id }, application_fee: fee }),
988
+ });
989
+
990
+ const result = await new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! }).syncConnectedPayouts({ start });
991
+ expect(result.failed).toBe(1);
992
+
993
+ const settlement = await getSettlement(payout);
994
+ expect(settlement.syncedAt).toBeNull();
995
+ });
996
+ });
997
+ });