@stamhoofd/backend 2.139.0 → 2.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
- 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 +14 -14
- 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 +285 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +419 -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 +366 -0
- package/src/helpers/SettlementExporter.ts +583 -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 +828 -0
- package/src/helpers/StripeSettlementSync.ts +947 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
- package/src/helpers/TwoFactorHelper.ts +1 -1
- 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/helpers/streamForBreakdown.ts +2 -2
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/PaymentService.ts +8 -0
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/filters/orders.test.ts +635 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.141.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -65,20 +65,20 @@
|
|
|
65
65
|
"@simonbackx/simple-errors": "1.5.0",
|
|
66
66
|
"@simonbackx/simple-logging": "1.0.1",
|
|
67
67
|
"@simplewebauthn/server": "13.3.2",
|
|
68
|
-
"@stamhoofd/backend-env": "2.
|
|
69
|
-
"@stamhoofd/backend-i18n": "2.
|
|
70
|
-
"@stamhoofd/backend-middleware": "2.
|
|
71
|
-
"@stamhoofd/crons": "2.
|
|
72
|
-
"@stamhoofd/email": "2.
|
|
73
|
-
"@stamhoofd/excel-writer": "2.
|
|
74
|
-
"@stamhoofd/logging": "2.
|
|
75
|
-
"@stamhoofd/models": "2.
|
|
76
|
-
"@stamhoofd/object-differ": "2.
|
|
77
|
-
"@stamhoofd/queues": "2.
|
|
78
|
-
"@stamhoofd/sql": "2.
|
|
79
|
-
"@stamhoofd/structures": "2.
|
|
80
|
-
"@stamhoofd/types": "2.
|
|
81
|
-
"@stamhoofd/utility": "2.
|
|
68
|
+
"@stamhoofd/backend-env": "2.141.0",
|
|
69
|
+
"@stamhoofd/backend-i18n": "2.141.0",
|
|
70
|
+
"@stamhoofd/backend-middleware": "2.141.0",
|
|
71
|
+
"@stamhoofd/crons": "2.141.0",
|
|
72
|
+
"@stamhoofd/email": "2.141.0",
|
|
73
|
+
"@stamhoofd/excel-writer": "2.141.0",
|
|
74
|
+
"@stamhoofd/logging": "2.141.0",
|
|
75
|
+
"@stamhoofd/models": "2.141.0",
|
|
76
|
+
"@stamhoofd/object-differ": "2.141.0",
|
|
77
|
+
"@stamhoofd/queues": "2.141.0",
|
|
78
|
+
"@stamhoofd/sql": "2.141.0",
|
|
79
|
+
"@stamhoofd/structures": "2.141.0",
|
|
80
|
+
"@stamhoofd/types": "2.141.0",
|
|
81
|
+
"@stamhoofd/utility": "2.141.0",
|
|
82
82
|
"archiver": "7.0.1",
|
|
83
83
|
"argon2": "0.44.0",
|
|
84
84
|
"axios": "1.18.1",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"uuid": "14.0.1"
|
|
103
103
|
},
|
|
104
104
|
"devDependencies": {
|
|
105
|
-
"@stamhoofd/test-utils": "2.
|
|
105
|
+
"@stamhoofd/test-utils": "2.141.0",
|
|
106
106
|
"@types/cookie": "0.6.0",
|
|
107
107
|
"@types/luxon": "3.7.2",
|
|
108
108
|
"@types/mailparser": "3.4.6",
|
|
@@ -117,5 +117,5 @@
|
|
|
117
117
|
"publishConfig": {
|
|
118
118
|
"access": "public"
|
|
119
119
|
},
|
|
120
|
-
"gitHead": "
|
|
120
|
+
"gitHead": "cf38053fe1507022767148347f436100a7ef07ed"
|
|
121
121
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Organization } from '@stamhoofd/models';
|
|
2
|
-
import { OrganizationFactory, Payment, StripeAccount } from '@stamhoofd/models';
|
|
3
|
-
import {
|
|
2
|
+
import { OrganizationFactory, Payment, Platform, StripeAccount } from '@stamhoofd/models';
|
|
3
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
4
|
+
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
5
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
7
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, SettlementReference } from '@stamhoofd/structures';
|
|
8
|
+
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
9
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
4
10
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
11
|
import { Formatter } from '@stamhoofd/utility';
|
|
6
12
|
import { v4 as uuidv4 } from 'uuid';
|
|
@@ -8,10 +14,20 @@ import { createFakeSettlements } from './fake-settlements.js';
|
|
|
8
14
|
|
|
9
15
|
describe('Cron.fake-settlements', () => {
|
|
10
16
|
let organization: Organization;
|
|
17
|
+
let membershipOrganization: Organization;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
membershipOrganization = await new OrganizationFactory({}).create();
|
|
21
|
+
});
|
|
11
22
|
|
|
12
23
|
beforeEach(async () => {
|
|
13
24
|
TestUtils.setEnvironment('environment', 'development');
|
|
14
25
|
organization = await new OrganizationFactory({}).create();
|
|
26
|
+
|
|
27
|
+
// The platform's own accounts belong to the membership organization
|
|
28
|
+
const platform = await Platform.getForEditing();
|
|
29
|
+
platform.membershipOrganizationId = membershipOrganization.id;
|
|
30
|
+
await platform.save();
|
|
15
31
|
});
|
|
16
32
|
|
|
17
33
|
/**
|
|
@@ -22,7 +38,7 @@ describe('Cron.fake-settlements', () => {
|
|
|
22
38
|
return Formatter.luxon(new Date()).startOf('week').minus({ weeks: weeksAgo }).plus({ days: 2, hours: 12 }).toJSDate();
|
|
23
39
|
};
|
|
24
40
|
|
|
25
|
-
const createPayment = async ({ price = 50_0000, paidAt = inWeek(2), provider = PaymentProvider.Mollie as PaymentProvider | null, status = PaymentStatus.Succeeded, method = PaymentMethod.Bancontact, type = PaymentType.Payment, organizationId = undefined as string | undefined, stripeAccountId = null as string | null }: {
|
|
41
|
+
const createPayment = async ({ price = 50_0000, paidAt = inWeek(2), provider = PaymentProvider.Mollie as PaymentProvider | null, status = PaymentStatus.Succeeded, method = PaymentMethod.Bancontact, type = PaymentType.Payment, organizationId = undefined as string | undefined, stripeAccountId = null as string | null, serviceFeePayout = 0, transferFee = 0 }: {
|
|
26
42
|
price?: number;
|
|
27
43
|
paidAt?: Date | null;
|
|
28
44
|
provider?: PaymentProvider | null;
|
|
@@ -31,6 +47,8 @@ describe('Cron.fake-settlements', () => {
|
|
|
31
47
|
type?: PaymentType;
|
|
32
48
|
organizationId?: string;
|
|
33
49
|
stripeAccountId?: string | null;
|
|
50
|
+
serviceFeePayout?: number;
|
|
51
|
+
transferFee?: number;
|
|
34
52
|
} = {}) => {
|
|
35
53
|
const payment = new Payment();
|
|
36
54
|
payment.organizationId = organizationId ?? organization.id;
|
|
@@ -41,6 +59,8 @@ describe('Cron.fake-settlements', () => {
|
|
|
41
59
|
payment.price = price;
|
|
42
60
|
payment.paidAt = paidAt;
|
|
43
61
|
payment.stripeAccountId = stripeAccountId;
|
|
62
|
+
payment.serviceFeePayout = serviceFeePayout;
|
|
63
|
+
payment.transferFee = transferFee;
|
|
44
64
|
await payment.save();
|
|
45
65
|
return payment;
|
|
46
66
|
};
|
|
@@ -69,8 +89,9 @@ describe('Cron.fake-settlements', () => {
|
|
|
69
89
|
expect(settlement).toMatchObject({
|
|
70
90
|
settledAt: Formatter.luxon(inWeek(2)).startOf('week').plus({ weeks: 1, days: 2 }).toJSDate(),
|
|
71
91
|
|
|
72
|
-
// Refunds are taken out of the payout, just like the provider does
|
|
73
|
-
|
|
92
|
+
// Refunds are taken out of the payout, just like the provider does, and so are the
|
|
93
|
+
// fake Mollie costs: 3 payments x 0.30 + 21% VAT
|
|
94
|
+
amount: 65_0000 - 90_00 - 18_90,
|
|
74
95
|
});
|
|
75
96
|
|
|
76
97
|
// All payments of the week point to the same payout
|
|
@@ -113,8 +134,10 @@ describe('Cron.fake-settlements', () => {
|
|
|
113
134
|
await getSettlement(otherStripe),
|
|
114
135
|
];
|
|
115
136
|
|
|
116
|
-
// Same week, but four accounts: four payouts, each holding only its own money
|
|
117
|
-
|
|
137
|
+
// Same week, but four accounts: four payouts, each holding only its own money. The Mollie
|
|
138
|
+
// payouts are reduced by the fake cost of one payment (0.30 + 21% VAT); the Stripe payments
|
|
139
|
+
// carry no fees here
|
|
140
|
+
expect(settlements.map(s => s!.amount)).toEqual([50_0000 - 36_30, 30_0000 - 36_30, 20_0000, 15_0000]);
|
|
118
141
|
expect(new Set(settlements.map(s => s!.id)).size).toBe(4);
|
|
119
142
|
expect(new Set(settlements.map(s => s!.reference)).size).toBe(4);
|
|
120
143
|
});
|
|
@@ -138,7 +161,7 @@ describe('Cron.fake-settlements', () => {
|
|
|
138
161
|
});
|
|
139
162
|
|
|
140
163
|
test('A payment that was already paid out keeps its own payout', async () => {
|
|
141
|
-
const settlement =
|
|
164
|
+
const settlement = SettlementReference.create({
|
|
142
165
|
id: 'stl_1',
|
|
143
166
|
reference: '1234.1234.1234',
|
|
144
167
|
settledAt: inWeek(1),
|
|
@@ -162,4 +185,102 @@ describe('Cron.fake-settlements', () => {
|
|
|
162
185
|
|
|
163
186
|
expect(await getSettlement(payment)).toBeNull();
|
|
164
187
|
});
|
|
188
|
+
|
|
189
|
+
describe('Settlement rows', () => {
|
|
190
|
+
const getSettlementRow = async (payment: Payment) => {
|
|
191
|
+
const reference = await getSettlement(payment);
|
|
192
|
+
return await Settlement.select().where('externalId', reference!.id).first(true);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
test('A Mollie payout stores payment lines and cost rows, and reconciles to zero', async () => {
|
|
196
|
+
const first = await createPayment({ price: 50_0000 });
|
|
197
|
+
const second = await createPayment({ price: -10_0000, type: PaymentType.Refund });
|
|
198
|
+
|
|
199
|
+
await createFakeSettlements();
|
|
200
|
+
|
|
201
|
+
const settlement = await getSettlementRow(first);
|
|
202
|
+
expect(settlement.provider).toBe(PaymentProvider.Mollie);
|
|
203
|
+
expect(settlement.organizationId).toBe(organization.id);
|
|
204
|
+
expect(settlement.amount).toBe(40_0000 - 60_00 - 12_60);
|
|
205
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
206
|
+
expect(settlement.syncedAt).not.toBeNull();
|
|
207
|
+
|
|
208
|
+
const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
|
|
209
|
+
expect(lines.map(l => [l.paymentId, l.amount]).sort()).toEqual([
|
|
210
|
+
[first.id, 50_0000],
|
|
211
|
+
[second.id, -10_0000],
|
|
212
|
+
].sort());
|
|
213
|
+
|
|
214
|
+
const settledMonth = settlement.settledAt.getFullYear() + '-' + (settlement.settledAt.getMonth() + 1).toString().padStart(2, '0');
|
|
215
|
+
const charges = await SettlementCharge.select().where('settlementId', settlement.id).fetch();
|
|
216
|
+
expect(charges.map(c => ({ type: c.type, amount: c.amount, providerInvoiceId: c.providerInvoiceId })).sort((a, b) => a.amount - b.amount)).toEqual([
|
|
217
|
+
{ type: SettlementChargeType.ProviderTransactionFee, amount: -60_00, providerInvoiceId: 'fake-invoice-mollie-' + settledMonth },
|
|
218
|
+
{ type: SettlementChargeType.Tax, amount: -12_60, providerInvoiceId: 'fake-invoice-mollie-' + settledMonth },
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('A Stripe application fee is mirrored on the payout and a monthly platform payout', async () => {
|
|
223
|
+
const stripeAccount = await createStripeAccount(organization);
|
|
224
|
+
const payment = await createPayment({
|
|
225
|
+
price: 20_0000,
|
|
226
|
+
provider: PaymentProvider.Stripe,
|
|
227
|
+
stripeAccountId: stripeAccount.id,
|
|
228
|
+
serviceFeePayout: 2_0000,
|
|
229
|
+
transferFee: 50_00,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await createFakeSettlements();
|
|
233
|
+
|
|
234
|
+
const settlement = await getSettlementRow(payment);
|
|
235
|
+
expect(settlement.amount).toBe(20_0000 - 2_0000 - 50_00);
|
|
236
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
237
|
+
expect(settlement.stripeAccountId).toBe(stripeAccount.id);
|
|
238
|
+
|
|
239
|
+
const applicationFeeId = 'fake-fee-' + payment.id;
|
|
240
|
+
const feeRows = await SettlementCharge.select().where('applicationFeeId', applicationFeeId).fetch();
|
|
241
|
+
|
|
242
|
+
const byType = new Map(feeRows.map(row => [row.type, row]));
|
|
243
|
+
expect(byType.get(SettlementChargeType.ApplicationFeeService)).toMatchObject({
|
|
244
|
+
amount: -2_0000, settlementId: settlement.id, paymentId: payment.id, stripeAccountId: stripeAccount.id,
|
|
245
|
+
});
|
|
246
|
+
expect(byType.get(SettlementChargeType.ApplicationFeeTransfer)).toMatchObject({
|
|
247
|
+
amount: -50_00, settlementId: settlement.id,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// The platform side receives the same fee: per application fee both sides of one kind
|
|
251
|
+
// sum to zero
|
|
252
|
+
const fees = await ApplicationFee.select().where('externalId', applicationFeeId).fetch();
|
|
253
|
+
const feeByType = new Map(fees.map(fee => [fee.type, fee]));
|
|
254
|
+
expect(feeByType.get(ApplicationFeeType.Service)).toMatchObject({
|
|
255
|
+
amount: 2_0000,
|
|
256
|
+
organizationId: membershipOrganization.id,
|
|
257
|
+
payingOrganizationId: organization.id,
|
|
258
|
+
payingStripeAccountId: stripeAccount.id,
|
|
259
|
+
payingPaymentId: payment.id,
|
|
260
|
+
});
|
|
261
|
+
expect(feeByType.get(ApplicationFeeType.Transfer)!.amount).toBe(50_00);
|
|
262
|
+
|
|
263
|
+
const platformSettlement = await Settlement.getByID(feeByType.get(ApplicationFeeType.Service)!.settlementId!);
|
|
264
|
+
expect(platformSettlement!.stripeAccountId).toBeNull();
|
|
265
|
+
expect(platformSettlement!.organizationId).toBe(membershipOrganization.id);
|
|
266
|
+
expect(platformSettlement!.externalId).toContain('fake-settlement-Stripe-platform-');
|
|
267
|
+
|
|
268
|
+
// The monthly platform settlement is shared: unsettled payments of other tests in the
|
|
269
|
+
// same database land in it too, so only the identity is asserted, not the exact amount
|
|
270
|
+
expect(platformSettlement!.amount).toBeGreaterThanOrEqual(2_5000);
|
|
271
|
+
expect(platformSettlement!.unexplainedAmount).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('Payments without fees write no charge rows', async () => {
|
|
275
|
+
const stripeAccount = await createStripeAccount(organization);
|
|
276
|
+
const payment = await createPayment({ price: 20_0000, provider: PaymentProvider.Stripe, stripeAccountId: stripeAccount.id });
|
|
277
|
+
|
|
278
|
+
await createFakeSettlements();
|
|
279
|
+
|
|
280
|
+
const settlement = await getSettlementRow(payment);
|
|
281
|
+
expect(settlement.amount).toBe(20_0000);
|
|
282
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
283
|
+
expect(await SettlementCharge.select().where('settlementId', settlement.id).count()).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
165
286
|
});
|
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { registerCron } from '@stamhoofd/crons';
|
|
8
|
-
import { Order, Payment } from '@stamhoofd/models';
|
|
8
|
+
import { Order, Payment, Platform } from '@stamhoofd/models';
|
|
9
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
10
|
+
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
11
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
12
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
9
13
|
import { SQL } from '@stamhoofd/sql';
|
|
10
|
-
import { PaymentStatus,
|
|
14
|
+
import { PaymentProvider, PaymentStatus, SettlementReference } from '@stamhoofd/structures';
|
|
11
15
|
import { SETTLING_PAYMENT_PROVIDERS } from '@stamhoofd/structures/PaymentSettlement.js';
|
|
16
|
+
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
17
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
12
18
|
import { Formatter } from '@stamhoofd/utility';
|
|
13
19
|
import type { DateTime } from 'luxon';
|
|
20
|
+
import { ApplicationFeeService } from '../services/ApplicationFeeService.js';
|
|
21
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
14
22
|
|
|
15
23
|
registerCron('fake-settlements', createFakeSettlements);
|
|
16
24
|
|
|
@@ -25,6 +33,12 @@ const PAYOUT_DELAY_DAYS = 2;
|
|
|
25
33
|
*/
|
|
26
34
|
const BATCH_SIZE = 1000;
|
|
27
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Fake Mollie transaction fee per settled payment, excluding VAT.
|
|
38
|
+
*/
|
|
39
|
+
const MOLLIE_FEE_PER_PAYMENT = 30_00;
|
|
40
|
+
const MOLLIE_FEE_VAT_PERCENTAGE = 21;
|
|
41
|
+
|
|
28
42
|
export async function createFakeSettlements() {
|
|
29
43
|
if (STAMHOOFD.environment !== 'development') {
|
|
30
44
|
return;
|
|
@@ -32,9 +46,21 @@ export async function createFakeSettlements() {
|
|
|
32
46
|
|
|
33
47
|
const now = new Date();
|
|
34
48
|
|
|
49
|
+
// The platform's own accounts belong to the membership organization; without one, their
|
|
50
|
+
// payouts can't be attributed and are skipped
|
|
51
|
+
const platformOrganizationId = (await Platform.getShared()).membershipOrganizationId;
|
|
52
|
+
|
|
53
|
+
// Payments that couldn't be attributed to an organization are scanned past instead of
|
|
54
|
+
// reselected forever, so they can't stall the payout of later weeks
|
|
55
|
+
let scanAfter: Date | null = null;
|
|
56
|
+
|
|
35
57
|
// Pay out week per week, oldest first, until we reach a week that isn't paid out yet
|
|
36
58
|
for (;;) {
|
|
37
|
-
|
|
59
|
+
let oldestQuery = selectUnsettledPayments();
|
|
60
|
+
if (scanAfter) {
|
|
61
|
+
oldestQuery = oldestQuery.where('paidAt', '>', scanAfter);
|
|
62
|
+
}
|
|
63
|
+
const oldest = await oldestQuery.orderBy('paidAt', 'ASC').first(false);
|
|
38
64
|
if (!oldest?.paidAt) {
|
|
39
65
|
break;
|
|
40
66
|
}
|
|
@@ -51,8 +77,17 @@ export async function createFakeSettlements() {
|
|
|
51
77
|
|
|
52
78
|
// Nothing is left before this week, so this selects exactly the payments of this week. What
|
|
53
79
|
// doesn't fit in one batch is picked up by the next round, in the same payout
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
let batchQuery = selectUnsettledPayments().where('paidAt', '<', weekEnd.toJSDate());
|
|
81
|
+
if (scanAfter) {
|
|
82
|
+
batchQuery = batchQuery.where('paidAt', '>', scanAfter);
|
|
83
|
+
}
|
|
84
|
+
const payments = await batchQuery.limit(BATCH_SIZE).fetch();
|
|
85
|
+
const settledCount = await settleWeek(payments, weekStart, settledAt, platformOrganizationId);
|
|
86
|
+
|
|
87
|
+
if (settledCount === 0) {
|
|
88
|
+
// Everything in this batch was skipped: continue behind it
|
|
89
|
+
scanAfter = new Date(Math.max(...payments.map(p => p.paidAt!.getTime())));
|
|
90
|
+
}
|
|
56
91
|
}
|
|
57
92
|
}
|
|
58
93
|
|
|
@@ -76,7 +111,40 @@ function getPayoutAccount(payment: Payment): string {
|
|
|
76
111
|
return payment.provider + '-' + (payment.stripeAccountId ?? payment.organizationId ?? 'platform');
|
|
77
112
|
}
|
|
78
113
|
|
|
79
|
-
|
|
114
|
+
function getStripeFakeFees(payment: Payment) {
|
|
115
|
+
return { serviceFee: payment.serviceFeePayout, transferFee: payment.transferFee };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Upsert a settlement while accumulating the amount: a week larger than one batch settles in
|
|
120
|
+
* multiple rounds, all in the same payout.
|
|
121
|
+
*/
|
|
122
|
+
async function upsertFakeSettlement(data: { provider: PaymentProvider; externalId: string; stripeAccountId: string | null; organizationId: string; reference: string; settledAt: Date; addedAmount: number }) {
|
|
123
|
+
const existing = await Settlement.select()
|
|
124
|
+
.where('provider', data.provider)
|
|
125
|
+
.where('externalId', data.externalId)
|
|
126
|
+
.first(false);
|
|
127
|
+
|
|
128
|
+
return await SettlementService.upsertSettlement({
|
|
129
|
+
provider: data.provider,
|
|
130
|
+
externalId: data.externalId,
|
|
131
|
+
stripeAccountId: data.stripeAccountId,
|
|
132
|
+
organizationId: data.organizationId,
|
|
133
|
+
reference: data.reference,
|
|
134
|
+
amount: (existing?.amount ?? 0) + data.addedAmount,
|
|
135
|
+
settledAt: data.settledAt,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function finishFakeSync(settlement: Settlement) {
|
|
140
|
+
const lineCount = await PaymentSettlement.select().where('settlementId', settlement.id).count();
|
|
141
|
+
const chargeCount = await SettlementCharge.select().where('settlementId', settlement.id).count();
|
|
142
|
+
const feeCount = await ApplicationFee.select().where('settlementId', settlement.id).count();
|
|
143
|
+
await SettlementService.finishSync(settlement, { transactionCount: lineCount + chargeCount + feeCount });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function settleWeek(payments: Payment[], weekStart: DateTime, settledAt: Date, platformOrganizationId: string | null): Promise<number> {
|
|
147
|
+
let settledCount = 0;
|
|
80
148
|
const groups = new Map<string, Payment[]>();
|
|
81
149
|
|
|
82
150
|
for (const payment of payments) {
|
|
@@ -97,10 +165,50 @@ async function settleWeek(payments: Payment[], weekStart: DateTime, settledAt: D
|
|
|
97
165
|
|
|
98
166
|
// Every account gets its own payout, so the reference has to tell them apart
|
|
99
167
|
const reference = 'DEV ' + week + ' (' + account.slice(-8) + ')';
|
|
100
|
-
const
|
|
168
|
+
const provider = group[0].provider!;
|
|
169
|
+
|
|
170
|
+
// Payments without an organization arrived on the platform's own account
|
|
171
|
+
const organizationId = group[0].organizationId ?? platformOrganizationId;
|
|
172
|
+
if (!organizationId) {
|
|
173
|
+
console.log('Skipped fake settlement ' + reference + ': no platform membership organization configured');
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fees are deducted from the payout, so the identity
|
|
178
|
+
// amount = sum of payment lines + sum of charges closes
|
|
179
|
+
let fees = 0;
|
|
180
|
+
if (provider === PaymentProvider.Stripe) {
|
|
181
|
+
fees = group.reduce((total, payment) => {
|
|
182
|
+
const { serviceFee, transferFee } = getStripeFakeFees(payment);
|
|
183
|
+
return total + serviceFee + transferFee;
|
|
184
|
+
}, 0);
|
|
185
|
+
} else if (provider === PaymentProvider.Mollie) {
|
|
186
|
+
const net = MOLLIE_FEE_PER_PAYMENT * group.length;
|
|
187
|
+
fees = net + Math.round(net * MOLLIE_FEE_VAT_PERCENTAGE / 100);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const grossAmount = group.reduce((total, payment) => total + payment.price, 0);
|
|
191
|
+
const amount = grossAmount - fees;
|
|
192
|
+
|
|
193
|
+
const settlement = await upsertFakeSettlement({
|
|
194
|
+
provider,
|
|
195
|
+
externalId: id,
|
|
196
|
+
stripeAccountId: group[0].stripeAccountId,
|
|
197
|
+
organizationId,
|
|
198
|
+
reference,
|
|
199
|
+
settledAt,
|
|
200
|
+
addedAmount: amount,
|
|
201
|
+
});
|
|
101
202
|
|
|
102
203
|
for (const payment of group) {
|
|
103
|
-
|
|
204
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
205
|
+
paymentId: payment.id,
|
|
206
|
+
amount: payment.price,
|
|
207
|
+
externalId: 'fake-txn-' + payment.id,
|
|
208
|
+
occurredAt: payment.paidAt!,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
payment.settlement = SettlementReference.create({ id, reference, settledAt, amount: settlement.amount });
|
|
104
212
|
await payment.save();
|
|
105
213
|
|
|
106
214
|
// Mark order as 'updated', or the frontend won't pull in the updates
|
|
@@ -112,6 +220,145 @@ async function settleWeek(payments: Payment[], weekStart: DateTime, settledAt: D
|
|
|
112
220
|
}
|
|
113
221
|
}
|
|
114
222
|
|
|
115
|
-
|
|
223
|
+
if (provider === PaymentProvider.Stripe) {
|
|
224
|
+
await createStripeFeeRows(settlement, group, settledAt, platformOrganizationId);
|
|
225
|
+
} else if (provider === PaymentProvider.Mollie) {
|
|
226
|
+
await createMollieCostRows(settlement, group, settledAt);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await finishFakeSync(settlement);
|
|
230
|
+
settledCount += group.length;
|
|
231
|
+
|
|
232
|
+
console.log('Created settlement ' + reference + ' of ' + Formatter.price(settlement.amount) + ' for ' + group.length + ' payments');
|
|
116
233
|
}
|
|
234
|
+
|
|
235
|
+
return settledCount;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The application fee of a Stripe payment is mirrored, like Stripe mirrors it: two negative
|
|
240
|
+
* deduction rows on the organization payout, and two application fee rows received in a fake
|
|
241
|
+
* monthly platform payout.
|
|
242
|
+
*/
|
|
243
|
+
async function createStripeFeeRows(settlement: Settlement, group: Payment[], settledAt: Date, platformOrganizationId: string | null) {
|
|
244
|
+
const month = SettlementService.getPeriodKey(settledAt);
|
|
245
|
+
let addedFees = 0;
|
|
246
|
+
const feeRows: { payment: Payment; applicationFeeId: string; parts: { type: ApplicationFeeType; amount: number; charge: SettlementCharge }[] }[] = [];
|
|
247
|
+
|
|
248
|
+
for (const payment of group) {
|
|
249
|
+
const { serviceFee, transferFee } = getStripeFakeFees(payment);
|
|
250
|
+
if ((serviceFee === 0 && transferFee === 0) || !payment.organizationId || !payment.stripeAccountId) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const applicationFeeId = 'fake-fee-' + payment.id;
|
|
255
|
+
const parts: { type: ApplicationFeeType; amount: number; charge: SettlementCharge }[] = [];
|
|
256
|
+
addedFees += serviceFee + transferFee;
|
|
257
|
+
|
|
258
|
+
for (const [chargeType, feeType, feeAmount] of [
|
|
259
|
+
[SettlementChargeType.ApplicationFeeService, ApplicationFeeType.Service, serviceFee],
|
|
260
|
+
[SettlementChargeType.ApplicationFeeTransfer, ApplicationFeeType.Transfer, transferFee],
|
|
261
|
+
] as const) {
|
|
262
|
+
if (feeAmount === 0) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const charge = await SettlementService.upsertCharge({
|
|
266
|
+
type: chargeType,
|
|
267
|
+
externalId: applicationFeeId + ':' + chargeType,
|
|
268
|
+
amount: -feeAmount,
|
|
269
|
+
settlementId: settlement.id,
|
|
270
|
+
applicationFeeId,
|
|
271
|
+
paymentId: payment.id,
|
|
272
|
+
organizationId: payment.organizationId,
|
|
273
|
+
stripeAccountId: payment.stripeAccountId,
|
|
274
|
+
occurredAt: payment.paidAt!,
|
|
275
|
+
});
|
|
276
|
+
parts.push({ type: feeType, amount: feeAmount, charge });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
feeRows.push({ payment, applicationFeeId, parts });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (feeRows.length === 0) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!platformOrganizationId) {
|
|
287
|
+
console.log('Skipped the fake platform fee payout: no platform membership organization configured');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// One fake platform payout per month receives all fees of that month
|
|
292
|
+
const platformSettlement = await upsertFakeSettlement({
|
|
293
|
+
provider: PaymentProvider.Stripe,
|
|
294
|
+
externalId: 'fake-settlement-Stripe-platform-' + month,
|
|
295
|
+
stripeAccountId: null,
|
|
296
|
+
organizationId: platformOrganizationId,
|
|
297
|
+
reference: 'DEV STRIPE FEES ' + month,
|
|
298
|
+
settledAt,
|
|
299
|
+
addedAmount: addedFees,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const invoicedFeeBalanceItemIds = new Set<string>();
|
|
303
|
+
for (const { payment, applicationFeeId, parts } of feeRows) {
|
|
304
|
+
for (const { type, amount, charge } of parts) {
|
|
305
|
+
const fee = await ApplicationFeeService.upsertFee({
|
|
306
|
+
externalId: applicationFeeId,
|
|
307
|
+
type,
|
|
308
|
+
amount,
|
|
309
|
+
organizationId: platformOrganizationId,
|
|
310
|
+
payingOrganizationId: payment.organizationId!,
|
|
311
|
+
payingStripeAccountId: payment.stripeAccountId!,
|
|
312
|
+
payingPaymentId: payment.id,
|
|
313
|
+
settlementChargeId: charge.id,
|
|
314
|
+
settlementId: platformSettlement.id,
|
|
315
|
+
occurredAt: payment.paidAt!,
|
|
316
|
+
});
|
|
317
|
+
if (fee.balanceItemId) {
|
|
318
|
+
invoicedFeeBalanceItemIds.add(fee.balanceItemId);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A dev database can contain fee payments (e.g. copied history): keep their derived lines
|
|
324
|
+
// in sync like a real payout walk does
|
|
325
|
+
await SettlementService.updatePaymentSettlementsForAccountDeductionBalanceItems([...invoicedFeeBalanceItemIds]);
|
|
326
|
+
|
|
327
|
+
await finishFakeSync(platformSettlement);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Mollie deducts its own costs from the settlement: one fake transaction fee line plus its VAT, so
|
|
332
|
+
* the settlement reconciles to 0 like a real one.
|
|
333
|
+
*/
|
|
334
|
+
async function createMollieCostRows(settlement: Settlement, group: Payment[], settledAt: Date) {
|
|
335
|
+
const externalId = settlement.externalId + ':cost:0';
|
|
336
|
+
const existing = await SettlementCharge.select().where('externalId', externalId).first(false);
|
|
337
|
+
|
|
338
|
+
const addedNet = MOLLIE_FEE_PER_PAYMENT * group.length;
|
|
339
|
+
const net = (existing ? -existing.amount : 0) + addedNet;
|
|
340
|
+
const vat = Math.round(net * MOLLIE_FEE_VAT_PERCENTAGE / 100);
|
|
341
|
+
const providerInvoiceId = 'fake-invoice-mollie-' + SettlementService.getPeriodKey(settledAt);
|
|
342
|
+
|
|
343
|
+
await SettlementService.upsertCharge({
|
|
344
|
+
type: SettlementChargeType.ProviderTransactionFee,
|
|
345
|
+
externalId,
|
|
346
|
+
amount: -net,
|
|
347
|
+
settlementId: settlement.id,
|
|
348
|
+
organizationId: settlement.organizationId,
|
|
349
|
+
providerInvoiceId,
|
|
350
|
+
description: 'DEV Mollie transactiekosten',
|
|
351
|
+
occurredAt: settledAt,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await SettlementService.upsertCharge({
|
|
355
|
+
type: SettlementChargeType.Tax,
|
|
356
|
+
externalId: externalId + ':tax',
|
|
357
|
+
amount: -vat,
|
|
358
|
+
settlementId: settlement.id,
|
|
359
|
+
organizationId: settlement.organizationId,
|
|
360
|
+
providerInvoiceId,
|
|
361
|
+
description: 'DEV BTW op Mollie transactiekosten',
|
|
362
|
+
occurredAt: settledAt,
|
|
363
|
+
});
|
|
117
364
|
}
|
package/src/crons/index.ts
CHANGED
|
@@ -13,8 +13,8 @@ import './fake-settlements.js';
|
|
|
13
13
|
import './invoices.js';
|
|
14
14
|
import './service-fees.js';
|
|
15
15
|
import './members-fees.js';
|
|
16
|
+
import './settlement-sync.js';
|
|
16
17
|
import './stripe-invoices.js';
|
|
17
|
-
import './stripe-payout-reports.js';
|
|
18
18
|
import './transfer-fees.js';
|
|
19
19
|
import './drip-emails.js';
|
|
20
20
|
import './update-organization-future-events.js';
|
package/src/crons/invoices.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { EmailTemplateType, InvoiceStruct, PaymentStatus, Replacement } from '@s
|
|
|
7
7
|
import { Formatter, Sorter } from '@stamhoofd/utility';
|
|
8
8
|
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
|
|
9
9
|
import { InvoiceService } from '../services/InvoiceService.js';
|
|
10
|
+
import { WebmasterReport } from '../helpers/WebmasterReport.js';
|
|
10
11
|
import { useSavedIterator } from './helpers/useSavedIterator.js';
|
|
11
12
|
import { isOutside } from './helpers/isOutside.js';
|
|
12
13
|
import { OrganizationAdminService } from '../services/OrganizationAdminService.js';
|
|
@@ -33,15 +34,19 @@ async function invoices() {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
// A problem in one invoice usually repeats in every invoice of this run: one email for all of
|
|
38
|
+
// them
|
|
39
|
+
await WebmasterReport.group('Aanmaken facturen', async () => {
|
|
40
|
+
// Get the next x organization to send e-mails for
|
|
41
|
+
for await (const organization of iterate()) {
|
|
42
|
+
if (!organization.meta.invoicesEnabled) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
// Create all invoices for this organization
|
|
47
|
+
await createInvoicesFor(organization);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
async function createInvoicesFor(organization: Organization) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
2
|
+
import type { Organization } from '@stamhoofd/models';
|
|
3
|
+
import { OrganizationFactory } from '@stamhoofd/models';
|
|
4
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
|
|
7
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
8
|
+
import { reportProblemSettlements } from './settlement-sync.js';
|
|
9
|
+
|
|
10
|
+
describe('Cron.settlement-sync', () => {
|
|
11
|
+
let organization: Organization;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
organization = await new OrganizationFactory({}).create();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const createUnsyncedSettlement = async (failureCount: number) => {
|
|
18
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
19
|
+
provider: PaymentProvider.Stripe,
|
|
20
|
+
externalId: 'po_' + uuidv4(),
|
|
21
|
+
organizationId: organization.id,
|
|
22
|
+
amount: 100_00_00,
|
|
23
|
+
settledAt: new Date(1990, 0, 5),
|
|
24
|
+
});
|
|
25
|
+
for (let i = 0; i < failureCount; i++) {
|
|
26
|
+
await SettlementService.markSyncFailed(settlement);
|
|
27
|
+
}
|
|
28
|
+
return settlement;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('problem settlements are reported to the webmaster', async () => {
|
|
32
|
+
await createUnsyncedSettlement(5);
|
|
33
|
+
|
|
34
|
+
await reportProblemSettlements();
|
|
35
|
+
|
|
36
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
37
|
+
expect(emails.find(e => e.subject.startsWith('Uitbetalingen met problemen'))).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
});
|