@stamhoofd/backend 2.144.0 → 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 (99) hide show
  1. package/package.json +19 -19
  2. package/src/crons/mollie-chargebacks.ts +31 -29
  3. package/src/crons/mollie-refunds.ts +61 -59
  4. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
  5. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
  6. package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
  7. package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
  8. package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
  9. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
  10. package/src/endpoints/auth/MFA.security.test.ts +9 -7
  11. package/src/endpoints/auth/MFA.test.ts +42 -15
  12. package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
  13. package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
  14. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
  15. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
  16. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
  17. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
  18. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
  19. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
  20. package/src/endpoints/global/files/UploadFile.test.ts +2 -1
  21. package/src/endpoints/global/files/UploadImage.test.ts +2 -1
  22. package/src/endpoints/global/files/upload-security.test.ts +3 -2
  23. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
  24. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
  25. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
  26. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
  27. package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
  28. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
  29. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
  30. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
  31. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
  32. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
  33. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
  34. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
  35. package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
  36. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
  37. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
  38. package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
  39. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
  40. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
  41. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
  42. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
  43. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
  44. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
  45. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
  46. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
  47. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
  48. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
  49. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
  50. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
  51. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
  52. package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
  53. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
  54. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
  55. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
  56. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
  57. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
  58. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
  59. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
  60. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
  61. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
  62. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
  63. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
  64. package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
  65. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
  66. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
  67. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
  68. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
  69. package/src/helpers/Context.ts +13 -6
  70. package/src/helpers/ContextStorage.ts +5 -0
  71. package/src/helpers/StripeSettlementSync.ts +10 -7
  72. package/src/helpers/TwoFactorHelper.ts +21 -22
  73. package/src/services/AdminSessionService.ts +5 -3
  74. package/src/services/InvoiceService.ts +2 -2
  75. package/src/services/MollieService.ts +168 -126
  76. package/src/services/PaymentService.ts +2 -2
  77. package/src/services/SSOService.test.ts +25 -1
  78. package/src/services/SSOService.ts +17 -6
  79. package/src/services/SessionService.test.ts +319 -0
  80. package/src/services/SessionService.ts +357 -0
  81. package/src/services/WebshopCrowdfundingService.test.ts +4 -3
  82. package/src/sql-filters/registrations.ts +59 -1
  83. package/tests/actions/patchUserMember.ts +2 -1
  84. package/tests/e2e/api-rate-limits.test.ts +2 -1
  85. package/tests/e2e/bundle-discounts.test.ts +4 -3
  86. package/tests/e2e/charge-members.test.ts +6 -5
  87. package/tests/e2e/documents.test.ts +4 -3
  88. package/tests/e2e/private-files.test.ts +9 -8
  89. package/tests/e2e/register.test.ts +4 -3
  90. package/tests/e2e/stock.test.ts +4 -3
  91. package/tests/e2e/tickets.test.ts +4 -3
  92. package/tests/helpers/ExportSlice.ts +2 -1
  93. package/tests/helpers/MollieMocker.ts +85 -23
  94. package/tests/init/initAdmin.ts +2 -1
  95. package/tests/init/initPlatformAdmin.ts +2 -1
  96. package/src/helpers/ViesHelper.test.ts +0 -401
  97. package/src/helpers/ViesHelper.ts +0 -222
  98. package/src/services/PeppolDirectoryService.test.ts +0 -139
  99. package/src/services/PeppolDirectoryService.ts +0 -111
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.144.0",
3
+ "version": "2.145.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -58,27 +58,27 @@
58
58
  "@aws-sdk/client-sqs": "3.1090.0",
59
59
  "@aws-sdk/s3-request-presigner": "3.1090.0",
60
60
  "@bwip-js/node": "4.11.2",
61
- "@mollie/api-client": "4.6.0",
62
61
  "@simonbackx/simple-database": "1.37.1",
63
62
  "@simonbackx/simple-encoding": "2.26.11",
64
63
  "@simonbackx/simple-endpoints": "1.22.0",
65
64
  "@simonbackx/simple-errors": "1.5.0",
66
65
  "@simonbackx/simple-logging": "1.0.1",
67
66
  "@simplewebauthn/server": "13.3.2",
68
- "@stamhoofd/backend-env": "2.144.0",
69
- "@stamhoofd/backend-i18n": "2.144.0",
70
- "@stamhoofd/backend-middleware": "2.144.0",
71
- "@stamhoofd/crons": "2.144.0",
72
- "@stamhoofd/email": "2.144.0",
73
- "@stamhoofd/excel-writer": "2.144.0",
74
- "@stamhoofd/logging": "2.144.0",
75
- "@stamhoofd/models": "2.144.0",
76
- "@stamhoofd/object-differ": "2.144.0",
77
- "@stamhoofd/queues": "2.144.0",
78
- "@stamhoofd/sql": "2.144.0",
79
- "@stamhoofd/structures": "2.144.0",
80
- "@stamhoofd/types": "2.144.0",
81
- "@stamhoofd/utility": "2.144.0",
67
+ "@stamhoofd/backend-env": "2.145.0",
68
+ "@stamhoofd/backend-i18n": "2.145.0",
69
+ "@stamhoofd/backend-middleware": "2.145.0",
70
+ "@stamhoofd/crons": "2.145.0",
71
+ "@stamhoofd/email": "2.145.0",
72
+ "@stamhoofd/excel-writer": "2.145.0",
73
+ "@stamhoofd/logging": "2.145.0",
74
+ "@stamhoofd/models": "2.145.0",
75
+ "@stamhoofd/object-differ": "2.145.0",
76
+ "@stamhoofd/queues": "2.145.0",
77
+ "@stamhoofd/sql": "2.145.0",
78
+ "@stamhoofd/structures": "2.145.0",
79
+ "@stamhoofd/types": "2.145.0",
80
+ "@stamhoofd/utility": "2.145.0",
81
+ "@stamhoofd/vies": "2.145.0",
82
82
  "archiver": "7.0.1",
83
83
  "argon2": "0.44.0",
84
84
  "axios": "1.18.1",
@@ -89,11 +89,11 @@
89
89
  "handlebars": "4.7.9",
90
90
  "jose": "5.10.0",
91
91
  "jsonwebtoken": "9.0.3",
92
- "jsvat-next": "3.0.4",
93
92
  "libphonenumber-js": "1.13.9",
94
93
  "luxon": "3.7.2",
95
94
  "mailparser": "3.9.14",
96
95
  "mockdate": "3.0.5",
96
+ "mollie-api-typescript": "1.12.23",
97
97
  "mysql2": "3.23.1",
98
98
  "node-rsa": "2.0.0",
99
99
  "openid-client": "5.7.1",
@@ -102,7 +102,7 @@
102
102
  "uuid": "14.0.1"
103
103
  },
104
104
  "devDependencies": {
105
- "@stamhoofd/test-utils": "2.144.0",
105
+ "@stamhoofd/test-utils": "2.145.0",
106
106
  "@types/cookie": "0.6.0",
107
107
  "@types/luxon": "3.7.2",
108
108
  "@types/mailparser": "3.4.6",
@@ -117,5 +117,5 @@
117
117
  "publishConfig": {
118
118
  "access": "public"
119
119
  },
120
- "gitHead": "31bc31b2a56d9516e8bc6be5779bc58af0022a88"
120
+ "gitHead": "ee58cd6383c03df39218d8589679638019241e15"
121
121
  }
@@ -42,40 +42,42 @@ export async function checkMollieChargebacksFor(service: MollieService, checkAll
42
42
  // Check last 3 days
43
43
  const offset = new Date(Date.now() - 1000 * 60 * 60 * 24 * 4);
44
44
 
45
- // due to a bug in mollie client code, testmode paramter is missing in the typescript definitions
46
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
47
- for await (const chargeback of service.client.chargebacks.iterate({ sort: 'desc', testmode: service.testMode } as any)) {
48
- if (!checkAll && new Date(chargeback.createdAt) < offset) {
49
- break;
50
- }
45
+ const pages = await service.client.chargebacks.all({ sort: 'desc', limit: 250 });
51
46
 
52
- // Check if this chargeback has been handled
53
- const existingChargeback = await MolliePayment.select().where('mollieId', chargeback.id).first(false);
54
- if (existingChargeback) {
55
- // We can break because all older chargebacks will also be handled
56
- break;
57
- }
47
+ pagesLoop: for await (const page of pages) {
48
+ for (const chargeback of page.result.embedded.chargebacks) {
49
+ if (!checkAll && new Date(chargeback.createdAt) < offset) {
50
+ break pagesLoop;
51
+ }
52
+
53
+ // Check if this chargeback has been handled
54
+ const existingChargeback = await MolliePayment.select().where('mollieId', chargeback.id).first(false);
55
+ if (existingChargeback) {
56
+ // We can break because all older chargebacks will also be handled
57
+ break pagesLoop;
58
+ }
58
59
 
59
- if (chargeback.paymentId) {
60
- const molliePayment = await MolliePayment.select().where('mollieId', chargeback.paymentId).first(false);
61
- if (molliePayment) {
62
- const payment = await Payment.getByID(molliePayment.paymentId);
63
- if (payment) {
64
- try {
65
- const amount = Math.round(parseFloat(chargeback.amount.value) * 100) * 100;
66
- const createdPayment = await PaymentService.registerChargeback(payment, amount, new Date(chargeback.createdAt));
60
+ if (chargeback.paymentId) {
61
+ const molliePayment = await MolliePayment.select().where('mollieId', chargeback.paymentId).first(false);
62
+ if (molliePayment) {
63
+ const payment = await Payment.getByID(molliePayment.paymentId);
64
+ if (payment) {
65
+ try {
66
+ const amount = Math.round(parseFloat(chargeback.amount.value) * 100) * 100;
67
+ const createdPayment = await PaymentService.registerChargeback(payment, amount, new Date(chargeback.createdAt));
67
68
 
68
- // Link Mollie chargeback ID (so we can set settlement later in the settlements cron)
69
- const molliePayment = new MolliePayment();
70
- molliePayment.paymentId = createdPayment.id;
71
- molliePayment.mollieId = chargeback.id;
72
- await molliePayment.save();
73
- } catch (e) {
74
- console.error('Failed to register chargeback ' + chargeback.id, e);
69
+ // Link Mollie chargeback ID (so we can set settlement later in the settlements cron)
70
+ const molliePayment = new MolliePayment();
71
+ molliePayment.paymentId = createdPayment.id;
72
+ molliePayment.mollieId = chargeback.id;
73
+ await molliePayment.save();
74
+ } catch (e) {
75
+ console.error('Failed to register chargeback ' + chargeback.id, e);
76
+ }
75
77
  }
78
+ } else {
79
+ console.error('Invalid chargeback payment id ' + chargeback.paymentId + ', not found');
76
80
  }
77
- } else {
78
- console.error('Invalid chargeback payment id ' + chargeback.paymentId + ', not found');
79
81
  }
80
82
  }
81
83
  }
@@ -1,7 +1,7 @@
1
- import { RefundStatus } from '@mollie/api-client';
2
1
  import { registerCron } from '@stamhoofd/crons';
3
2
  import { MolliePayment, MollieToken, Organization, Payment } from '@stamhoofd/models';
4
3
  import { PaymentStatus, PaymentType } from '@stamhoofd/structures';
4
+ import type { ListEntityRefundStatus } from 'mollie-api-typescript/models';
5
5
  import { MollieService } from '../services/MollieService.js';
6
6
  import { PaymentService } from '../services/PaymentService.js';
7
7
 
@@ -49,73 +49,75 @@ export async function checkMollieRefundsFor(service: MollieService, checkAll = f
49
49
  // Check last 7 days
50
50
  const offset = new Date(Date.now() - 1000 * 60 * 60 * 24 * 7);
51
51
 
52
- // due to a bug in mollie client code, testmode paramter is missing in the typescript definitions
53
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
54
- for await (const refund of service.client.refunds.iterate({ sort: 'desc', testmode: service.testMode } as any)) {
55
- const createdAt = new Date(refund.createdAt);
52
+ const pages = await service.client.refunds.all({ sort: 'desc', limit: 250 });
56
53
 
57
- if (createdAt < MOLLIE_REFUNDS_MINIMUM_DATE) {
58
- break;
59
- }
54
+ pagesLoop: for await (const page of pages) {
55
+ for (const refund of page.result.embedded.refunds) {
56
+ const createdAt = new Date(refund.createdAt);
60
57
 
61
- if (!checkAll && createdAt < offset) {
62
- break;
63
- }
58
+ if (createdAt < MOLLIE_REFUNDS_MINIMUM_DATE) {
59
+ break pagesLoop;
60
+ }
64
61
 
65
- // Check if this refund is already registered (refunds created via the API are linked on
66
- // creation, so we cannot break here: an older refund created in the Mollie UI could still
67
- // be unhandled). Registered refunds that are still pending locally are reconciled: Mollie
68
- // refunds can still fail or be canceled until they are actually executed.
69
- const existingRefund = await MolliePayment.select().where('mollieId', refund.id).first(false);
70
- if (existingRefund) {
71
- await reconcileRefundStatus(existingRefund.paymentId, refund.status, createdAt);
72
- continue;
73
- }
62
+ if (!checkAll && createdAt < offset) {
63
+ break pagesLoop;
64
+ }
74
65
 
75
- // Refunds that were canceled or failed before we ever registered them can be ignored
76
- if (refund.status === RefundStatus.canceled || refund.status === RefundStatus.failed) {
77
- continue;
78
- }
66
+ // Check if this refund is already registered (refunds created via the API are linked on
67
+ // creation, so we cannot break here: an older refund created in the Mollie UI could still
68
+ // be unhandled). Registered refunds that are still pending locally are reconciled: Mollie
69
+ // refunds can still fail or be canceled until they are actually executed.
70
+ const existingRefund = await MolliePayment.select().where('mollieId', refund.id).first(false);
71
+ if (existingRefund) {
72
+ await reconcileRefundStatus(existingRefund.paymentId, refund.status, createdAt);
73
+ continue;
74
+ }
79
75
 
80
- // Safety net: when creating a refund via the API succeeded, but saving the link afterwards
81
- // failed, the local refund payment already exists. Restore the link instead of registering
82
- // the refund twice (the metadata is set in PaymentService.createRefund).
83
- const metadata = refund.metadata as { refundPaymentId?: string } | null;
84
- if (metadata?.refundPaymentId) {
85
- const localRefund = await Payment.getByID(metadata.refundPaymentId);
86
- if (localRefund && localRefund.type === PaymentType.Refund) {
87
- console.error('Restoring missing link for Mollie refund ' + refund.id + ' to payment ' + localRefund.id);
88
- const link = new MolliePayment();
89
- link.paymentId = localRefund.id;
90
- link.mollieId = refund.id;
91
- await link.save();
92
-
93
- await reconcileRefundStatus(localRefund.id, refund.status, createdAt);
76
+ // Refunds that were canceled or failed before we ever registered them can be ignored
77
+ if (refund.status === 'canceled' || refund.status === 'failed') {
94
78
  continue;
95
79
  }
96
- }
97
80
 
98
- if (refund.paymentId) {
99
- const molliePayment = await MolliePayment.select().where('mollieId', refund.paymentId).first(false);
100
- if (molliePayment) {
101
- const payment = await Payment.getByID(molliePayment.paymentId);
102
- if (payment) {
103
- try {
104
- const amount = Math.round(parseFloat(refund.amount.value) * 100) * 100;
105
- const status = MollieService.refundStatusToPaymentStatus(refund.status) === PaymentStatus.Succeeded ? PaymentStatus.Succeeded : PaymentStatus.Pending;
106
- const createdPayment = await PaymentService.registerRefund(payment, amount, createdAt, status);
107
-
108
- // Link Mollie refund ID (so we don't register it twice and can set the settlement later)
109
- const molliePayment = new MolliePayment();
110
- molliePayment.paymentId = createdPayment.id;
111
- molliePayment.mollieId = refund.id;
112
- await molliePayment.save();
113
- } catch (e) {
114
- console.error('Failed to register refund ' + refund.id, e);
81
+ // Safety net: when creating a refund via the API succeeded, but saving the link afterwards
82
+ // failed, the local refund payment already exists. Restore the link instead of registering
83
+ // the refund twice (the metadata is set in PaymentService.createRefund).
84
+ const metadata = refund.metadata as { refundPaymentId?: string } | null;
85
+ if (metadata?.refundPaymentId) {
86
+ const localRefund = await Payment.getByID(metadata.refundPaymentId);
87
+ if (localRefund && localRefund.type === PaymentType.Refund) {
88
+ console.error('Restoring missing link for Mollie refund ' + refund.id + ' to payment ' + localRefund.id);
89
+ const link = new MolliePayment();
90
+ link.paymentId = localRefund.id;
91
+ link.mollieId = refund.id;
92
+ await link.save();
93
+
94
+ await reconcileRefundStatus(localRefund.id, refund.status, createdAt);
95
+ continue;
96
+ }
97
+ }
98
+
99
+ if (refund.paymentId) {
100
+ const molliePayment = await MolliePayment.select().where('mollieId', refund.paymentId).first(false);
101
+ if (molliePayment) {
102
+ const payment = await Payment.getByID(molliePayment.paymentId);
103
+ if (payment) {
104
+ try {
105
+ const amount = Math.round(parseFloat(refund.amount.value) * 100) * 100;
106
+ const status = MollieService.refundStatusToPaymentStatus(refund.status) === PaymentStatus.Succeeded ? PaymentStatus.Succeeded : PaymentStatus.Pending;
107
+ const createdPayment = await PaymentService.registerRefund(payment, amount, createdAt, status);
108
+
109
+ // Link Mollie refund ID (so we don't register it twice and can set the settlement later)
110
+ const molliePayment = new MolliePayment();
111
+ molliePayment.paymentId = createdPayment.id;
112
+ molliePayment.mollieId = refund.id;
113
+ await molliePayment.save();
114
+ } catch (e) {
115
+ console.error('Failed to register refund ' + refund.id, e);
116
+ }
115
117
  }
118
+ } else {
119
+ console.error('Invalid refund payment id ' + refund.paymentId + ', not found');
116
120
  }
117
- } else {
118
- console.error('Invalid refund payment id ' + refund.paymentId + ', not found');
119
121
  }
120
122
  }
121
123
  }
@@ -129,7 +131,7 @@ export async function checkMollieRefundsFor(service: MollieService, checkAll = f
129
131
  * Update a locally registered refund that is still pending when Mollie reports a final status
130
132
  * (refunded, failed or canceled).
131
133
  */
132
- async function reconcileRefundStatus(paymentId: string, mollieStatus: RefundStatus, createdAt: Date) {
134
+ async function reconcileRefundStatus(paymentId: string, mollieStatus: ListEntityRefundStatus, createdAt: Date) {
133
135
  const payment = await Payment.getByID(paymentId);
134
136
  if (!payment || !payment.organizationId) {
135
137
  return;
@@ -10,6 +10,7 @@ import { Organization as OrganizationStruct } from '@stamhoofd/structures';
10
10
  import { Formatter } from '@stamhoofd/utility';
11
11
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures.js';
12
12
  import { Context } from '../../../helpers/Context.js';
13
+ import { SQL } from '@stamhoofd/sql';
13
14
 
14
15
  type Params = Record<string, never>;
15
16
  type Query = undefined;
@@ -64,13 +65,23 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
64
65
 
65
66
  // Financial records may not disappear together with the organization that had to pay them
66
67
  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(),
68
+ BalanceItem.select().where('payingOrganizationId', id).first(false),
69
+ Payment.select().where('payingOrganizationId', id).first(false),
70
+ ApplicationFee.select().where('payingOrganizationId', id).first(false),
71
+ Invoice.select().where('payingOrganizationId', id).first(false),
72
+ Payment.select()
73
+ .where('organizationId', id)
74
+ .where(
75
+ SQL.where('serviceFeePayout', '!=', 0)
76
+ .or('serviceFeeManual', '!=', 0)
77
+ .or('serviceFeeManualCharged', '!=', 0)
78
+ .or('transferFeeManual', '!=', 0)
79
+ .or('transferFeeManualCharged', '!=', 0),
80
+ )
81
+ .first(false),
71
82
  ]);
72
83
 
73
- if (payerRecordCounts.some(count => count > 0)) {
84
+ if (payerRecordCounts.some(hasSome => hasSome !== null)) {
74
85
  throw new SimpleError({
75
86
  code: 'organization_has_financial_records',
76
87
  message: 'Organization is still referenced as paying organization',
@@ -1,6 +1,9 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { OrganizationFactory, Token, User, UserFactory } from '@stamhoofd/models';
3
- import { Token as TokenStruct } from '@stamhoofd/structures';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { OrganizationFactory, Token, User, UserFactory, UserSession } from '@stamhoofd/models';
4
+ import { SESSION_DURATIONS } from '@stamhoofd/models/constants/sessions.js';
5
+ import { PermissionLevel, Permissions, SessionClientType, Token as TokenStruct } from '@stamhoofd/structures';
6
+ import { SessionService } from '../../services/SessionService.js';
4
7
 
5
8
  import { testServer } from '../../../tests/helpers/TestServer.js';
6
9
  import { CreateTokenEndpoint } from './CreateTokenEndpoint.js';
@@ -41,7 +44,7 @@ describe('Endpoint.CreateToken', () => {
41
44
  // Also check UTF8 passwords
42
45
  const password = '54😂test👌🏾86s&é';
43
46
  const user = await new UserFactory({ organization, password }).create();
44
- const token = await Token.createToken(user);
47
+ const token = await SessionService.createSession(user);
45
48
 
46
49
  const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
47
50
  grant_type: 'refresh_token',
@@ -94,7 +97,7 @@ describe('Endpoint.CreateToken', () => {
94
97
  test('refreshing a token marks the user as active again', async () => {
95
98
  const organization = await new OrganizationFactory({}).create();
96
99
  const user = await new UserFactory({ organization, password }).create();
97
- const token = await Token.createToken(user);
100
+ const token = await SessionService.createSession(user);
98
101
 
99
102
  const monthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
100
103
  monthsAgo.setMilliseconds(0);
@@ -125,4 +128,207 @@ describe('Endpoint.CreateToken', () => {
125
128
  expect(await getLastActiveAt(user)).toBeNull();
126
129
  });
127
130
  });
131
+
132
+ describe('session length', () => {
133
+ const password = 'test-password-1234';
134
+ const DAY = 24 * 60 * 60 * 1000;
135
+
136
+ function expectValidFor(date: Date, duration: number) {
137
+ expect(date.getTime()).toBeGreaterThan(Date.now() + duration - 5000);
138
+ expect(date.getTime()).toBeLessThanOrEqual(Date.now() + duration);
139
+ }
140
+
141
+ async function login(organization: Organization, user: User, platform: string | null = null): Promise<Token> {
142
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
143
+ grant_type: 'password',
144
+ username: user.email,
145
+ password,
146
+ });
147
+ if (platform) {
148
+ r.headers['x-platform'] = platform;
149
+ }
150
+
151
+ const response = await testServer.test(endpoint, r);
152
+ if (!(response.body instanceof TokenStruct)) {
153
+ throw new Error('Expected TokenStruct');
154
+ }
155
+
156
+ const token = await Token.getByAccessToken(response.body.accessToken);
157
+ expect(token).toBeDefined();
158
+ return token!;
159
+ }
160
+
161
+ async function refresh(organization: Organization, refreshToken: string): Promise<Token> {
162
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
163
+ grant_type: 'refresh_token',
164
+ refresh_token: refreshToken,
165
+ });
166
+
167
+ const response = await testServer.test(endpoint, r);
168
+ if (!(response.body instanceof TokenStruct)) {
169
+ throw new Error('Expected TokenStruct');
170
+ }
171
+
172
+ const token = await Token.getByAccessToken(response.body.accessToken);
173
+ expect(token).toBeDefined();
174
+ return token!;
175
+ }
176
+
177
+ async function getSession(token: Token): Promise<UserSession> {
178
+ const session = await UserSession.getByID(token.sessionId);
179
+ if (!session) throw new Error('Expected user session');
180
+ return session;
181
+ }
182
+
183
+ test('an administrator that signs in in a browser gets a limited session', async () => {
184
+ const organization = await new OrganizationFactory({}).create();
185
+ const admin = await new UserFactory({
186
+ organization,
187
+ password,
188
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
189
+ }).create();
190
+
191
+ const token = await login(organization, admin, 'web');
192
+
193
+ expect((await getSession(token)).clientType).toBe(SessionClientType.Browser);
194
+ expectValidFor(token.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.Browser].refreshToken);
195
+ });
196
+
197
+ test('an administrator that signs in in the native app has no maximum session length', async () => {
198
+ const organization = await new OrganizationFactory({}).create();
199
+ const admin = await new UserFactory({
200
+ organization,
201
+ password,
202
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
203
+ }).create();
204
+
205
+ const token = await login(organization, admin, 'ios');
206
+
207
+ expect((await getSession(token)).clientType).toBe(SessionClientType.iOS);
208
+ expectValidFor(token.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.iOS].refreshToken);
209
+
210
+ const browserSessionDuration = SESSION_DURATIONS.admin[SessionClientType.Browser].session;
211
+ if (browserSessionDuration === null) {
212
+ throw new Error('Expected browser administrator sessions to have a maximum length');
213
+ }
214
+ const userSession = await getSession(token);
215
+ userSession.startedAt = new Date(Date.now() - 2 * browserSessionDuration);
216
+ await userSession.save();
217
+
218
+ const renewed = await refresh(organization, token.refreshToken);
219
+ expectValidFor(renewed.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.iOS].refreshToken);
220
+ });
221
+
222
+ test('renewing the same active token twice revokes the first replacement', async () => {
223
+ const organization = await new OrganizationFactory({}).create();
224
+ const user = await new UserFactory({ organization, password }).create();
225
+ const original = await login(organization, user);
226
+
227
+ const firstReplacement = await refresh(organization, original.refreshToken);
228
+ const secondReplacement = await refresh(organization, original.refreshToken);
229
+
230
+ expect(secondReplacement.sessionId).toBe(original.sessionId);
231
+ expect(await Token.getByAccessToken(firstReplacement.accessToken)).toBeUndefined();
232
+ expect(await Token.getByAccessToken(original.accessToken)).toBeDefined();
233
+ expect(await Token.getByAccessToken(secondReplacement.accessToken)).toBeDefined();
234
+
235
+ const sessionTokens = await Token.where({ sessionId: original.sessionId });
236
+ expect(sessionTokens.map(token => token.id).sort()).toEqual([original.id, secondReplacement.id].sort());
237
+
238
+ const forkRequest = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
239
+ grant_type: 'refresh_token',
240
+ refresh_token: firstReplacement.refreshToken,
241
+ });
242
+ await expect(testServer.test(endpoint, forkRequest)).rejects.toMatchObject({ code: 'invalid_refresh_token' });
243
+ });
244
+
245
+ test('an SSO handoff refresh token can only be exchanged once', async () => {
246
+ const organization = await new OrganizationFactory({}).create();
247
+ const user = await new UserFactory({ organization }).create();
248
+ const handoff = await SessionService.createSSOHandoff(user);
249
+
250
+ const sessionToken = await refresh(organization, handoff.refreshToken);
251
+ expect(sessionToken.sessionId).toBe(handoff.sessionId);
252
+
253
+ await expect(refresh(organization, handoff.refreshToken)).rejects.toMatchObject({
254
+ code: 'invalid_refresh_token',
255
+ });
256
+ });
257
+
258
+ test('concurrent exchanges cannot fork an SSO handoff', async () => {
259
+ const organization = await new OrganizationFactory({}).create();
260
+ const user = await new UserFactory({ organization }).create();
261
+ const handoff = await SessionService.createSSOHandoff(user);
262
+
263
+ const exchanges = await Promise.allSettled([
264
+ refresh(organization, handoff.refreshToken),
265
+ refresh(organization, handoff.refreshToken),
266
+ ]);
267
+
268
+ expect(exchanges.filter(result => result.status === 'fulfilled')).toHaveLength(1);
269
+ const rejected = exchanges.filter(result => result.status === 'rejected');
270
+ expect(rejected).toHaveLength(1);
271
+ expect(rejected[0].reason).toMatchObject({ code: 'invalid_refresh_token' });
272
+ });
273
+
274
+ test('renewing an access token does not extend the session past its maximum length', async () => {
275
+ const organization = await new OrganizationFactory({}).create();
276
+ const admin = await new UserFactory({
277
+ organization,
278
+ password,
279
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
280
+ }).create();
281
+
282
+ const token = await login(organization, admin, 'web');
283
+
284
+ const sessionDuration = SESSION_DURATIONS.admin[SessionClientType.Browser].session;
285
+ if (sessionDuration === null) {
286
+ throw new Error('Expected browser administrator sessions to have a maximum length');
287
+ }
288
+ const sessionStartedAt = new Date(Date.now() - sessionDuration + DAY);
289
+ sessionStartedAt.setMilliseconds(0);
290
+ const userSession = await getSession(token);
291
+ userSession.startedAt = sessionStartedAt;
292
+ await userSession.save();
293
+
294
+ const renewed = await refresh(organization, token.refreshToken);
295
+
296
+ expect((await getSession(renewed)).startedAt).toEqual(sessionStartedAt);
297
+ expect(renewed.refreshTokenValidUntil.getTime()).toBeLessThanOrEqual(sessionStartedAt.getTime() + sessionDuration);
298
+ expect(renewed.refreshTokenValidUntil.getTime()).toBeGreaterThan(Date.now());
299
+ });
300
+
301
+ test('a session that reached its maximum length cannot be renewed', async () => {
302
+ const organization = await new OrganizationFactory({}).create();
303
+ const admin = await new UserFactory({
304
+ organization,
305
+ password,
306
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
307
+ }).create();
308
+
309
+ const token = await login(organization, admin, 'web');
310
+ const sessionDuration = SESSION_DURATIONS.admin[SessionClientType.Browser].session;
311
+ if (sessionDuration === null) {
312
+ throw new Error('Expected browser administrator sessions to have a maximum length');
313
+ }
314
+ const userSession = await getSession(token);
315
+ userSession.startedAt = new Date(Date.now() - sessionDuration - DAY);
316
+ await userSession.save();
317
+ token.refreshTokenValidUntil = new Date(Date.now() + DAY);
318
+ await token.save();
319
+
320
+ const otherSession = await login(organization, admin, 'web');
321
+
322
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
323
+ grant_type: 'refresh_token',
324
+ refresh_token: token.refreshToken,
325
+ });
326
+
327
+ await expect(testServer.test(endpoint, r)).rejects.toMatchObject({ code: 'invalid_refresh_token' });
328
+ expect(await Token.getByAccessToken(otherSession.accessToken)).toBeDefined();
329
+
330
+ await expect(testServer.test(endpoint, r)).rejects.toMatchObject({ code: 'invalid_refresh_token' });
331
+ expect(await Token.getByAccessToken(otherSession.accessToken)).toBeDefined();
332
+ });
333
+ });
128
334
  });