@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.
Files changed (69) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/auth/MFA.security.test.ts +76 -1
  11. package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
  13. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  14. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +14 -14
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  17. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  22. package/src/excel-loaders/payments.ts +18 -1
  23. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  24. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  25. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  26. package/src/helpers/AuthenticatedStructures.ts +14 -1
  27. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  28. package/src/helpers/MollieSettlementSync.ts +323 -0
  29. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  30. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  31. package/src/helpers/SettlementExporter.test.ts +366 -0
  32. package/src/helpers/SettlementExporter.ts +583 -0
  33. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  34. package/src/helpers/SettlementSyncRunner.ts +64 -0
  35. package/src/helpers/StripeHelper.ts +2 -1
  36. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  37. package/src/helpers/StripeSettlementSync.ts +947 -0
  38. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  39. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  40. package/src/helpers/TwoFactorHelper.ts +1 -1
  41. package/src/helpers/WebmasterReport.test.ts +109 -0
  42. package/src/helpers/WebmasterReport.ts +115 -0
  43. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  45. package/src/helpers/streamForBreakdown.ts +2 -2
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +283 -0
  48. package/src/services/DocumentRenderService.test.ts +72 -1
  49. package/src/services/InvoiceService.ts +18 -1
  50. package/src/services/PaymentService.ts +8 -0
  51. package/src/services/SettlementService.test.ts +559 -0
  52. package/src/services/SettlementService.ts +607 -0
  53. package/src/sql-filters/orders.ts +6 -2
  54. package/src/sql-filters/payment-settlement.test.ts +2 -2
  55. package/src/sql-filters/payments.ts +73 -0
  56. package/tests/filters/orders.test.ts +635 -0
  57. package/tests/helpers/MollieMocker.ts +64 -6
  58. package/tests/helpers/StripeMocker.ts +209 -17
  59. package/src/crons/stripe-payout-reports.ts +0 -69
  60. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  61. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  62. package/src/helpers/CheckSettlements.test.ts +0 -190
  63. package/src/helpers/CheckSettlements.ts +0 -237
  64. package/src/helpers/StripeInvoicer.ts +0 -419
  65. package/src/helpers/StripePayoutChecker.ts +0 -193
  66. package/src/helpers/StripePayoutExportData.ts +0 -195
  67. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  68. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  69. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -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 { PayoutExportStatus, StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
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 = PayoutExportStatus[];
11
+ type ResponseBody = SettlementsSyncStatus[];
10
12
 
11
- export class GetStripePayoutsExportStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
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, '/stripe/payouts/status', {});
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 StripePayoutsExportEndpoint.authenticate();
28
+ await SettlementsSyncEndpoint.authenticate();
27
29
 
28
- return new Response(StripePayoutsExportEndpoint.queue.map((item) => {
29
- return PayoutExportStatus.create(item);
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
+ });