@stamhoofd/backend 2.140.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 (61) 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/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  11. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  12. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  15. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  16. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  17. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  20. package/src/excel-loaders/payments.ts +18 -1
  21. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  22. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  23. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  24. package/src/helpers/AuthenticatedStructures.ts +14 -1
  25. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  26. package/src/helpers/MollieSettlementSync.ts +323 -0
  27. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  28. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  29. package/src/helpers/SettlementExporter.test.ts +366 -0
  30. package/src/helpers/SettlementExporter.ts +583 -0
  31. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  32. package/src/helpers/SettlementSyncRunner.ts +64 -0
  33. package/src/helpers/StripeHelper.ts +2 -1
  34. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  35. package/src/helpers/StripeSettlementSync.ts +947 -0
  36. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  37. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  38. package/src/helpers/WebmasterReport.test.ts +109 -0
  39. package/src/helpers/WebmasterReport.ts +115 -0
  40. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  41. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  42. package/src/services/ApplicationFeeService.test.ts +256 -0
  43. package/src/services/ApplicationFeeService.ts +283 -0
  44. package/src/services/InvoiceService.ts +18 -1
  45. package/src/services/SettlementService.test.ts +559 -0
  46. package/src/services/SettlementService.ts +607 -0
  47. package/src/sql-filters/payment-settlement.test.ts +2 -2
  48. package/src/sql-filters/payments.ts +73 -0
  49. package/tests/helpers/MollieMocker.ts +64 -6
  50. package/tests/helpers/StripeMocker.ts +209 -17
  51. package/src/crons/stripe-payout-reports.ts +0 -69
  52. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  53. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  54. package/src/helpers/CheckSettlements.test.ts +0 -190
  55. package/src/helpers/CheckSettlements.ts +0 -237
  56. package/src/helpers/StripeInvoicer.ts +0 -419
  57. package/src/helpers/StripePayoutChecker.ts +0 -193
  58. package/src/helpers/StripePayoutExportData.ts +0 -195
  59. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  60. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  61. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,109 @@
1
+ // Nightly settlement sync for all providers. Everything is an upsert and synced payouts are
2
+ // skipped, so the windowed re-walk is cheap.
3
+ import { registerCron } from '@stamhoofd/crons';
4
+ import { Email } from '@stamhoofd/email';
5
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
6
+ import { PaymentProvider } from '@stamhoofd/structures';
7
+ import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
8
+ import { Formatter } from '@stamhoofd/utility';
9
+
10
+ import { SettlementSyncRunner } from '../helpers/SettlementSyncRunner.js';
11
+ import { isApplicationFeeInvoicingEnabled } from './stripe-invoices.js';
12
+
13
+ registerCron('settlement-sync', syncSettlements);
14
+
15
+ /**
16
+ * How many days of settlements the nightly run re-walks.
17
+ */
18
+ const SYNC_WINDOW_DAYS = 30;
19
+
20
+ /**
21
+ * Fees received in a platform payout are billed by the monthly invoicer, so pendingFees taking a
22
+ * while to reach 0 is normal. Older than this it means the invoicer failed or skipped something.
23
+ */
24
+ const MAXIMUM_PENDING_FEES_AGE_MS = 31 * 24 * 60 * 60 * 1000;
25
+
26
+ let lastSettlementSync: Date | null = null;
27
+
28
+ async function syncSettlements() {
29
+ if (STAMHOOFD.environment !== 'production') {
30
+ return;
31
+ }
32
+
33
+ // Between 5 and 6 AM Brussels time.
34
+ if (Formatter.luxon().hour !== 5) {
35
+ return;
36
+ }
37
+
38
+ // Wait for the next day before doing a new sync
39
+ const today = new Date();
40
+ if (lastSettlementSync && Formatter.dateIso(lastSettlementSync) === Formatter.dateIso(today)) {
41
+ console.log('Settlement sync done for this day');
42
+ return;
43
+ }
44
+
45
+ console.log('Syncing settlements...');
46
+
47
+ const start = new Date(today.getTime() - SYNC_WINDOW_DAYS * 24 * 60 * 60 * 1000);
48
+
49
+ const runner = new SettlementSyncRunner();
50
+ await runner.run({
51
+ start,
52
+ providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
53
+ stripe: { retryUnsynced: true },
54
+ });
55
+
56
+ await reportProblemSettlements();
57
+
58
+ lastSettlementSync = new Date();
59
+ }
60
+
61
+ /**
62
+ * Every unsynced settlement or non-zero delta is a real question to answer, not noise.
63
+ *
64
+ * Exported for tests only.
65
+ */
66
+ export async function reportProblemSettlements() {
67
+ // Only payouts that actually arrived: money that failed or was canceled holds no transactions
68
+ // to reconcile
69
+ const unexplained = await Settlement.select()
70
+ .where('status', SettlementStatus.Paid)
71
+ .where('unexplainedAmount', '!=', 0)
72
+ .limit(50)
73
+ .fetch();
74
+
75
+ const unsynced = await Settlement.select()
76
+ .where('status', SettlementStatus.Paid)
77
+ .where('syncedAt', null)
78
+ .limit(50)
79
+ .fetch();
80
+
81
+ // Fees only stop being pending once the invoicer bills them: while it is off, every platform
82
+ // payout would be reported forever
83
+ const stalePendingFees = isApplicationFeeInvoicingEnabled()
84
+ ? await Settlement.select()
85
+ .where('status', SettlementStatus.Paid)
86
+ .where('pendingFees', '!=', 0)
87
+ .where('settledAt', '<', new Date(Date.now() - MAXIMUM_PENDING_FEES_AGE_MS))
88
+ .limit(50)
89
+ .fetch()
90
+ : [];
91
+
92
+ if (unexplained.length === 0 && unsynced.length === 0 && stalePendingFees.length === 0) {
93
+ return;
94
+ }
95
+
96
+ const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
97
+
98
+ const describe = (settlement: Settlement) => {
99
+ return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
100
+ };
101
+
102
+ Email.sendWebmaster({
103
+ subject: 'Uitbetalingen met problemen: ' + total,
104
+ html: 'Deze uitbetalingen kloppen niet of konden niet gesynchroniseerd worden: <br><br>'
105
+ + 'Onverklaard verschil:<br>' + (unexplained.map(describe).join('<br>') || 'geen') + '<br><br>'
106
+ + 'Niet gesynchroniseerd:<br>' + (unsynced.map(describe).join('<br>') || 'geen') + '<br><br>'
107
+ + 'Kosten te lang niet gefactureerd:<br>' + (stalePendingFees.map(describe).join('<br>') || 'geen'),
108
+ });
109
+ }
@@ -1,22 +1,28 @@
1
1
  import { registerCron } from '@stamhoofd/crons';
2
- import { Formatter } from '@stamhoofd/utility';
3
- import { StripeInvoicer } from '../helpers/StripeInvoicer.js';
4
2
  import { Organization, Platform } from '@stamhoofd/models';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ import { ApplicationFeeInvoicer } from '../helpers/ApplicationFeeInvoicer.js';
5
6
 
6
7
  registerCron('stripe-invoices', createStripeInvoices);
7
8
 
8
9
  let lastStripeInvoice: Date | null = null;
9
10
 
10
- async function createStripeInvoices() {
11
- if (STAMHOOFD.environment !== 'production' && STAMHOOFD.environment !== 'development') {
12
- return;
13
- }
14
-
15
- if (STAMHOOFD.userMode === 'platform') {
16
- return;
17
- }
11
+ /**
12
+ * Whether application fees are billed automatically. The sync stores what we received either way,
13
+ * so anything that reports on uninvoiced fees has to ask this first: while it is off, fees staying
14
+ * uninvoiced is the expected state, not a problem.
15
+ *
16
+ * The settlements feature is not released yet: allow production at go-live.
17
+ */
18
+ export function isApplicationFeeInvoicingEnabled(): boolean {
19
+ return (STAMHOOFD.environment as string) === 'development'
20
+ && STAMHOOFD.userMode !== 'platform'
21
+ && STAMHOOFD.STRIPE_CONNECT_METHOD !== 'standard';
22
+ }
18
23
 
19
- if (STAMHOOFD.STRIPE_CONNECT_METHOD === 'standard') {
24
+ async function createStripeInvoices() {
25
+ if (!isApplicationFeeInvoicingEnabled()) {
20
26
  return;
21
27
  }
22
28
 
@@ -41,9 +47,9 @@ async function createStripeInvoices() {
41
47
 
42
48
  const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
43
49
 
44
- const invoicer = new StripeInvoicer({
50
+ const invoicer = new ApplicationFeeInvoicer({
45
51
  secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
46
52
  });
47
- await invoicer.generateAllInvoices(membershipOrganization, { forceLast: Formatter.dateIso(today) === '2026-07-04' });
53
+ await invoicer.generateInvoices(membershipOrganization);
48
54
  lastStripeInvoice = new Date();
49
55
  }
package/src/crons.ts CHANGED
@@ -3,7 +3,6 @@ import { registerCron } from '@stamhoofd/crons';
3
3
  import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
4
4
  import { SQL } from '@stamhoofd/sql';
5
5
  import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
6
- import { checkSettlements } from './helpers/CheckSettlements.js';
7
6
  import { OrganizationDNSService } from './services/OrganizationDNSService.js';
8
7
  import { PaymentService } from './services/PaymentService.js';
9
8
  import { RegistrationService } from './services/RegistrationService.js';
@@ -193,7 +192,6 @@ async function checkOldPayments() {
193
192
  // 5 days - 10 days
194
193
  async function checkOldDirectDebitPayments() {
195
194
  let timeout = 60 * 1000 * 60 * 24 * 5;
196
- const timeout2 = 60 * 1000 * 60 * 24 * 10;
197
195
 
198
196
  if (STAMHOOFD.environment === 'development') {
199
197
  // For Mollie, webhooks won't work, so we poll in the backend
@@ -207,8 +205,7 @@ async function checkOldDirectDebitPayments() {
207
205
  PaymentMethod.DirectDebit,
208
206
  ])
209
207
  .and('status', [PaymentStatus.Created, PaymentStatus.Pending])
210
- .and('createdAt', '<', new Date(new Date().getTime() - timeout))
211
- .and('createdAt', '>', new Date(new Date().getTime() - timeout2)),
208
+ .and('createdAt', '<', new Date(new Date().getTime() - timeout)),
212
209
  )
213
210
  .orderBy('createdAt', 'ASC')
214
211
  .limit(500)
@@ -357,7 +354,6 @@ async function checkReservedUntil() {
357
354
  }
358
355
  }
359
356
 
360
- registerCron('checkSettlements', checkSettlements);
361
357
  registerCron('checkExpirationEmails', checkExpirationEmails);
362
358
  registerCron('checkReservedUntil', checkReservedUntil);
363
359
  registerCron('checkDNS', checkDNS);
@@ -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, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
4
  import type { StamhoofdFilter } from '@stamhoofd/structures';
5
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, Settlement, TranslatedString } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, TranslatedString } from '@stamhoofd/structures';
6
6
  import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
7
7
  import type { BalanceItemBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
8
8
  import { BreakdownAmountType, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
@@ -254,13 +254,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
254
254
  });
255
255
 
256
256
  describe('Grouping by payout', () => {
257
- const march = Settlement.create({ id: 'stl_march', reference: '1234567.0312.01', settledAt: new Date(2026, 2, 12), amount: 0 });
258
- const april = Settlement.create({ id: 'stl_april', reference: '1234567.0409.01', settledAt: new Date(2026, 3, 9), amount: 0 });
257
+ const march = SettlementReference.create({ id: 'stl_march', reference: '1234567.0312.01', settledAt: new Date(2026, 2, 12), amount: 0 });
258
+ const april = SettlementReference.create({ id: 'stl_april', reference: '1234567.0409.01', settledAt: new Date(2026, 3, 9), amount: 0 });
259
259
 
260
260
  /**
261
261
  * Pays a part of a balance item, the way a payment provider settles it afterwards.
262
262
  */
263
- const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?: Settlement; status?: PaymentStatus; method?: PaymentMethod }) => {
263
+ const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?: SettlementReference; status?: PaymentStatus; method?: PaymentMethod }) => {
264
264
  const payment = new Payment();
265
265
  payment.organizationId = organization.id;
266
266
  payment.method = options.method ?? PaymentMethod.Bancontact;
@@ -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
- checkMollieSettlementsFor(mollieToken.accessToken, true).catch(console.error);
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, Settlement, StripeBusinessProfile, StripeMetaData, TransferSettings, TranslatedString } 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, 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?: 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 Settlement.create({ id: 'stl_' + reference, reference, settledAt, amount: 0 });
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 { 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
+ });