@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
|
@@ -7,8 +7,8 @@ import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
|
7
7
|
import { PermissionLevel } from '@stamhoofd/structures';
|
|
8
8
|
|
|
9
9
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
10
|
-
import { checkMollieSettlementsFor } from '../../../../helpers/CheckSettlements.js';
|
|
11
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
|
+
import { MollieSettlementSync } from '../../../../helpers/MollieSettlementSync.js';
|
|
12
12
|
import { MollieService } from '../../../../services/MollieService.js';
|
|
13
13
|
|
|
14
14
|
type Params = Record<string, never>;
|
|
@@ -53,7 +53,7 @@ export class ConnectMollieEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
53
53
|
await service.setupOnboarding();
|
|
54
54
|
|
|
55
55
|
// Check settlements after linking (shouldn't block)
|
|
56
|
-
|
|
56
|
+
new MollieSettlementSync({ token: mollieToken }).syncSettlements({ start: organization.createdAt }).catch(console.error);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
return new Response(await AuthenticatedStructures.organization(organization));
|
|
@@ -667,7 +667,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
667
667
|
* A custom PEPPOL endpoint id may only be set by full platform admins for now.
|
|
668
668
|
*/
|
|
669
669
|
private requirePeppolPermission() {
|
|
670
|
-
if (!Context.auth.hasPlatformFullAccess()) {
|
|
670
|
+
if (!Context.auth.hasPlatformFullAccess() && STAMHOOFD.userMode === 'platform') {
|
|
671
671
|
throw new SimpleError({
|
|
672
672
|
code: 'permission_denied',
|
|
673
673
|
message: 'Only platform admins can set a custom PEPPOL endpoint id',
|
|
@@ -2,7 +2,7 @@ import { Request } from '@simonbackx/simple-endpoints';
|
|
|
2
2
|
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
3
|
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, StripeAccount, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice,
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, StripeBusinessProfile, StripeMetaData, TransferSettings, TranslatedString } from '@stamhoofd/structures';
|
|
6
6
|
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
7
|
import type { PaymentBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
8
|
import { BreakdownGraphUnit, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
@@ -142,7 +142,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
142
142
|
iban?: string;
|
|
143
143
|
transferDescription?: string;
|
|
144
144
|
transferFee?: number;
|
|
145
|
-
settlement?:
|
|
145
|
+
settlement?: SettlementReference;
|
|
146
146
|
/**
|
|
147
147
|
* What this payment rounded away, because a payment goes to the cent while what was charged
|
|
148
148
|
* goes to four digits after the comma.
|
|
@@ -631,7 +631,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
631
631
|
|
|
632
632
|
describe('Grouping by payout', () => {
|
|
633
633
|
const createSettlement = (reference: string, settledAt: Date) => {
|
|
634
|
-
return
|
|
634
|
+
return SettlementReference.create({ id: 'stl_' + reference, reference, settledAt, amount: 0 });
|
|
635
635
|
};
|
|
636
636
|
|
|
637
637
|
test('per payout, with what is not online and what still has to be paid out apart', async () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import type { Organization, User } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, StripeAccount, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { PaginatedResponse, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
7
|
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { SettlementService } from '../../../../services/SettlementService.js';
|
|
7
9
|
import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
|
|
8
10
|
|
|
9
11
|
// These tests exercise the balance-item filters reused inside the payments query (balanceItemPayments ->
|
|
@@ -155,4 +157,150 @@ describe('Endpoint.GetPaymentsEndpoint', () => {
|
|
|
155
157
|
expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
|
|
156
158
|
});
|
|
157
159
|
});
|
|
160
|
+
|
|
161
|
+
describe('Settlements of a payment', () => {
|
|
162
|
+
const createSettledPayment = async (organization: Organization) => {
|
|
163
|
+
const payment = new Payment();
|
|
164
|
+
payment.method = PaymentMethod.Bancontact;
|
|
165
|
+
payment.provider = PaymentProvider.Stripe;
|
|
166
|
+
payment.status = PaymentStatus.Succeeded;
|
|
167
|
+
payment.organizationId = organization.id;
|
|
168
|
+
payment.price = 50_00_00;
|
|
169
|
+
payment.paidAt = new Date();
|
|
170
|
+
await payment.save();
|
|
171
|
+
|
|
172
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
173
|
+
provider: PaymentProvider.Stripe,
|
|
174
|
+
externalId: 'po_' + payment.id,
|
|
175
|
+
organizationId: organization.id,
|
|
176
|
+
reference: 'STRIPE PAYOUT',
|
|
177
|
+
amount: 49_00_00,
|
|
178
|
+
settledAt: new Date(2026, 0, 15),
|
|
179
|
+
});
|
|
180
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
181
|
+
paymentId: payment.id,
|
|
182
|
+
amount: 50_00_00,
|
|
183
|
+
externalId: 'txn_' + payment.id,
|
|
184
|
+
occurredAt: new Date(2026, 0, 14),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { payment, settlement };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
test('the m2m settlement filter selects only payments in a matching payout', async () => {
|
|
191
|
+
const organization = await new OrganizationFactory({}).create();
|
|
192
|
+
const user = await createFinanceUser(organization);
|
|
193
|
+
|
|
194
|
+
const { payment, settlement } = await createSettledPayment(organization);
|
|
195
|
+
|
|
196
|
+
// Negative control: a payment without settlement rows
|
|
197
|
+
const other = new Payment();
|
|
198
|
+
other.method = PaymentMethod.Bancontact;
|
|
199
|
+
other.provider = PaymentProvider.Stripe;
|
|
200
|
+
other.status = PaymentStatus.Succeeded;
|
|
201
|
+
other.organizationId = organization.id;
|
|
202
|
+
other.price = 10_00_00;
|
|
203
|
+
await other.save();
|
|
204
|
+
|
|
205
|
+
const response = await getPayments({
|
|
206
|
+
filter: {
|
|
207
|
+
settlements: {
|
|
208
|
+
$elemMatch: {
|
|
209
|
+
settlement: {
|
|
210
|
+
externalId: settlement.externalId,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
organization,
|
|
216
|
+
user,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(response.status).toBe(200);
|
|
220
|
+
expect(response.body.results.map(r => r.id)).toEqual([payment.id]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an admin of the organization receives the settlements of a payment', async () => {
|
|
224
|
+
const organization = await new OrganizationFactory({}).create();
|
|
225
|
+
const user = await createFinanceUser(organization);
|
|
226
|
+
|
|
227
|
+
const { payment, settlement } = await createSettledPayment(organization);
|
|
228
|
+
|
|
229
|
+
const response = await getPayments({
|
|
230
|
+
filter: { id: payment.id },
|
|
231
|
+
organization,
|
|
232
|
+
user,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(response.status).toBe(200);
|
|
236
|
+
expect(response.body.results).toHaveLength(1);
|
|
237
|
+
|
|
238
|
+
const result = response.body.results[0];
|
|
239
|
+
expect(result.settlements).toHaveLength(1);
|
|
240
|
+
expect(result.settlements[0]).toMatchObject({
|
|
241
|
+
paymentId: payment.id,
|
|
242
|
+
amount: 50_00_00,
|
|
243
|
+
externalId: 'txn_' + payment.id,
|
|
244
|
+
});
|
|
245
|
+
expect(result.settlements[0].settlement).toMatchObject({
|
|
246
|
+
externalId: settlement.externalId,
|
|
247
|
+
reference: 'STRIPE PAYOUT',
|
|
248
|
+
amount: 49_00_00,
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('the platform payout of a destination charge is never returned to the organization', async () => {
|
|
253
|
+
const organization = await new OrganizationFactory({}).create();
|
|
254
|
+
const user = await createFinanceUser(organization);
|
|
255
|
+
|
|
256
|
+
const stripeAccount = new StripeAccount();
|
|
257
|
+
stripeAccount.organizationId = organization.id;
|
|
258
|
+
stripeAccount.accountId = 'acct_' + uuidv4();
|
|
259
|
+
await stripeAccount.save();
|
|
260
|
+
|
|
261
|
+
const payment = new Payment();
|
|
262
|
+
payment.method = PaymentMethod.Bancontact;
|
|
263
|
+
payment.provider = PaymentProvider.Stripe;
|
|
264
|
+
payment.status = PaymentStatus.Succeeded;
|
|
265
|
+
payment.organizationId = organization.id;
|
|
266
|
+
payment.stripeAccountId = stripeAccount.id;
|
|
267
|
+
payment.price = 50_00_00;
|
|
268
|
+
payment.paidAt = new Date();
|
|
269
|
+
await payment.save();
|
|
270
|
+
|
|
271
|
+
const organizationPayout = await SettlementService.upsertSettlement({
|
|
272
|
+
provider: PaymentProvider.Stripe,
|
|
273
|
+
externalId: 'po_org_' + payment.id,
|
|
274
|
+
stripeAccountId: stripeAccount.id,
|
|
275
|
+
organizationId: organization.id,
|
|
276
|
+
amount: 49_00_00,
|
|
277
|
+
settledAt: new Date(2026, 0, 15),
|
|
278
|
+
});
|
|
279
|
+
await SettlementService.upsertPaymentLine(organizationPayout, {
|
|
280
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_org_' + payment.id, occurredAt: new Date(2026, 0, 14),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// The same gross charge also sits in our platform payout, owned by the membership
|
|
284
|
+
// organization
|
|
285
|
+
const membershipOrganization = await new OrganizationFactory({}).create();
|
|
286
|
+
const platformPayout = await SettlementService.upsertSettlement({
|
|
287
|
+
provider: PaymentProvider.Stripe,
|
|
288
|
+
externalId: 'po_platform_' + payment.id,
|
|
289
|
+
stripeAccountId: null,
|
|
290
|
+
organizationId: membershipOrganization.id,
|
|
291
|
+
amount: 1234_00_00,
|
|
292
|
+
settledAt: new Date(2026, 0, 12),
|
|
293
|
+
});
|
|
294
|
+
await SettlementService.upsertPaymentLine(platformPayout, {
|
|
295
|
+
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_platform_' + payment.id, occurredAt: new Date(2026, 0, 11),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const response = await getPayments({ filter: { id: payment.id }, organization, user });
|
|
299
|
+
|
|
300
|
+
expect(response.status).toBe(200);
|
|
301
|
+
const result = response.body.results[0];
|
|
302
|
+
expect(result.settlements).toHaveLength(1);
|
|
303
|
+
expect(result.settlements[0].settlement.externalId).toBe(organizationPayout.externalId);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
158
306
|
});
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { SettlementsSyncStatus } from '@stamhoofd/structures/settlements/SettlementsSyncStatus.js';
|
|
5
|
+
|
|
6
|
+
import { SettlementsSyncEndpoint } from './SettlementsSyncEndpoint.js';
|
|
5
7
|
|
|
6
8
|
type Params = Record<string, never>;
|
|
7
9
|
type Body = undefined;
|
|
8
10
|
type Query = undefined;
|
|
9
|
-
type ResponseBody =
|
|
11
|
+
type ResponseBody = SettlementsSyncStatus[];
|
|
10
12
|
|
|
11
|
-
export class
|
|
13
|
+
export class GetSettlementsSyncStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
12
14
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
13
15
|
if (request.method !== 'GET') {
|
|
14
16
|
return [false];
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
const params = Endpoint.parseParameters(request.url, '/
|
|
19
|
+
const params = Endpoint.parseParameters(request.url, '/settlements/sync/status', {});
|
|
18
20
|
|
|
19
21
|
if (params) {
|
|
20
22
|
return [true, params as Params];
|
|
@@ -23,10 +25,10 @@ export class GetStripePayoutsExportStatusEndpoint extends Endpoint<Params, Query
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
26
|
-
await
|
|
28
|
+
await SettlementsSyncEndpoint.authenticate();
|
|
27
29
|
|
|
28
|
-
return new Response(
|
|
29
|
-
return
|
|
30
|
+
return new Response(SettlementsSyncEndpoint.queue.map((item) => {
|
|
31
|
+
return SettlementsSyncStatus.create(item);
|
|
30
32
|
}));
|
|
31
33
|
}
|
|
32
34
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
3
|
+
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
4
|
+
import { OrganizationFactory, Payment, Token as TokenModel, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
7
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
8
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
9
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
10
|
+
|
|
11
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
12
|
+
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
13
|
+
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
14
|
+
import { SettlementService } from '../../../../services/SettlementService.js';
|
|
15
|
+
import { SettlementsExportEndpoint } from './SettlementsExportEndpoint.js';
|
|
16
|
+
|
|
17
|
+
describe('Endpoint.SettlementsExport', () => {
|
|
18
|
+
const endpoint = new SettlementsExportEndpoint();
|
|
19
|
+
|
|
20
|
+
let membershipOrganization: Organization;
|
|
21
|
+
let admin: User;
|
|
22
|
+
let adminToken: Token;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
membershipOrganization = await initMembershipOrganization();
|
|
26
|
+
membershipOrganization.privateMeta.featureFlags = ['settlements'];
|
|
27
|
+
await membershipOrganization.save();
|
|
28
|
+
|
|
29
|
+
({ admin, adminToken } = await initPlatformAdmin());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const createFinanceAdmin = async (organization: Organization) => {
|
|
33
|
+
const user = await new UserFactory({
|
|
34
|
+
organization,
|
|
35
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
36
|
+
}).create();
|
|
37
|
+
return { user, token: await TokenModel.createToken(user) };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const post = async (organization: Organization, token: Token, { start = new Date(2026, 0, 1), end = new Date(2026, 1, 1) } = {}) => {
|
|
41
|
+
const request = Request.buildJson('POST', '/settlements/export', organization.getApiHost(), {
|
|
42
|
+
start: start.getTime(),
|
|
43
|
+
end: end.getTime(),
|
|
44
|
+
});
|
|
45
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
46
|
+
return await testServer.test(endpoint, request);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const createSettledPayment = async () => {
|
|
50
|
+
const organization = await new OrganizationFactory({}).create();
|
|
51
|
+
|
|
52
|
+
const payment = new Payment();
|
|
53
|
+
payment.organizationId = organization.id;
|
|
54
|
+
payment.method = PaymentMethod.Bancontact;
|
|
55
|
+
payment.provider = PaymentProvider.Mollie;
|
|
56
|
+
payment.status = PaymentStatus.Succeeded;
|
|
57
|
+
payment.price = 50_00_00;
|
|
58
|
+
payment.paidAt = new Date(2026, 0, 10);
|
|
59
|
+
await payment.save();
|
|
60
|
+
|
|
61
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
62
|
+
provider: PaymentProvider.Mollie,
|
|
63
|
+
externalId: 'stl_' + uuidv4(),
|
|
64
|
+
organizationId: organization.id,
|
|
65
|
+
reference: '1234567.2601.01',
|
|
66
|
+
amount: 49_63_70,
|
|
67
|
+
settledAt: new Date(2026, 0, 15),
|
|
68
|
+
});
|
|
69
|
+
await SettlementService.upsertPaymentLine(settlement, {
|
|
70
|
+
paymentId: payment.id,
|
|
71
|
+
amount: 50_00_00,
|
|
72
|
+
externalId: 'tr_' + uuidv4(),
|
|
73
|
+
occurredAt: new Date(2026, 0, 15),
|
|
74
|
+
});
|
|
75
|
+
await SettlementService.upsertCharge({
|
|
76
|
+
type: SettlementChargeType.ProviderTransactionFee,
|
|
77
|
+
externalId: settlement.externalId + ':cost:0',
|
|
78
|
+
amount: -30_00,
|
|
79
|
+
settlementId: settlement.id,
|
|
80
|
+
organizationId: settlement.organizationId,
|
|
81
|
+
providerInvoiceId: 'inv_123',
|
|
82
|
+
description: 'Transactiekosten',
|
|
83
|
+
occurredAt: new Date(2026, 0, 15),
|
|
84
|
+
});
|
|
85
|
+
await SettlementService.upsertCharge({
|
|
86
|
+
type: SettlementChargeType.Tax,
|
|
87
|
+
externalId: settlement.externalId + ':cost:0:tax',
|
|
88
|
+
amount: -6_30,
|
|
89
|
+
settlementId: settlement.id,
|
|
90
|
+
organizationId: settlement.organizationId,
|
|
91
|
+
providerInvoiceId: 'inv_123',
|
|
92
|
+
description: 'BTW op transactiekosten',
|
|
93
|
+
occurredAt: new Date(2026, 0, 15),
|
|
94
|
+
});
|
|
95
|
+
await SettlementService.finishSync(settlement, { transactionCount: 3 });
|
|
96
|
+
|
|
97
|
+
return { organization, payment, settlement };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
test('A platform admin receives the report by email', async () => {
|
|
101
|
+
await createSettledPayment();
|
|
102
|
+
|
|
103
|
+
const response = await post(membershipOrganization, adminToken);
|
|
104
|
+
expect(response.status).toBe(200);
|
|
105
|
+
|
|
106
|
+
await QueueHandler.awaitAll();
|
|
107
|
+
|
|
108
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
109
|
+
const reportEmail = emails.find(e => e.subject.startsWith('Uitbetalingen export'));
|
|
110
|
+
expect(reportEmail).toBeDefined();
|
|
111
|
+
expect(reportEmail!.attachments).toHaveLength(1);
|
|
112
|
+
expect(reportEmail!.attachments![0].filename).toContain('.xlsx');
|
|
113
|
+
expect(reportEmail!.to).toContain(admin.email);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('A user without finance access cannot export settlements', async () => {
|
|
117
|
+
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
118
|
+
const token = await TokenModel.createToken(user);
|
|
119
|
+
|
|
120
|
+
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
121
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('A finance admin exports the settlements of their own organization', async () => {
|
|
126
|
+
const { organization } = await createSettledPayment();
|
|
127
|
+
organization.privateMeta.featureFlags = ['settlements'];
|
|
128
|
+
await organization.save();
|
|
129
|
+
|
|
130
|
+
const { user, token } = await createFinanceAdmin(organization);
|
|
131
|
+
|
|
132
|
+
const response = await post(organization, token);
|
|
133
|
+
expect(response.status).toBe(200);
|
|
134
|
+
|
|
135
|
+
await QueueHandler.awaitAll();
|
|
136
|
+
|
|
137
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
138
|
+
const reportEmail = emails.filter(e => e.subject.startsWith('Uitbetalingen export')).at(-1);
|
|
139
|
+
expect(reportEmail).toBeDefined();
|
|
140
|
+
expect(reportEmail!.to).toContain(user.email);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Without the feature flag the export is not available', async () => {
|
|
144
|
+
const organization = await new OrganizationFactory({}).create();
|
|
145
|
+
const { token } = await createFinanceAdmin(organization);
|
|
146
|
+
|
|
147
|
+
await expect(post(organization, token)).rejects.toThrow(
|
|
148
|
+
STExpect.simpleError({ code: 'not_available' }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('An unbounded range is rejected', async () => {
|
|
153
|
+
await expect(post(membershipOrganization, adminToken, { start: new Date(2020, 0, 1), end: new Date(2026, 0, 1) })).rejects.toThrow(
|
|
154
|
+
STExpect.simpleError({ code: 'range_too_large' }),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { AutoEncoder, DateDecoder, EnumDecoder, field } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Email } from '@stamhoofd/email';
|
|
7
|
+
import { Organization, Platform } from '@stamhoofd/models';
|
|
8
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
9
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
10
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
11
|
+
|
|
12
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
13
|
+
import { SettlementExporter } from '../../../../helpers/SettlementExporter.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* payment_settlements grows like payments, so an export can't span an unbounded range.
|
|
17
|
+
*/
|
|
18
|
+
const MAXIMUM_RANGE_MS = 1100 * 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
type Params = Record<string, never>;
|
|
21
|
+
class Body extends AutoEncoder {
|
|
22
|
+
@field({ decoder: DateDecoder })
|
|
23
|
+
start: Date;
|
|
24
|
+
|
|
25
|
+
@field({ decoder: DateDecoder })
|
|
26
|
+
end: Date;
|
|
27
|
+
|
|
28
|
+
@field({ decoder: new EnumDecoder(PaymentProvider), nullable: true, optional: true })
|
|
29
|
+
provider: PaymentProvider | null = null;
|
|
30
|
+
}
|
|
31
|
+
type Query = undefined;
|
|
32
|
+
type ResponseBody = undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the settlements reconciliation report (from the stored tables only, no provider API calls)
|
|
36
|
+
* and email it to the requesting admin.
|
|
37
|
+
*
|
|
38
|
+
* An export always covers exactly the payouts of the organization in context, for any finance
|
|
39
|
+
* admin of that organization. The platform's own payouts belong to the membership organization, so
|
|
40
|
+
* platform admins export those by scoping to the membership organization like any other.
|
|
41
|
+
*/
|
|
42
|
+
export class SettlementsExportEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
43
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
44
|
+
|
|
45
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
46
|
+
if (request.method !== 'POST') {
|
|
47
|
+
return [false];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const params = Endpoint.parseParameters(request.url, '/settlements/export', {});
|
|
51
|
+
|
|
52
|
+
if (params) {
|
|
53
|
+
return [true, params as Params];
|
|
54
|
+
}
|
|
55
|
+
return [false];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static async authenticate() {
|
|
59
|
+
const organization = await Context.setOrganizationScope();
|
|
60
|
+
const { user } = await Context.authenticate();
|
|
61
|
+
|
|
62
|
+
if (!await Context.auth.canManageFinances(organization.id)) {
|
|
63
|
+
throw Context.auth.error();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const platform = await Platform.getSharedStruct();
|
|
67
|
+
const enabled = platform.config.featureFlags.includes('settlements')
|
|
68
|
+
|| organization.privateMeta.featureFlags.includes('settlements');
|
|
69
|
+
|
|
70
|
+
if (!enabled) {
|
|
71
|
+
throw new SimpleError({
|
|
72
|
+
code: 'not_available',
|
|
73
|
+
message: 'Settlements are not enabled for this organization',
|
|
74
|
+
statusCode: 400,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { organization, user, platform };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
82
|
+
const { organization, user, platform } = await SettlementsExportEndpoint.authenticate();
|
|
83
|
+
|
|
84
|
+
const { start, end, provider } = request.body;
|
|
85
|
+
|
|
86
|
+
if (end.getTime() < start.getTime()) {
|
|
87
|
+
throw new SimpleError({
|
|
88
|
+
code: 'invalid_range',
|
|
89
|
+
message: 'The export ends before it starts',
|
|
90
|
+
statusCode: 400,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (end.getTime() - start.getTime() > MAXIMUM_RANGE_MS) {
|
|
95
|
+
throw new SimpleError({
|
|
96
|
+
code: 'range_too_large',
|
|
97
|
+
message: 'The requested export range is too large',
|
|
98
|
+
statusCode: 400,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The membership organization sells the platform's fee invoices
|
|
103
|
+
const membershipOrganization = platform.membershipOrganizationId ? await Organization.getByID(platform.membershipOrganizationId) : null;
|
|
104
|
+
if (!membershipOrganization) {
|
|
105
|
+
throw new SimpleError({
|
|
106
|
+
code: 'missing_membership_organization',
|
|
107
|
+
message: 'Platform has no membership organization configured',
|
|
108
|
+
statusCode: 400,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Serialized so concurrent exports can't compete for memory; the result arrives by email
|
|
113
|
+
QueueHandler.schedule('settlements-export', async () => {
|
|
114
|
+
const exporter = new SettlementExporter({
|
|
115
|
+
start,
|
|
116
|
+
end,
|
|
117
|
+
provider,
|
|
118
|
+
organization,
|
|
119
|
+
sellingOrganization: membershipOrganization,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await exporter.sendEmail({
|
|
123
|
+
to: [{ email: user.email, name: user.name }],
|
|
124
|
+
});
|
|
125
|
+
}).catch((e) => {
|
|
126
|
+
// The request already returned: without this the export just never arrives
|
|
127
|
+
console.error('Settlements export failed', e);
|
|
128
|
+
|
|
129
|
+
Email.send({
|
|
130
|
+
from: Email.getWebmasterFromEmail(),
|
|
131
|
+
to: [{ email: user.email, name: user.name }],
|
|
132
|
+
subject: 'Uitbetalingen export mislukt',
|
|
133
|
+
text: 'Het maken van de export van ' + Formatter.dateTime(start) + ' tot ' + Formatter.dateTime(end) + ' is mislukt. Probeer een kortere periode of neem contact op.\n',
|
|
134
|
+
type: 'transactional',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return new Response(undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
3
|
+
import { OrganizationFactory, Payment, Token as TokenModel, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
5
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
|
+
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
7
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
8
|
+
|
|
9
|
+
import { StripeMocker } from '../../../../../tests/helpers/StripeMocker.js';
|
|
10
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
11
|
+
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
12
|
+
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
13
|
+
import { GetSettlementsSyncStatusEndpoint } from './GetSettlementsSyncStatusEndpoint.js';
|
|
14
|
+
import { SettlementsSyncEndpoint } from './SettlementsSyncEndpoint.js';
|
|
15
|
+
|
|
16
|
+
describe('Endpoint.SettlementsSync', () => {
|
|
17
|
+
const endpoint = new SettlementsSyncEndpoint();
|
|
18
|
+
const statusEndpoint = new GetSettlementsSyncStatusEndpoint();
|
|
19
|
+
const stripeMocker = new StripeMocker();
|
|
20
|
+
|
|
21
|
+
let membershipOrganization: Organization;
|
|
22
|
+
let admin: User;
|
|
23
|
+
let adminToken: Token;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
membershipOrganization = await initMembershipOrganization();
|
|
27
|
+
({ admin, adminToken } = await initPlatformAdmin());
|
|
28
|
+
stripeMocker.start();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
stripeMocker.stop();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
stripeMocker.clear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const post = async (organization: Organization, token: Token, body: Record<string, unknown> = {}) => {
|
|
40
|
+
const request = Request.buildJson('POST', '/settlements/sync', organization.getApiHost(), {
|
|
41
|
+
start: new Date(2026, 0, 1).getTime(),
|
|
42
|
+
end: new Date(2026, 0, 31).getTime(),
|
|
43
|
+
providers: [PaymentProvider.Stripe],
|
|
44
|
+
...body,
|
|
45
|
+
});
|
|
46
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
47
|
+
return await testServer.test(endpoint, request);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const getStatus = async (organization: Organization, token: Token) => {
|
|
51
|
+
const request = Request.buildJson('GET', '/settlements/sync/status', organization.getApiHost());
|
|
52
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
53
|
+
return await testServer.test(statusEndpoint, request);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test('A platform admin can run the sync, which stores the payouts', async () => {
|
|
57
|
+
// A payment on our own platform account: a connected organization's payment only passes
|
|
58
|
+
// through our balance and is settled by its own payout
|
|
59
|
+
const payment = new Payment();
|
|
60
|
+
payment.organizationId = membershipOrganization.id;
|
|
61
|
+
payment.method = PaymentMethod.Bancontact;
|
|
62
|
+
payment.provider = PaymentProvider.Stripe;
|
|
63
|
+
payment.status = PaymentStatus.Succeeded;
|
|
64
|
+
payment.price = 100_00_00;
|
|
65
|
+
payment.paidAt = new Date(2026, 0, 15);
|
|
66
|
+
await payment.save();
|
|
67
|
+
|
|
68
|
+
const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
|
|
69
|
+
stripeMocker.createBalanceTransaction({
|
|
70
|
+
type: 'charge',
|
|
71
|
+
amount: 10000,
|
|
72
|
+
created: new Date(2026, 0, 15),
|
|
73
|
+
payout: payout.id,
|
|
74
|
+
source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const response = await post(membershipOrganization, adminToken);
|
|
78
|
+
expect(response.status).toBe(200);
|
|
79
|
+
|
|
80
|
+
await QueueHandler.awaitAll();
|
|
81
|
+
|
|
82
|
+
const settlement = await Settlement.select().where('externalId', payout.id).first(true);
|
|
83
|
+
expect(settlement.syncedAt).not.toBeNull();
|
|
84
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
85
|
+
|
|
86
|
+
// The queue is empty again
|
|
87
|
+
const statusResponse = await getStatus(membershipOrganization, adminToken);
|
|
88
|
+
expect(statusResponse.body).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('A user without platform full access cannot run the sync', async () => {
|
|
92
|
+
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
93
|
+
const token = await TokenModel.createToken(user);
|
|
94
|
+
|
|
95
|
+
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
96
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
97
|
+
);
|
|
98
|
+
await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
|
|
99
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('The sync is not available for other organizations', async () => {
|
|
104
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
105
|
+
|
|
106
|
+
await expect(post(otherOrganization, adminToken)).rejects.toThrow(
|
|
107
|
+
STExpect.simpleError({ code: 'not_available' }),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|