@stamhoofd/backend 2.143.1 → 2.145.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 (111) hide show
  1. package/package.json +19 -19
  2. package/src/crons/fake-settlements.test.ts +13 -11
  3. package/src/crons/fake-settlements.ts +22 -38
  4. package/src/crons/mollie-chargebacks.ts +31 -29
  5. package/src/crons/mollie-refunds.ts +61 -59
  6. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
  7. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
  8. package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
  9. package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
  10. package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
  11. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
  12. package/src/endpoints/auth/MFA.security.test.ts +9 -7
  13. package/src/endpoints/auth/MFA.test.ts +42 -15
  14. package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
  15. package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
  16. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
  17. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
  18. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
  19. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
  20. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
  21. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
  22. package/src/endpoints/global/files/UploadFile.test.ts +2 -1
  23. package/src/endpoints/global/files/UploadFile.ts +53 -25
  24. package/src/endpoints/global/files/UploadImage.test.ts +2 -1
  25. package/src/endpoints/global/files/UploadImage.ts +32 -6
  26. package/src/endpoints/global/files/upload-security.test.ts +3 -2
  27. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
  28. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
  29. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
  30. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
  31. package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
  32. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
  33. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
  34. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
  35. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
  36. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
  37. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
  38. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
  39. package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
  40. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
  41. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
  42. package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
  43. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
  44. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
  45. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
  46. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
  47. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
  48. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
  49. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
  50. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
  51. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
  52. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
  53. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
  54. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
  55. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
  56. package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
  57. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
  58. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
  59. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
  60. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
  62. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
  63. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
  64. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
  65. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
  66. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
  67. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
  68. package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
  69. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
  70. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
  72. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
  73. package/src/helpers/ApplicationFeeInvoicer.ts +1 -1
  74. package/src/helpers/Context.ts +14 -21
  75. package/src/helpers/ContextStorage.ts +5 -0
  76. package/src/helpers/MollieSettlementSync.test.ts +94 -41
  77. package/src/helpers/MollieSettlementSync.ts +174 -78
  78. package/src/helpers/StripeSettlementSync.test.ts +0 -85
  79. package/src/helpers/StripeSettlementSync.ts +47 -70
  80. package/src/helpers/TwoFactorHelper.ts +21 -22
  81. package/src/helpers/streamForBreakdown.ts +1 -1
  82. package/src/services/AdminSessionService.ts +5 -3
  83. package/src/services/InvoiceService.ts +2 -2
  84. package/src/services/MollieService.ts +168 -126
  85. package/src/services/PaymentService.ts +2 -2
  86. package/src/services/SSOService.test.ts +25 -1
  87. package/src/services/SSOService.ts +17 -6
  88. package/src/services/SessionService.test.ts +319 -0
  89. package/src/services/SessionService.ts +357 -0
  90. package/src/services/SettlementService.test.ts +13 -85
  91. package/src/services/SettlementService.ts +5 -113
  92. package/src/services/WebshopCrowdfundingService.test.ts +4 -3
  93. package/src/sql-filters/balance-items.ts +10 -0
  94. package/src/sql-filters/registrations.ts +59 -1
  95. package/tests/actions/patchUserMember.ts +2 -1
  96. package/tests/e2e/api-rate-limits.test.ts +2 -1
  97. package/tests/e2e/bundle-discounts.test.ts +4 -3
  98. package/tests/e2e/charge-members.test.ts +6 -5
  99. package/tests/e2e/documents.test.ts +4 -3
  100. package/tests/e2e/private-files.test.ts +9 -8
  101. package/tests/e2e/register.test.ts +4 -3
  102. package/tests/e2e/stock.test.ts +4 -3
  103. package/tests/e2e/tickets.test.ts +4 -3
  104. package/tests/helpers/ExportSlice.ts +2 -1
  105. package/tests/helpers/MollieMocker.ts +173 -38
  106. package/tests/init/initAdmin.ts +2 -1
  107. package/tests/init/initPlatformAdmin.ts +2 -1
  108. package/src/helpers/ViesHelper.test.ts +0 -401
  109. package/src/helpers/ViesHelper.ts +0 -215
  110. package/src/services/PeppolDirectoryService.test.ts +0 -139
  111. package/src/services/PeppolDirectoryService.ts +0 -111
@@ -25,6 +25,9 @@ export type MollieMockPayment = {
25
25
  mandateId: string | null;
26
26
  isCancelable: boolean;
27
27
  details: Record<string, unknown> | null;
28
+ description?: string;
29
+ profileId?: string | null;
30
+ createdAt?: string;
28
31
  };
29
32
 
30
33
  export type MollieMockMandate = {
@@ -53,13 +56,6 @@ export type MollieMockRefund = {
53
56
  metadata: Record<string, unknown> | null;
54
57
  };
55
58
 
56
- export type MollieMockSettlementCost = {
57
- description: string;
58
- method: string | null;
59
- amountNet: { currency: string; value: string };
60
- amountVat: { currency: string; value: string } | null;
61
- };
62
-
63
59
  export type MollieMockSettlement = {
64
60
  id: string;
65
61
  reference: string;
@@ -68,10 +64,6 @@ export type MollieMockSettlement = {
68
64
  createdAt: string;
69
65
  /** null for the still-open settlement, a date once it has been paid out */
70
66
  settledAt: string | null;
71
- /** id of the invoice Mollie created for the settlement costs, null until it exists */
72
- invoiceId: string | null;
73
- /** Mollie's own costs per period, keyed year → month */
74
- periods: Record<string, Record<string, { costs: MollieMockSettlementCost[]; invoiceId?: string | null }>>;
75
67
  /** Mollie payment ids (tr_...) settled in this settlement */
76
68
  paymentIds: string[];
77
69
  /** Mollie refund ids (re_...) settled in this settlement */
@@ -80,14 +72,21 @@ export type MollieMockSettlement = {
80
72
  chargebackIds: string[];
81
73
  };
82
74
 
75
+ export type MollieMockBalanceTransaction = {
76
+ id: string;
77
+ type: string;
78
+ createdAt: string;
79
+ deductions: { currency: string; value: string } | null;
80
+ deductionDetails: { fees?: { currency: string; value: string } } | null;
81
+ context: Record<string, string> | null;
82
+ };
83
+
83
84
  const MOLLIE_CHECKOUT_URL = 'https://molliecheckout/';
84
85
 
85
86
  /**
86
87
  * Mirrors the StripeMocker (tests/helpers/StripeMocker.ts) but for Mollie.
87
88
  *
88
- * The Mollie node client (@mollie/api-client v4) performs its HTTP requests through node-fetch v2,
89
- * which uses Node's http/https module under the hood. nock can therefore intercept every call to
90
- * https://api.mollie.com - exactly like the StripeMocker does for Stripe.
89
+ * The Mollie client sends every API call to https://api.mollie.com, which nock intercepts here.
91
90
  *
92
91
  * Usage:
93
92
  * const mollieMocker = new MollieMocker();
@@ -104,6 +103,7 @@ export class MollieMocker {
104
103
  chargebacks: MollieMockChargeback[] = [];
105
104
  refunds: MollieMockRefund[] = [];
106
105
  settlements: MollieMockSettlement[] = [];
106
+ balanceTransactions: MollieMockBalanceTransaction[] = [];
107
107
 
108
108
  /**
109
109
  * Cap the settlements list page size below the requested limit, to exercise pagination
@@ -111,8 +111,30 @@ export class MollieMocker {
111
111
  */
112
112
  settlementsPageSize: number | null = null;
113
113
 
114
+ /**
115
+ * Same, for the balance transactions list.
116
+ */
117
+ balanceTransactionsPageSize: number | null = null;
118
+
114
119
  #forceFailure = false;
115
120
 
121
+ // The SDK validates responses with zod: these links are required on most resources
122
+ #dashboardLink = { href: 'https://www.mollie.com/dashboard', type: 'text/html' };
123
+ #documentationLink = { href: 'https://docs.mollie.com', type: 'text/html' };
124
+
125
+ /**
126
+ * A Mollie error body, complete enough for the SDK to parse it as an ErrorResponse.
127
+ */
128
+ #errorBody(status: number, title: string, detail: string, field?: string) {
129
+ return {
130
+ status,
131
+ title,
132
+ detail,
133
+ field,
134
+ _links: { documentation: this.#documentationLink },
135
+ };
136
+ }
137
+
116
138
  reset() {
117
139
  this.payments = [];
118
140
  this.customers = [];
@@ -120,7 +142,9 @@ export class MollieMocker {
120
142
  this.chargebacks = [];
121
143
  this.refunds = [];
122
144
  this.settlements = [];
145
+ this.balanceTransactions = [];
123
146
  this.settlementsPageSize = null;
147
+ this.balanceTransactionsPageSize = null;
124
148
  this.#forceFailure = false;
125
149
  }
126
150
 
@@ -162,15 +186,18 @@ export class MollieMocker {
162
186
  const interceptor = nock('https://api.mollie.com').persist();
163
187
 
164
188
  interceptor.get(matcher).reply((uri) => {
165
- return this.#handle('GET', uri, undefined);
189
+ const [status, body] = this.#handle('GET', uri, undefined);
190
+ return [status, body, { 'content-type': 'application/hal+json' }];
166
191
  });
167
192
 
168
193
  interceptor.post(matcher).reply((uri, body) => {
169
- return this.#handle('POST', uri, body);
194
+ const [status, responseBody] = this.#handle('POST', uri, body);
195
+ return [status, responseBody, { 'content-type': 'application/hal+json' }];
170
196
  });
171
197
 
172
198
  interceptor.delete(matcher).reply((uri, body) => {
173
- return this.#handle('DELETE', uri, body);
199
+ const [status, responseBody] = this.#handle('DELETE', uri, body);
200
+ return [status, responseBody, { 'content-type': 'application/hal+json' }];
174
201
  });
175
202
  }
176
203
 
@@ -238,10 +265,19 @@ export class MollieMocker {
238
265
  return [200, this.#onboarding()];
239
266
  }
240
267
 
268
+ if (parts[0] === 'onboarding' && method === 'POST') {
269
+ return [204];
270
+ }
271
+
241
272
  if (parts[0] === 'chargebacks' && method === 'GET') {
242
273
  return this.#listResource('chargebacks', this.chargebacks.map(c => this.#chargebackResource(c)));
243
274
  }
244
275
 
276
+ // balance transactions (drives the transaction fee part of the settlements sync)
277
+ if (parts[0] === 'balances' && method === 'GET' && parts.length === 3 && parts[2] === 'transactions') {
278
+ return this.#listBalanceTransactions(uri);
279
+ }
280
+
245
281
  // settlements + nested payments/refunds (drives the settlements sync)
246
282
  if (parts[0] === 'settlements' && method === 'GET') {
247
283
  if (parts.length === 1) {
@@ -283,6 +319,9 @@ export class MollieMocker {
283
319
  mandateId: body.mandateId ?? null,
284
320
  isCancelable: true,
285
321
  details: null,
322
+ description: body.description ?? '',
323
+ profileId: body.profileId ?? null,
324
+ createdAt: new Date().toISOString(),
286
325
  };
287
326
  this.payments.push(payment);
288
327
  return [201, this.#paymentResource(payment)];
@@ -291,7 +330,7 @@ export class MollieMocker {
291
330
  #getPayment(id: string): [number, unknown] {
292
331
  const payment = this.payments.find(p => p.id === id);
293
332
  if (!payment) {
294
- return [404, { detail: 'Payment not found' }];
333
+ return [404, this.#errorBody(404, 'Not Found', 'No payment exists with token ' + id)];
295
334
  }
296
335
  return [200, this.#paymentResource(payment)];
297
336
  }
@@ -299,7 +338,7 @@ export class MollieMocker {
299
338
  #cancelPayment(id: string): [number, unknown] {
300
339
  const payment = this.payments.find(p => p.id === id);
301
340
  if (!payment) {
302
- return [404, { detail: 'Payment not found' }];
341
+ return [404, this.#errorBody(404, 'Not Found', 'No payment exists with token ' + id)];
303
342
  }
304
343
  payment.status = 'canceled';
305
344
  return [200, this.#paymentResource(payment)];
@@ -318,9 +357,14 @@ export class MollieMocker {
318
357
  mandateId: payment.mandateId ?? undefined,
319
358
  details: payment.details ?? undefined,
320
359
  metadata: payment.internalPaymentId ? { paymentId: payment.internalPaymentId } : undefined,
360
+ description: payment.description ?? '',
361
+ profileId: payment.profileId ?? 'pfl_test',
362
+ createdAt: payment.createdAt ?? new Date().toISOString(),
321
363
  _links: {
322
364
  self: { href: 'https://api.mollie.com/v2/payments/' + payment.id, type: 'application/hal+json' },
323
365
  checkout: { href: MOLLIE_CHECKOUT_URL, type: 'text/html' },
366
+ dashboard: this.#dashboardLink,
367
+ documentation: this.#documentationLink,
324
368
  },
325
369
  };
326
370
  }
@@ -347,9 +391,15 @@ export class MollieMocker {
347
391
  id,
348
392
  mode: 'test',
349
393
  name: body.name ?? 'Test',
350
- email: body.email ?? undefined,
351
- metadata: body.metadata ?? undefined,
352
- _links: { self: { href: 'https://api.mollie.com/v2/customers/' + id, type: 'application/hal+json' } },
394
+ email: body.email ?? null,
395
+ locale: null,
396
+ metadata: body.metadata ?? null,
397
+ createdAt: new Date().toISOString(),
398
+ _links: {
399
+ self: { href: 'https://api.mollie.com/v2/customers/' + id, type: 'application/hal+json' },
400
+ dashboard: this.#dashboardLink,
401
+ documentation: this.#documentationLink,
402
+ },
353
403
  };
354
404
  }
355
405
 
@@ -395,8 +445,14 @@ export class MollieMocker {
395
445
  method: mandate.method,
396
446
  details: mandate.details,
397
447
  customerId: mandate.customerId,
448
+ mandateReference: null,
449
+ signatureDate: mandate.createdAt.substring(0, 10),
398
450
  createdAt: mandate.createdAt,
399
- _links: { self: { href: 'https://api.mollie.com/v2/customers/' + mandate.customerId + '/mandates/' + mandate.id, type: 'application/hal+json' } },
451
+ _links: {
452
+ self: { href: 'https://api.mollie.com/v2/customers/' + mandate.customerId + '/mandates/' + mandate.id, type: 'application/hal+json' },
453
+ customer: { href: 'https://api.mollie.com/v2/customers/' + mandate.customerId, type: 'application/hal+json' },
454
+ documentation: this.#documentationLink,
455
+ },
400
456
  };
401
457
  }
402
458
 
@@ -410,8 +466,16 @@ export class MollieMocker {
410
466
  mode: 'test',
411
467
  name: 'Stamhoofd',
412
468
  website: 'https://www.stamhoofd.be',
469
+ email: 'hallo@stamhoofd.be',
470
+ phone: '+32499999999',
471
+ businessCategory: 'SOCIAL_ASSOCIATIONS',
413
472
  status: 'verified',
414
- _links: { self: { href: 'https://api.mollie.com/v2/profiles/' + id, type: 'application/hal+json' } },
473
+ createdAt: new Date().toISOString(),
474
+ _links: {
475
+ self: { href: 'https://api.mollie.com/v2/profiles/' + id, type: 'application/hal+json' },
476
+ dashboard: this.#dashboardLink,
477
+ documentation: this.#documentationLink,
478
+ },
415
479
  };
416
480
  }
417
481
 
@@ -422,6 +486,7 @@ export class MollieMocker {
422
486
  status: 'completed',
423
487
  canReceivePayments: true,
424
488
  canReceiveSettlements: true,
489
+ signedUpAt: new Date().toISOString(),
425
490
  _links: { self: { href: 'https://api.mollie.com/v2/onboarding/me', type: 'application/hal+json' } },
426
491
  };
427
492
  }
@@ -450,7 +515,11 @@ export class MollieMocker {
450
515
  amount: chargeback.amount,
451
516
  paymentId: chargeback.paymentId,
452
517
  createdAt: chargeback.createdAt,
453
- _links: { self: { href: 'https://api.mollie.com/v2/payments/' + chargeback.paymentId + '/chargebacks/' + chargeback.id, type: 'application/hal+json' } },
518
+ _links: {
519
+ self: { href: 'https://api.mollie.com/v2/payments/' + chargeback.paymentId + '/chargebacks/' + chargeback.id, type: 'application/hal+json' },
520
+ payment: { href: 'https://api.mollie.com/v2/payments/' + chargeback.paymentId, type: 'application/hal+json' },
521
+ documentation: this.#documentationLink,
522
+ },
454
523
  };
455
524
  }
456
525
 
@@ -473,20 +542,20 @@ export class MollieMocker {
473
542
  #createRefund(paymentId: string, body: Record<string, any>): [number, unknown] {
474
543
  const payment = this.payments.find(p => p.id === paymentId);
475
544
  if (!payment) {
476
- return [404, { status: 404, title: 'Not Found', detail: 'No payment exists with token ' + paymentId }];
545
+ return [404, this.#errorBody(404, 'Not Found', 'No payment exists with token ' + paymentId)];
477
546
  }
478
547
 
479
548
  if (payment.status !== 'paid') {
480
- return [422, { status: 422, title: 'Unprocessable Entity', detail: 'The payment is not paid; you can only refund paid payments' }];
549
+ return [422, this.#errorBody(422, 'Unprocessable Entity', 'The payment is not paid; you can only refund paid payments')];
481
550
  }
482
551
 
483
552
  const value = parseFloat(String(body.amount?.value ?? '0'));
484
553
  if (!(value > 0)) {
485
- return [422, { status: 422, title: 'Unprocessable Entity', detail: 'The amount is invalid', field: 'amount' }];
554
+ return [422, this.#errorBody(422, 'Unprocessable Entity', 'The amount is invalid', 'amount')];
486
555
  }
487
556
 
488
557
  if (value > this.getRemainingAmount(payment) + 0.000001) {
489
- return [422, { status: 422, title: 'Unprocessable Entity', detail: 'The amount is higher than the remaining payment amount', field: 'amount' }];
558
+ return [422, this.#errorBody(422, 'Unprocessable Entity', 'The amount is higher than the remaining payment amount', 'amount')];
490
559
  }
491
560
 
492
561
  const refund: MollieMockRefund = {
@@ -505,7 +574,7 @@ export class MollieMocker {
505
574
  #getRefund(paymentId: string, refundId: string): [number, unknown] {
506
575
  const refund = this.refunds.find(r => r.id === refundId && r.paymentId === paymentId);
507
576
  if (!refund) {
508
- return [404, { status: 404, title: 'Not Found', detail: 'No refund exists with token ' + refundId }];
577
+ return [404, this.#errorBody(404, 'Not Found', 'No refund exists with token ' + refundId)];
509
578
  }
510
579
  return [200, this.#refundResource(refund)];
511
580
  }
@@ -563,8 +632,12 @@ export class MollieMocker {
563
632
  description: refund.description,
564
633
  paymentId: refund.paymentId,
565
634
  createdAt: refund.createdAt,
566
- metadata: refund.metadata ?? undefined,
567
- _links: { self: { href: 'https://api.mollie.com/v2/payments/' + refund.paymentId + '/refunds/' + refund.id, type: 'application/hal+json' } },
635
+ metadata: refund.metadata ?? null,
636
+ _links: {
637
+ self: { href: 'https://api.mollie.com/v2/payments/' + refund.paymentId + '/refunds/' + refund.id, type: 'application/hal+json' },
638
+ payment: { href: 'https://api.mollie.com/v2/payments/' + refund.paymentId, type: 'application/hal+json' },
639
+ documentation: this.#documentationLink,
640
+ },
568
641
  };
569
642
  }
570
643
 
@@ -574,7 +647,7 @@ export class MollieMocker {
574
647
  * Register a settled (paid out) settlement that groups the given payments and refunds.
575
648
  * Used to drive the MollieSettlementSync walk.
576
649
  */
577
- createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date; invoiceId?: string | null; periods?: Record<string, Record<string, { costs: MollieMockSettlementCost[]; invoiceId?: string | null }>> } = {}): MollieMockSettlement {
650
+ createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date } = {}): MollieMockSettlement {
578
651
  const settlement: MollieMockSettlement = {
579
652
  id: this.createId('stl'),
580
653
  reference: '1234567.' + (this.settlements.length + 1).toString().padStart(4, '0') + '.01',
@@ -582,8 +655,6 @@ export class MollieMocker {
582
655
  amount: { currency: 'EUR', value: options.value ?? '0.00' },
583
656
  createdAt: new Date().toISOString(),
584
657
  settledAt: (options.settledAt ?? new Date()).toISOString(),
585
- invoiceId: options.invoiceId ?? null,
586
- periods: options.periods ?? {},
587
658
  paymentIds: (options.payments ?? []).map(p => p.id),
588
659
  refundIds: (options.refunds ?? []).map(r => r.id),
589
660
  chargebackIds: (options.chargebacks ?? []).map(c => c.id),
@@ -617,7 +688,7 @@ export class MollieMocker {
617
688
  const from = query.get('from');
618
689
  const startIndex = from ? sorted.findIndex(s => s.id === from) : 0;
619
690
  if (startIndex === -1) {
620
- return [404, { status: 404, title: 'Not Found', detail: 'No settlement exists with token ' + from }];
691
+ return [404, this.#errorBody(404, 'Not Found', 'No settlement exists with token ' + from)];
621
692
  }
622
693
 
623
694
  const page = sorted.slice(startIndex, startIndex + limit);
@@ -637,12 +708,76 @@ export class MollieMocker {
637
708
  amount: settlement.amount,
638
709
  createdAt: settlement.createdAt,
639
710
  settledAt: settlement.settledAt ?? null,
640
- invoiceId: settlement.invoiceId,
641
- periods: settlement.periods,
642
711
  _links: { self: { href: 'https://api.mollie.com/v2/settlements/' + settlement.id, type: 'application/hal+json' } },
643
712
  };
644
713
  }
645
714
 
715
+ // ---- Balance transactions ---------------------------------------------
716
+
717
+ /**
718
+ * Register the balance transaction of a payment, refund or chargeback, carrying the fee Mollie
719
+ * deducted for it. `fee` and `deductions` are positive euro values, stored negated like Mollie
720
+ * reports them. Pass only `deductions` for a transaction without the deductionDetails breakdown.
721
+ */
722
+ createBalanceTransaction(options: {
723
+ type: 'payment' | 'refund' | 'chargeback';
724
+ entryId: string;
725
+ fee?: string;
726
+ deductions?: string;
727
+ createdAt?: Date;
728
+ }): MollieMockBalanceTransaction {
729
+ const contextKey = options.type === 'payment' ? 'paymentId' : options.type === 'refund' ? 'refundId' : 'chargebackId';
730
+ const deductions = options.deductions ?? options.fee;
731
+
732
+ const transaction: MollieMockBalanceTransaction = {
733
+ id: this.createId('baltr'),
734
+ type: options.type,
735
+ createdAt: (options.createdAt ?? new Date()).toISOString(),
736
+ deductions: deductions ? { currency: 'EUR', value: '-' + deductions } : null,
737
+ deductionDetails: options.fee ? { fees: { currency: 'EUR', value: '-' + options.fee } } : null,
738
+ context: { [contextKey]: options.entryId },
739
+ };
740
+ this.balanceTransactions.push(transaction);
741
+ return transaction;
742
+ }
743
+
744
+ /**
745
+ * Mollie lists balance transactions newest-first, paginated like the settlements list.
746
+ */
747
+ #listBalanceTransactions(uri: string): [number, unknown] {
748
+ const query = new URLSearchParams(uri.split('?')[1] ?? '');
749
+
750
+ const sorted = this.balanceTransactions.slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
751
+
752
+ const requestedLimit = parseInt(query.get('limit') ?? '250');
753
+ const limit = Math.min(requestedLimit, this.balanceTransactionsPageSize ?? requestedLimit);
754
+
755
+ const from = query.get('from');
756
+ const startIndex = from ? sorted.findIndex(t => t.id === from) : 0;
757
+ if (startIndex === -1) {
758
+ return [404, this.#errorBody(404, 'Not Found', 'No balance transaction exists with token ' + from)];
759
+ }
760
+
761
+ const page = sorted.slice(startIndex, startIndex + limit);
762
+ const nextItem = sorted[startIndex + limit];
763
+
764
+ return this.#listResource('balance_transactions', page.map(t => this.#balanceTransactionResource(t)), {
765
+ next: nextItem ? 'https://api.mollie.com/v2/balances/primary/transactions?limit=' + requestedLimit + '&from=' + nextItem.id : null,
766
+ });
767
+ }
768
+
769
+ #balanceTransactionResource(transaction: MollieMockBalanceTransaction) {
770
+ return {
771
+ resource: 'balance-transaction',
772
+ id: transaction.id,
773
+ type: transaction.type,
774
+ createdAt: transaction.createdAt,
775
+ deductions: transaction.deductions,
776
+ deductionDetails: transaction.deductionDetails,
777
+ context: transaction.context,
778
+ };
779
+ }
780
+
646
781
  // ---- List helper ------------------------------------------------------
647
782
 
648
783
  #listResource(binderName: string, items: unknown[], options: { next?: string | null } = {}): [number, unknown] {
@@ -3,6 +3,7 @@ import { Token, UserFactory } from '@stamhoofd/models';
3
3
  import type { AccessRight } from '@stamhoofd/structures';
4
4
  import { PermissionLevel, PermissionRole, Permissions } from '@stamhoofd/structures';
5
5
  import { initPermissionRole } from './initPermissionRole.js';
6
+ import { SessionService } from '../../src/services/SessionService.js';
6
7
 
7
8
  /**
8
9
  * You cannot assign access rights directy to a user, but it can be done using roles. So when setting accessRights, a role wil be created and assigned to the user.
@@ -25,6 +26,6 @@ export async function initAdmin({ organization, permissions, accessRights }: { o
25
26
  permissions,
26
27
  }).create();
27
28
 
28
- const adminToken = await Token.createToken(admin);
29
+ const adminToken = await SessionService.createSession(admin);
29
30
  return { admin, adminToken };
30
31
  }
@@ -1,5 +1,6 @@
1
1
  import { Token, UserFactory } from '@stamhoofd/models';
2
2
  import { PermissionLevel, Permissions } from '@stamhoofd/structures';
3
+ import { SessionService } from '../../src/services/SessionService.js';
3
4
 
4
5
  export async function initPlatformAdmin() {
5
6
  const admin = await new UserFactory({
@@ -8,6 +9,6 @@ export async function initPlatformAdmin() {
8
9
  }),
9
10
  }).create();
10
11
 
11
- const adminToken = await Token.createToken(admin);
12
+ const adminToken = await SessionService.createSession(admin);
12
13
  return { admin, adminToken };
13
14
  }