@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
@@ -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);
@@ -1,7 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
4
- import { PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
4
+ import { NewUser, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
5
5
  import { TestUtils } from '@stamhoofd/test-utils';
6
6
  import crypto from 'crypto';
7
7
  import { authenticator } from 'otplib';
@@ -16,6 +16,7 @@ import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
16
16
  import { DeleteTOTPEndpoint } from './DeleteTOTPEndpoint.js';
17
17
  import { GetMFAChallengeEndpoint } from './GetMFAChallengeEndpoint.js';
18
18
  import { GetMFAStatusEndpoint } from './GetMFAStatusEndpoint.js';
19
+ import { PatchUserEndpoint } from './PatchUserEndpoint.js';
19
20
  import { RegisterPasskeyOptionsEndpoint } from './RegisterPasskeyOptionsEndpoint.js';
20
21
  import { SetupTOTPEndpoint } from './SetupTOTPEndpoint.js';
21
22
  import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
@@ -508,6 +509,80 @@ describe('MFA security', () => {
508
509
  });
509
510
  });
510
511
 
512
+ // -----------------------------------------------------------------------
513
+ // Absorbing someone else's account by taking over their email address
514
+ // -----------------------------------------------------------------------
515
+ describe('merging accounts', () => {
516
+ /**
517
+ * Verifying an email address that already belongs to another account merges that
518
+ * account into yours. Reading the victim's mailbox is a single primary credential,
519
+ * exactly the one a second factor exists to back up, so it must not be enough to
520
+ * pull an account that has a second factor (and its permissions) into an account
521
+ * that does not.
522
+ */
523
+ async function requestEmailChange(user: User, token: Token, newEmail: string, organization: Organization): Promise<EmailVerificationCode> {
524
+ const request = Request.patch({
525
+ path: '/user/' + user.id,
526
+ host: organization.getApiHost(),
527
+ headers: { authorization: 'Bearer ' + token.accessToken },
528
+ body: NewUser.patch({ id: user.id, email: newEmail }),
529
+ });
530
+
531
+ const err = await captureError(testServer.test(new PatchUserEndpoint(), request));
532
+ expect(err.code).toBe('verify_email');
533
+
534
+ const verificationToken = (err.meta as { token: string }).token;
535
+ const code = await EmailVerificationCode.select().where('token', verificationToken).first(true);
536
+ expect(code.email).toBe(newEmail);
537
+ return code;
538
+ }
539
+
540
+ test('taking over the email address of an account with a second factor is refused', async () => {
541
+ const organization = await new OrganizationFactory({}).create();
542
+ const victim = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
543
+ const victimTotp = await addConfirmedTOTP(victim);
544
+
545
+ const attacker = await new UserFactory({ organization, password }).create();
546
+ const attackerToken = await freshToken(attacker);
547
+
548
+ const code = await requestEmailChange(attacker, attackerToken, victim.email, organization);
549
+
550
+ const err = await captureError(testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), attackerToken)));
551
+ expect(err.code).toBe('email_in_use');
552
+
553
+ // The victim still owns their account, their permissions and their factor.
554
+ const storedVictim = await User.getByID(victim.id);
555
+ expect(storedVictim).toBeDefined();
556
+ expect(storedVictim!.email).toBe(victim.email);
557
+ expect(await MFATOTP.getByID(victimTotp.id)).toBeDefined();
558
+
559
+ const storedAttacker = await User.getByID(attacker.id);
560
+ expect(storedAttacker!.email).toBe(attacker.email);
561
+ expect(storedAttacker!.permissions).toBeNull();
562
+ });
563
+
564
+ test('an account without a second factor can still be merged', async () => {
565
+ // Merging is a real feature for people who signed up twice. Without a factor,
566
+ // whoever reads the mailbox could take that account over with a password reset
567
+ // anyway, so nothing is bypassed here.
568
+ const organization = await new OrganizationFactory({}).create();
569
+ const other = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
570
+
571
+ const user = await new UserFactory({ organization, password }).create();
572
+ const token = await freshToken(user);
573
+
574
+ const code = await requestEmailChange(user, token, other.email, organization);
575
+
576
+ const response = await testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), token));
577
+ expect(response.body).toBeInstanceOf(TokenStruct);
578
+
579
+ expect(await User.getByID(other.id)).toBeUndefined();
580
+ const stored = await User.getByID(user.id);
581
+ expect(stored!.email).toBe(other.email);
582
+ expect(stored!.permissions).not.toBeNull();
583
+ });
584
+ });
585
+
511
586
  // -----------------------------------------------------------------------
512
587
  // Changing the factors of an account ends the sessions the user is not on
513
588
  // -----------------------------------------------------------------------
@@ -82,6 +82,20 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
82
82
  const other = await User.getForAuthentication(user.organizationId, code.email, { allowWithoutAccount: true });
83
83
 
84
84
  if (other) {
85
+ // Merging absorbs the other account (its permissions included) and then
86
+ // deletes it, second factor and all. Reading the mailbox is exactly the
87
+ // single credential a second factor exists to back up, so an account that
88
+ // has one may not be taken over this way: nothing in this request proves
89
+ // the caller can pass it.
90
+ if (await TwoFactorHelper.userHasFactors(other.id)) {
91
+ throw new SimpleError({
92
+ code: 'email_in_use',
93
+ message: 'This e-mail is already in use by an account with two-factor authentication',
94
+ human: $t('%Zis'),
95
+ statusCode: 400,
96
+ });
97
+ }
98
+
85
99
  // Delete the other user, but merge data
86
100
  await user.merge(other);
87
101
  if (user.organizationId) {
@@ -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;
@@ -317,17 +317,17 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
317
317
  expect(response.status).toBe(200);
318
318
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
319
319
  { name: '1234567.0312.01', price: 40_00 },
320
- { name: $t('In verwerking'), price: 30_00 },
320
+ { name: $t('%1OL'), price: 30_00 },
321
321
  // Only what was tried sits under the failed payment, the rest was never attempted
322
- { name: $t('Mislukte betaling'), price: 20_00 },
323
- { name: $t('Openstaand na mislukte poging'), price: 10_00 },
322
+ { name: $t('%ZjW'), price: 20_00 },
323
+ { name: $t('%ZjC'), price: 10_00 },
324
324
  ]);
325
325
 
326
326
  // Every part of what was charged ends up in exactly one row
327
327
  expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
328
328
 
329
329
  // Running the rows through the database gives back the balance items they were added up from
330
- for (const name of [$t('Mislukte betaling'), $t('Openstaand na mislukte poging')]) {
330
+ for (const name of [$t('%ZjW'), $t('%ZjC')]) {
331
331
  const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
332
332
  const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
333
333
  expect(exported.body.balanceItemCount).toBe(1);
@@ -348,19 +348,19 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
348
348
  expect(response.status).toBe(200);
349
349
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
350
350
  { name: '1234567.0312.01', price: 50_00 },
351
- { name: $t('Terug te betalen'), price: -50_00 },
352
- { name: $t('Openstaand'), price: 25_00 },
351
+ { name: $t('%10b'), price: -50_00 },
352
+ { name: $t('%1Ni'), price: 25_00 },
353
353
  ]);
354
354
 
355
355
  // A canceled item is not charged anymore, so it doesn't add to what is open
356
356
  expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
357
357
 
358
358
  // Each row selects exactly the balance items it was added up from
359
- const refund = response.body.bySettlement.find(g => g.name.toString() === $t('Terug te betalen'))!;
359
+ const refund = response.body.bySettlement.find(g => g.name.toString() === $t('%10b'))!;
360
360
  const refunded = await getBreakdown({ organization, user, filter: refund.selection!.listFilter });
361
361
  expect(refunded.body.balanceItemCount).toBe(1);
362
362
 
363
- const open = response.body.bySettlement.find(g => g.name.toString() === $t('Openstaand'))!;
363
+ const open = response.body.bySettlement.find(g => g.name.toString() === $t('%1Ni'))!;
364
364
  const stillOpen = await getBreakdown({ organization, user, filter: open.selection!.listFilter });
365
365
  expect(stillOpen.body.balanceItemCount).toBe(1);
366
366
  expect(stillOpen.body.price).toBe(25_00);
@@ -381,13 +381,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
381
381
 
382
382
  expect(response.status).toBe(200);
383
383
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
384
- { name: $t('Mislukte betaling'), price: 100_00 },
384
+ { name: $t('%ZjW'), price: 100_00 },
385
385
  { name: '1234567.0312.01', price: 40_00 },
386
- { name: $t('Openstaand'), price: 25_00 },
386
+ { name: $t('%1Ni'), price: 25_00 },
387
387
  ]);
388
388
 
389
389
  // Both rows select exactly the balance items they were added up from
390
- for (const [name, count] of [[$t('Mislukte betaling'), 1], [$t('Openstaand'), 1]] as [string, number][]) {
390
+ for (const [name, count] of [[$t('%ZjW'), 1], [$t('%1Ni'), 1]] as [string, number][]) {
391
391
  const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
392
392
  const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
393
393
  expect(exported.body.balanceItemCount).toBe(count);
@@ -405,7 +405,7 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
405
405
  await payItem(organization, processing, { price: 25_00, status: PaymentStatus.Pending });
406
406
 
407
407
  const all = await getBreakdown({ organization, user });
408
- const row = all.body.bySettlement.find(g => g.name.toString() === $t('In verwerking'))!;
408
+ const row = all.body.bySettlement.find(g => g.name.toString() === $t('%1OL'))!;
409
409
 
410
410
  const narrowed = await getBreakdown({
411
411
  organization,
@@ -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.
@@ -367,7 +367,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
367
367
 
368
368
  expect(response.status).toBe(200);
369
369
  expect(response.body.byArticle).toHaveLength(1);
370
- expect(response.body.byArticle[0].name.toString()).toBe($t('Deels betaalde bestelling'));
370
+ expect(response.body.byArticle[0].name.toString()).toBe($t('%Zjb'));
371
371
  expect(response.body.byArticle[0].price).toBe(1_00);
372
372
  });
373
373
 
@@ -391,7 +391,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
391
391
  const response = await getBreakdown({ organization, user });
392
392
 
393
393
  expect(response.status).toBe(200);
394
- expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('Gewijzigde bestelling')]);
394
+ expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('%Zik')]);
395
395
  });
396
396
  });
397
397
 
@@ -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 () => {
@@ -652,8 +652,8 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
652
652
  expect(response.status).toBe(200);
653
653
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price, count: g.count }))).toEqual([
654
654
  { name: '1234567.0312.01', price: 50_00, count: 2 },
655
- { name: $t('Nog niet uitbetaald'), price: 30_00, count: 1 },
656
- { name: $t('Niet online betaald'), price: 20_00, count: 1 },
655
+ { name: $t('%Zjn'), price: 30_00, count: 1 },
656
+ { name: $t('%ZjN'), price: 20_00, count: 1 },
657
657
  ]);
658
658
  });
659
659
 
@@ -717,7 +717,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
717
717
  await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
718
718
 
719
719
  const all = await getBreakdown({ organization, user });
720
- const pending = all.body.bySettlement.find(g => g.name.toString() === $t('Nog niet uitbetaald'))!;
720
+ const pending = all.body.bySettlement.find(g => g.name.toString() === $t('%Zjn'))!;
721
721
 
722
722
  const exported = await getBreakdown({ organization, user, filter: pending.selection!.filter });
723
723
  expect(exported.body.price).toBe(30_00);
@@ -735,7 +735,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
735
735
  await createPayment(organization, { items: [[item, 10_00]], iban: 'BE68539007547034' });
736
736
 
737
737
  const all = await getBreakdown({ organization, user });
738
- const offline = all.body.bySettlement.find(g => g.name.toString() === $t('Niet online betaald'))!;
738
+ const offline = all.body.bySettlement.find(g => g.name.toString() === $t('%ZjN'))!;
739
739
 
740
740
  const exported = await getBreakdown({ organization, user, filter: offline.selection!.filter });
741
741
  expect(exported.body.price).toBe(30_00);
@@ -756,9 +756,9 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
756
756
  const all = await getBreakdown({ organization, user });
757
757
 
758
758
  expect(all.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
759
- { name: $t('Nog niet uitbetaald'), price: 40_00 },
759
+ { name: $t('%Zjn'), price: 40_00 },
760
760
  { name: getPaymentProviderName(PaymentProvider.Buckaroo), price: 25_00 },
761
- { name: $t('Niet online betaald'), price: 20_00 },
761
+ { name: $t('%ZjN'), price: 20_00 },
762
762
  { name: getPaymentProviderName(PaymentProvider.Payconiq), price: 15_00 },
763
763
  ]);
764
764
 
@@ -812,7 +812,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
812
812
 
813
813
  expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
814
814
  { name: 'Kapoenen', price: 14_6652 },
815
- { name: $t('Afronding'), price: 48 },
815
+ { name: $t('%1b6'), price: 48 },
816
816
  ]);
817
817
 
818
818
  // Everything the payment is worth is accounted for
@@ -870,12 +870,12 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
870
870
 
871
871
  expect(response.status).toBe(200);
872
872
  expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
873
- { name: $t('Niet online betaald'), price: 40_00 },
873
+ { name: $t('%ZjN'), price: 40_00 },
874
874
  { name: PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), price: 10_00 },
875
875
  ]);
876
876
 
877
877
  // Running the rows through the database keeps them apart
878
- for (const [name, price] of [[$t('Niet online betaald'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
878
+ for (const [name, price] of [[$t('%ZjN'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
879
879
  const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
880
880
  const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
881
881
  expect(exported.body.price).toBe(price);