@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,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
|
+
});
|