@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.
Files changed (65) 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/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  12. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  13. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  14. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  15. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  16. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  17. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  18. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  19. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  22. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  23. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  24. package/src/excel-loaders/payments.ts +18 -1
  25. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  26. package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
  27. package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
  28. package/src/helpers/AuthenticatedStructures.ts +14 -1
  29. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  30. package/src/helpers/MollieSettlementSync.ts +323 -0
  31. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  32. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  33. package/src/helpers/SettlementExporter.test.ts +388 -0
  34. package/src/helpers/SettlementExporter.ts +593 -0
  35. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  36. package/src/helpers/SettlementSyncRunner.ts +64 -0
  37. package/src/helpers/StripeHelper.ts +2 -1
  38. package/src/helpers/StripeSettlementSync.test.ts +997 -0
  39. package/src/helpers/StripeSettlementSync.ts +1053 -0
  40. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  41. package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
  42. package/src/helpers/WebmasterReport.test.ts +109 -0
  43. package/src/helpers/WebmasterReport.ts +115 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  45. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +301 -0
  48. package/src/services/InvoiceService.ts +18 -1
  49. package/src/services/SettlementService.test.ts +632 -0
  50. package/src/services/SettlementService.ts +650 -0
  51. package/src/sql-filters/payment-settlement.test.ts +2 -2
  52. package/src/sql-filters/payments.ts +73 -0
  53. package/tests/helpers/MollieMocker.ts +64 -6
  54. package/tests/helpers/StripeMocker.ts +209 -17
  55. package/src/crons/stripe-payout-reports.ts +0 -69
  56. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  57. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  58. package/src/helpers/CheckSettlements.test.ts +0 -190
  59. package/src/helpers/CheckSettlements.ts +0 -237
  60. package/src/helpers/StripeInvoicer.ts +0 -419
  61. package/src/helpers/StripePayoutChecker.ts +0 -193
  62. package/src/helpers/StripePayoutExportData.ts +0 -195
  63. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  64. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  65. 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, ' + Formatter.price(settlement.uncollectibleFees) + ' niet-aanrekenbare 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);
@@ -0,0 +1,165 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { BalanceItemFactory, Invoice, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
5
+ import { PatchableArray } from '@simonbackx/simple-encoding';
6
+ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
7
+ import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
8
+ import { PaymentMethod, PaymentStatus } from '@stamhoofd/structures';
9
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
10
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
11
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
14
+ import { initPlatformAdmin } from '../../../../tests/init/index.js';
15
+ import { initMembershipOrganization } from '../../../../tests/init/initMembershipOrganization.js';
16
+ import { PatchOrganizationsEndpoint } from './PatchOrganizationsEndpoint.js';
17
+
18
+ const baseUrl = `/admin/organizations`;
19
+ const endpoint = new PatchOrganizationsEndpoint();
20
+
21
+ describe('Endpoint.PatchOrganizationsEndpoint', () => {
22
+ let membershipOrganization: Organization;
23
+
24
+ beforeEach(async () => {
25
+ TestUtils.setEnvironment('userMode', 'organization');
26
+ });
27
+
28
+ beforeAll(async () => {
29
+ membershipOrganization = await initMembershipOrganization();
30
+ });
31
+
32
+ const deleteOrganization = async (organization: Organization) => {
33
+ const { adminToken } = await initPlatformAdmin();
34
+
35
+ const patch: PatchableArrayAutoEncoder<OrganizationStruct> = new PatchableArray();
36
+ patch.addDelete(organization.id);
37
+
38
+ // Platform admins are not scoped to an organization, so no host is required
39
+ const request = Request.patch({
40
+ path: baseUrl,
41
+ host: '',
42
+ body: patch,
43
+ headers: {
44
+ authorization: 'Bearer ' + adminToken.accessToken,
45
+ },
46
+ });
47
+
48
+ return await testServer.test(endpoint, request);
49
+ };
50
+
51
+ const createPayment = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
52
+ const payment = new Payment();
53
+ payment.organizationId = membershipOrganization.id;
54
+ payment.payingOrganizationId = payingOrganizationId;
55
+ payment.method = PaymentMethod.Transfer;
56
+ payment.status = PaymentStatus.Succeeded;
57
+ payment.price = 10_00;
58
+ await payment.save();
59
+ return payment;
60
+ };
61
+
62
+ const createApplicationFee = async ({ payingOrganizationId }: { payingOrganizationId: string }) => {
63
+ const charge = new SettlementCharge();
64
+ charge.type = SettlementChargeType.ApplicationFeeService;
65
+ charge.externalId = uuidv4();
66
+ charge.amount = -30_00;
67
+ charge.organizationId = payingOrganizationId;
68
+ charge.occurredAt = new Date();
69
+ await charge.save();
70
+
71
+ const fee = new ApplicationFee();
72
+ fee.externalId = uuidv4();
73
+ fee.type = ApplicationFeeType.Service;
74
+ fee.amount = 30_00;
75
+ fee.organizationId = membershipOrganization.id;
76
+ fee.payingOrganizationId = payingOrganizationId;
77
+ fee.settlementChargeId = charge.id;
78
+ fee.occurredAt = new Date();
79
+ await fee.save();
80
+ return fee;
81
+ };
82
+
83
+ const createInvoice = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
84
+ const invoice = new Invoice();
85
+ invoice.organizationId = membershipOrganization.id;
86
+ invoice.payingOrganizationId = payingOrganizationId;
87
+ await invoice.save();
88
+ return invoice;
89
+ };
90
+
91
+ test('An organization without financial records can be deleted', async () => {
92
+ const organization = await new OrganizationFactory({}).create();
93
+
94
+ const response = await deleteOrganization(organization);
95
+
96
+ expect(response.status).toBe(200);
97
+ expect(await Organization.getByID(organization.id)).toBeUndefined();
98
+ });
99
+
100
+ const payerRecords = [
101
+ {
102
+ name: 'a balance item it owes',
103
+ create: async (organization: Organization) => {
104
+ await new BalanceItemFactory({
105
+ organizationId: membershipOrganization.id,
106
+ payingOrganizationId: organization.id,
107
+ amount: 1,
108
+ unitPrice: 10_00,
109
+ }).create();
110
+ },
111
+ },
112
+ {
113
+ name: 'a payment it made',
114
+ create: async (organization: Organization) => {
115
+ await createPayment({ payingOrganizationId: organization.id });
116
+ },
117
+ },
118
+ {
119
+ name: 'an application fee it was charged',
120
+ create: async (organization: Organization) => {
121
+ await createApplicationFee({ payingOrganizationId: organization.id });
122
+ },
123
+ },
124
+ {
125
+ name: 'an invoice it has to pay',
126
+ create: async (organization: Organization) => {
127
+ await createInvoice({ payingOrganizationId: organization.id });
128
+ },
129
+ },
130
+ ];
131
+
132
+ test.each(payerRecords)('An organization with $name cannot be deleted', async ({ create }) => {
133
+ const organization = await new OrganizationFactory({}).create();
134
+ await create(organization);
135
+
136
+ await expect(deleteOrganization(organization)).rejects.toThrow(STExpect.errorWithCode('organization_has_financial_records'));
137
+ expect(await Organization.getByID(organization.id)).toBeDefined();
138
+ });
139
+
140
+ test('Financial records the organization received do not block the delete', async () => {
141
+ const organization = await new OrganizationFactory({}).create();
142
+
143
+ await new BalanceItemFactory({
144
+ organizationId: organization.id,
145
+ amount: 1,
146
+ unitPrice: 10_00,
147
+ }).create();
148
+
149
+ const payment = new Payment();
150
+ payment.organizationId = organization.id;
151
+ payment.method = PaymentMethod.Transfer;
152
+ payment.status = PaymentStatus.Succeeded;
153
+ payment.price = 10_00;
154
+ await payment.save();
155
+
156
+ const invoice = new Invoice();
157
+ invoice.organizationId = organization.id;
158
+ await invoice.save();
159
+
160
+ const response = await deleteOrganization(organization);
161
+
162
+ expect(response.status).toBe(200);
163
+ expect(await Organization.getByID(organization.id)).toBeUndefined();
164
+ });
165
+ });
@@ -3,7 +3,8 @@ import { PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encodin
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
6
+ import { BalanceItem, Invoice, Organization, OrganizationRegistrationPeriod, Payment, Platform, RegistrationPeriod } from '@stamhoofd/models';
7
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
7
8
  import { Organization as OrganizationStruct } from '@stamhoofd/structures';
8
9
 
9
10
  import { Formatter } from '@stamhoofd/utility';
@@ -61,6 +62,22 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
61
62
  });
62
63
  }
63
64
 
65
+ // Financial records may not disappear together with the organization that had to pay them
66
+ const payerRecordCounts = await Promise.all([
67
+ BalanceItem.select().where('payingOrganizationId', id).count(),
68
+ Payment.select().where('payingOrganizationId', id).count(),
69
+ ApplicationFee.select().where('payingOrganizationId', id).count(),
70
+ Invoice.select().where('payingOrganizationId', id).count(),
71
+ ]);
72
+
73
+ if (payerRecordCounts.some(count => count > 0)) {
74
+ throw new SimpleError({
75
+ code: 'organization_has_financial_records',
76
+ message: 'Organization is still referenced as paying organization',
77
+ human: $t('Er zijn nog openstaande bedragen, betalingen, transactiekosten of facturen waarvoor {organization} moet betalen of betaald heeft. Die gegevens mogen niet verloren gaan.', { organization: organization.name }),
78
+ });
79
+ }
80
+
64
81
  await organization.delete();
65
82
  }
66
83
 
@@ -2,17 +2,18 @@ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
2
  import { PatchableArray } from '@simonbackx/simple-encoding';
3
3
  import { Request } from '@simonbackx/simple-endpoints';
4
4
  import type { Organization, User } from '@stamhoofd/models';
5
- import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
5
+ import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitation, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
6
6
  import type { RegistrationInvitation as RegistrationInvitationStruct } from '@stamhoofd/structures';
7
- import { PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
7
+ import { GroupType, PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
8
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
8
9
  import { testServer } from '../../../../tests/helpers/TestServer.js';
9
10
  import { PatchRegistrationInvitationsEndpoint } from './PatchRegistrationInvitationsEndpoint.js';
10
11
 
11
12
  describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
12
13
  const endpoint = new PatchRegistrationInvitationsEndpoint();
13
14
 
14
- const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization; user: User | null }) => {
15
- const request = Request.buildJson('PATCH', '/registration-invitations', organization.getApiHost(), patch);
15
+ const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization | null; user: User | null }) => {
16
+ const request = Request.buildJson('PATCH', '/registration-invitations', organization?.getApiHost(), patch);
16
17
  if (user) {
17
18
  const token = await Token.createToken(user);
18
19
  request.headers.authorization = 'Bearer ' + token.accessToken;
@@ -401,4 +402,153 @@ describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
401
402
  })).rejects.toThrow('Je hebt geen toegangsrechten om deze uitnodiging te verwijderen.');
402
403
  });
403
404
  });
405
+
406
+ describe('Without organization scope (platform admins)', () => {
407
+ beforeEach(() => {
408
+ TestUtils.setEnvironment('userMode', 'platform');
409
+ });
410
+
411
+ test('A platform admin can invite for an event group', async () => {
412
+ const organization = await new OrganizationFactory({}).create();
413
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
414
+
415
+ const admin = await new UserFactory({
416
+ globalPermissions: Permissions.create({
417
+ level: PermissionLevel.Full,
418
+ }),
419
+ }).create();
420
+
421
+ const member = await new MemberFactory({
422
+ organization,
423
+ firstName: 'John',
424
+ lastName: 'Doe',
425
+ birthDay: { year: 1994, month: 6, day: 24 },
426
+ }).create();
427
+
428
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
429
+
430
+ patch.addPut(RegistrationInvitationRequest.create({
431
+ groupId: group.id,
432
+ memberId: member.id,
433
+ }));
434
+
435
+ const response = await patchInvitations({
436
+ patch,
437
+ organization: null,
438
+ user: admin,
439
+ });
440
+
441
+ expect(response.body).toHaveLength(1);
442
+ expect(response.body[0].group.id).toBe(group.id);
443
+ expect(response.body[0].member.id).toBe(member.id);
444
+
445
+ // The invitation belongs to the organization of the group
446
+ const invitation = await RegistrationInvitation.getByID(response.body[0].id);
447
+ expect(invitation?.organizationId).toBe(organization.id);
448
+ });
449
+
450
+ test('Should fail for a user without platform access', async () => {
451
+ const organization = await new OrganizationFactory({}).create();
452
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
453
+
454
+ const user = await new UserFactory({
455
+ organization,
456
+ permissions: Permissions.create({
457
+ level: PermissionLevel.Full,
458
+ }),
459
+ }).create();
460
+
461
+ const member = await new MemberFactory({
462
+ organization,
463
+ firstName: 'John',
464
+ lastName: 'Doe',
465
+ birthDay: { year: 1994, month: 6, day: 24 },
466
+ }).create();
467
+
468
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
469
+
470
+ patch.addPut(RegistrationInvitationRequest.create({
471
+ groupId: group.id,
472
+ memberId: member.id,
473
+ }));
474
+
475
+ await expect(patchInvitations({
476
+ patch,
477
+ organization: null,
478
+ user,
479
+ })).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
480
+ });
481
+
482
+ test('Should fail for a platform admin without access to the group', async () => {
483
+ const organization = await new OrganizationFactory({}).create();
484
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
485
+
486
+ // Some platform access (passes the fast throw), but no rights on this group or its organization
487
+ const admin = await new UserFactory({
488
+ globalPermissions: Permissions.create({
489
+ level: PermissionLevel.None,
490
+ resources: new Map([
491
+ [PermissionsResourceType.OrganizationTags, new Map([
492
+ ['unrelated-tag-id', ResourcePermissions.create({
493
+ level: PermissionLevel.Read,
494
+ })],
495
+ ])],
496
+ ]),
497
+ }),
498
+ }).create();
499
+
500
+ const member = await new MemberFactory({
501
+ organization,
502
+ firstName: 'John',
503
+ lastName: 'Doe',
504
+ birthDay: { year: 1994, month: 6, day: 24 },
505
+ }).create();
506
+
507
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
508
+
509
+ patch.addPut(RegistrationInvitationRequest.create({
510
+ groupId: group.id,
511
+ memberId: member.id,
512
+ }));
513
+
514
+ await expect(patchInvitations({
515
+ patch,
516
+ organization: null,
517
+ user: admin,
518
+ })).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
519
+ });
520
+
521
+ test('A platform admin can delete an invitation', async () => {
522
+ const organization = await new OrganizationFactory({}).create();
523
+ const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
524
+
525
+ const admin = await new UserFactory({
526
+ globalPermissions: Permissions.create({
527
+ level: PermissionLevel.Full,
528
+ }),
529
+ }).create();
530
+
531
+ const member = await new MemberFactory({
532
+ organization,
533
+ firstName: 'John',
534
+ lastName: 'Doe',
535
+ birthDay: { year: 1994, month: 6, day: 24 },
536
+ }).create();
537
+
538
+ const invitation = await new RegistrationInvitationFactory({ member, group, organization }).create();
539
+
540
+ const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
541
+
542
+ patch.addDelete(invitation.id);
543
+
544
+ const response = await patchInvitations({
545
+ patch,
546
+ organization: null,
547
+ user: admin,
548
+ });
549
+
550
+ expect(response.body).toHaveLength(0);
551
+ expect(await RegistrationInvitation.getByID(invitation.id)).toBeUndefined();
552
+ });
553
+ });
404
554
  });
@@ -35,11 +35,15 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
35
35
  }
36
36
 
37
37
  async handle(request: DecodedRequest<Params, Query, Body>) {
38
- const organization = await Context.setOrganizationScope();
38
+ const organization = await Context.setOptionalOrganizationScope();
39
39
  await Context.authenticate();
40
40
 
41
41
  // Fast throw first (more in depth checking for patches later)
42
- if (!await Context.auth.hasSomeAccess(organization.id)) {
42
+ if (organization) {
43
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
44
+ throw Context.auth.error();
45
+ }
46
+ } else if (!Context.auth.hasSomePlatformAccess()) {
43
47
  throw Context.auth.error();
44
48
  }
45
49
 
@@ -48,11 +52,11 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
48
52
 
49
53
  const puts = request.body.getPuts();
50
54
  for (const { put } of puts) {
51
- await this.checkCanCreateRegistrationInvitation(put, organization.id);
55
+ const group = await this.checkCanCreateRegistrationInvitation(put, organization?.id ?? null);
52
56
 
53
57
  const invitation = new RegistrationInvitation();
54
58
  invitation.id = put.id;
55
- invitation.organizationId = organization.id;
59
+ invitation.organizationId = group.organizationId;
56
60
  invitation.groupId = put.groupId;
57
61
  invitation.memberId = put.memberId;
58
62
 
@@ -125,12 +129,13 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
125
129
  /**
126
130
  * Will throw if not allowed to invite.
127
131
  * @param invitation
128
- * @param organizationId id of organization to invite for, should match the organizationId in the invitation
132
+ * @param organizationId organization scope of the request; when set, the group must belong to it (platform admins can invite without a scope)
133
+ * @returns the group to invite for
129
134
  */
130
- private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string) {
135
+ private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string | null): Promise<Group> {
131
136
  const group = await Group.getByID(invitation.groupId);
132
137
 
133
- if (!group || group.organizationId !== organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
138
+ if (!group || (organizationId !== null && group.organizationId !== organizationId) || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
134
139
  throw Context.auth.error($t(`%1ST`));
135
140
  }
136
141
 
@@ -147,7 +152,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
147
152
 
148
153
  if (!member
149
154
  // in userMode 'organization' we can only invite members from the same organization
150
- || (STAMHOOFD.userMode === 'organization' && member.organizationId !== organizationId)
155
+ || (STAMHOOFD.userMode === 'organization' && member.organizationId !== group.organizationId)
151
156
  // read access is suficient
152
157
  || !await Context.auth.canAccessMember(member, PermissionLevel.Read)
153
158
  ) {
@@ -163,5 +168,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
163
168
  human: $t('%1S2'),
164
169
  });
165
170
  }
171
+
172
+ return group;
166
173
  }
167
174
  }
@@ -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;