@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.
- package/package.json +19 -19
- package/src/crons/fake-settlements.test.ts +13 -11
- package/src/crons/fake-settlements.ts +22 -38
- package/src/crons/mollie-chargebacks.ts +31 -29
- package/src/crons/mollie-refunds.ts +61 -59
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
- package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
- package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
- package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
- package/src/endpoints/auth/MFA.security.test.ts +9 -7
- package/src/endpoints/auth/MFA.test.ts +42 -15
- package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
- package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
- package/src/endpoints/global/files/UploadFile.test.ts +2 -1
- package/src/endpoints/global/files/UploadFile.ts +53 -25
- package/src/endpoints/global/files/UploadImage.test.ts +2 -1
- package/src/endpoints/global/files/UploadImage.ts +32 -6
- package/src/endpoints/global/files/upload-security.test.ts +3 -2
- package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
- package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
- package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
- package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
- package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
- package/src/helpers/ApplicationFeeInvoicer.ts +1 -1
- package/src/helpers/Context.ts +14 -21
- package/src/helpers/ContextStorage.ts +5 -0
- package/src/helpers/MollieSettlementSync.test.ts +94 -41
- package/src/helpers/MollieSettlementSync.ts +174 -78
- package/src/helpers/StripeSettlementSync.test.ts +0 -85
- package/src/helpers/StripeSettlementSync.ts +47 -70
- package/src/helpers/TwoFactorHelper.ts +21 -22
- package/src/helpers/streamForBreakdown.ts +1 -1
- package/src/services/AdminSessionService.ts +5 -3
- package/src/services/InvoiceService.ts +2 -2
- package/src/services/MollieService.ts +168 -126
- package/src/services/PaymentService.ts +2 -2
- package/src/services/SSOService.test.ts +25 -1
- package/src/services/SSOService.ts +17 -6
- package/src/services/SessionService.test.ts +319 -0
- package/src/services/SessionService.ts +357 -0
- package/src/services/SettlementService.test.ts +13 -85
- package/src/services/SettlementService.ts +5 -113
- package/src/services/WebshopCrowdfundingService.test.ts +4 -3
- package/src/sql-filters/balance-items.ts +10 -0
- package/src/sql-filters/registrations.ts +59 -1
- package/tests/actions/patchUserMember.ts +2 -1
- package/tests/e2e/api-rate-limits.test.ts +2 -1
- package/tests/e2e/bundle-discounts.test.ts +4 -3
- package/tests/e2e/charge-members.test.ts +6 -5
- package/tests/e2e/documents.test.ts +4 -3
- package/tests/e2e/private-files.test.ts +9 -8
- package/tests/e2e/register.test.ts +4 -3
- package/tests/e2e/stock.test.ts +4 -3
- package/tests/e2e/tickets.test.ts +4 -3
- package/tests/helpers/ExportSlice.ts +2 -1
- package/tests/helpers/MollieMocker.ts +173 -38
- package/tests/init/initAdmin.ts +2 -1
- package/tests/init/initPlatformAdmin.ts +2 -1
- package/src/helpers/ViesHelper.test.ts +0 -401
- package/src/helpers/ViesHelper.ts +0 -215
- package/src/services/PeppolDirectoryService.test.ts +0 -139
- package/src/services/PeppolDirectoryService.ts +0 -111
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { Database } from '@simonbackx/simple-database';
|
|
2
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
+
import { Token, User, UserSession } from '@stamhoofd/models';
|
|
4
|
+
import type { SessionDurations, SessionType } from '@stamhoofd/models/constants/sessions.js';
|
|
5
|
+
import { ACCESS_TOKEN_DURATION, SESSION_DURATIONS, SSO_HANDOFF_TOKEN_DURATION } from '@stamhoofd/models/constants/sessions.js';
|
|
6
|
+
import { SQLWhereSign } from '@stamhoofd/sql';
|
|
7
|
+
import { SessionClientType, SessionDeviceType, SessionLoginMethod, SessionOS } from '@stamhoofd/structures';
|
|
8
|
+
import type { SessionMetaData } from '@stamhoofd/structures';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
11
|
+
|
|
12
|
+
import { contextStorage } from '../helpers/ContextStorage.js';
|
|
13
|
+
|
|
14
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
15
|
+
const MAX_SESSIONS = 15;
|
|
16
|
+
const API_TOKEN_DURATION = 5 * 365 * DAY;
|
|
17
|
+
|
|
18
|
+
async function randomBytes(size: number): Promise<Buffer> {
|
|
19
|
+
return await new Promise((resolve, reject) => {
|
|
20
|
+
crypto.randomBytes(size, (error, buffer) => error ? reject(error) : resolve(buffer));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class SessionService {
|
|
25
|
+
/**
|
|
26
|
+
* @param authenticatedAt Pass the current date when a primary credential was verified
|
|
27
|
+
* as part of this request, so the session counts as "fresh" for sensitive actions.
|
|
28
|
+
*/
|
|
29
|
+
static async createSession<U extends User>(user: U, { loginMethod = SessionLoginMethod.Password, authenticatedAt = null }: { loginMethod?: SessionLoginMethod; authenticatedAt?: Date | null } = {}): Promise<Token & { user: U }> {
|
|
30
|
+
const tokenId = uuidv4();
|
|
31
|
+
const session = await this.createUserSession(user, {
|
|
32
|
+
tokenId,
|
|
33
|
+
clientType: this.getClientType(),
|
|
34
|
+
loginMethod,
|
|
35
|
+
metaData: this.getMetaData(),
|
|
36
|
+
});
|
|
37
|
+
const token = await this.createToken(user, { session, tokenId });
|
|
38
|
+
token.authenticatedAt = authenticatedAt;
|
|
39
|
+
this.applyLimits({ token, session, user });
|
|
40
|
+
await token.save();
|
|
41
|
+
return token;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Used by the SSO callback: that is a browser redirect, so the only way to hand the
|
|
46
|
+
* session to the client is in the URL. Only the refresh token travels along, and the
|
|
47
|
+
* client exchanges it for a usable access token.
|
|
48
|
+
*/
|
|
49
|
+
static async createSSOHandoff<U extends User>(user: U, { clientType = this.getClientType(), metaData = this.getMetaData() }: { clientType?: SessionClientType; metaData?: SessionMetaData } = {}): Promise<Token & { user: U }> {
|
|
50
|
+
const tokenId = uuidv4();
|
|
51
|
+
const session = await this.createUserSession(user, { tokenId, clientType, loginMethod: SessionLoginMethod.SSO, metaData });
|
|
52
|
+
const token = await this.createToken(user, { session, tokenId });
|
|
53
|
+
|
|
54
|
+
token.accessTokenValidUntil = new Date(Date.now() - 31 * DAY);
|
|
55
|
+
token.accessTokenValidUntil.setMilliseconds(0);
|
|
56
|
+
this.applyLimits({ token, session, user });
|
|
57
|
+
token.refreshTokenValidUntil = new Date(Math.min(token.refreshTokenValidUntil.getTime(), Date.now() + SSO_HANDOFF_TOKEN_DURATION));
|
|
58
|
+
token.refreshTokenValidUntil.setMilliseconds(0);
|
|
59
|
+
await token.save();
|
|
60
|
+
return token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static async createApiSession<U extends User>(user: U): Promise<Token & { user: U }> {
|
|
64
|
+
const tokenId = uuidv4();
|
|
65
|
+
const session = await this.createUserSession(user, {
|
|
66
|
+
tokenId,
|
|
67
|
+
clientType: SessionClientType.Browser,
|
|
68
|
+
loginMethod: SessionLoginMethod.Password,
|
|
69
|
+
metaData: this.getFallbackMetaData(),
|
|
70
|
+
});
|
|
71
|
+
const token = await this.createToken(user, { session, tokenId });
|
|
72
|
+
token.accessTokenValidUntil = new Date(Date.now() + API_TOKEN_DURATION);
|
|
73
|
+
token.accessTokenValidUntil.setMilliseconds(0);
|
|
74
|
+
token.refreshTokenValidUntil = new Date(token.accessTokenValidUntil);
|
|
75
|
+
await token.save();
|
|
76
|
+
return token;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The new token continues the session of the old one, so it keeps its start date and
|
|
81
|
+
* cannot outlive it. Using the session is what postpones the inactivity limit.
|
|
82
|
+
*/
|
|
83
|
+
static async rotateSession<U extends User>(oldToken: Token & { user: U }): Promise<Token & { user: U }> {
|
|
84
|
+
const session = await UserSession.getByID(oldToken.sessionId);
|
|
85
|
+
if (!session || session.userId !== oldToken.userId) {
|
|
86
|
+
throw this.invalidRefreshTokenError();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const durations = this.getDurations(session, oldToken.user);
|
|
90
|
+
if (durations.session !== null && session.startedAt.getTime() + durations.session <= Date.now()) {
|
|
91
|
+
await oldToken.delete();
|
|
92
|
+
throw this.invalidRefreshTokenError();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isSSOHandoff = this.isSSOHandoff(oldToken, session);
|
|
96
|
+
const token = await this.createToken(oldToken.user, { session });
|
|
97
|
+
|
|
98
|
+
this.applyLimits({ token, session, user: oldToken.user });
|
|
99
|
+
await token.save();
|
|
100
|
+
|
|
101
|
+
const didRotate = isSSOHandoff
|
|
102
|
+
? await this.consumeSSOHandoff({ sessionId: session.id, handoffTokenId: oldToken.id, tokenId: token.id })
|
|
103
|
+
: await this.replaceLastUsedToken({ sessionId: session.id, previousTokenId: oldToken.id, tokenId: token.id });
|
|
104
|
+
if (!didRotate) {
|
|
105
|
+
await token.delete();
|
|
106
|
+
throw this.invalidRefreshTokenError();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const metaData = this.getMetaData();
|
|
110
|
+
session.appVersion = metaData.appVersion ?? session.appVersion;
|
|
111
|
+
session.nativeAppVersion = metaData.nativeAppVersion ?? session.nativeAppVersion;
|
|
112
|
+
session.osVersion = metaData.osVersion ?? session.osVersion;
|
|
113
|
+
await session.save();
|
|
114
|
+
return token;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private static isSSOHandoff(token: Token, session: UserSession): boolean {
|
|
118
|
+
// Handoff access tokens expire before they are created; normal access tokens do not.
|
|
119
|
+
return session.loginMethod === SessionLoginMethod.SSO && token.accessTokenValidUntil < token.createdAt;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static async getByRefreshToken(refreshToken: string) {
|
|
123
|
+
const token = await Token.getByRefreshToken(refreshToken);
|
|
124
|
+
if (!token || token.user.isApiUser) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (token.refreshTokenValidUntil < new Date()) {
|
|
128
|
+
console.error('Detected an expired refresh token, deleting all sessions and tokens for user', token.userId);
|
|
129
|
+
await UserSession.delete().where('userId', token.userId).delete();
|
|
130
|
+
await Token.delete().where('userId', token.userId).delete();
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return token;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static async activateToken(token: Token): Promise<boolean> {
|
|
137
|
+
const [result] = await Database.update(
|
|
138
|
+
'UPDATE `user_sessions` SET `lastActiveTokenId` = ? WHERE `id` = ? AND `lastUsedTokenId` = ?',
|
|
139
|
+
[token.id, token.sessionId, token.id],
|
|
140
|
+
);
|
|
141
|
+
if (result.affectedRows > 0) {
|
|
142
|
+
await Database.delete('DELETE FROM `tokens` WHERE `sessionId` = ? AND `id` != ?', [token.sessionId, token.id]);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const [sessions] = await Database.select('SELECT `id` FROM `user_sessions` WHERE `id` = ? AND `lastActiveTokenId` = ? LIMIT 1', [token.sessionId, token.id]);
|
|
147
|
+
return sessions.length > 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static async endSession(token: Token): Promise<void> {
|
|
151
|
+
await UserSession.delete().where('id', token.sessionId).delete();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
static async deleteOtherSessions({ userId, keepAccessToken }: { userId: string; keepAccessToken: string | null }): Promise<number> {
|
|
155
|
+
const user = await User.getByID(userId);
|
|
156
|
+
if (user?.isApiUser) {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
return await this.deleteForUsers({ userIds: [userId], keepAccessToken });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static async deleteForUsers({ userIds, keepAccessToken }: { userIds: string[]; keepAccessToken: string | null }): Promise<number> {
|
|
163
|
+
if (userIds.length === 0) {
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
const query = Token.delete().where('userId', userIds);
|
|
167
|
+
if (keepAccessToken) {
|
|
168
|
+
query.where(Token.primary.name, SQLWhereSign.NotEqual, keepAccessToken);
|
|
169
|
+
}
|
|
170
|
+
const { affectedRows } = await query.delete();
|
|
171
|
+
return affectedRows;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
static async clearFor({ userId, keepAccessToken }: { userId: string; keepAccessToken: string }): Promise<void> {
|
|
175
|
+
await Database.delete('DELETE FROM `tokens` WHERE `userId` = ? AND `accessToken` != ?', [userId, keepAccessToken]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static invalidRefreshTokenError() {
|
|
179
|
+
return new SimpleError({
|
|
180
|
+
code: 'invalid_refresh_token',
|
|
181
|
+
message: 'Invalid refresh token',
|
|
182
|
+
statusCode: 400,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private static async createToken<U extends User>(user: U, { session, tokenId = uuidv4() }: { session: UserSession; tokenId?: string }): Promise<Token & { user: U }> {
|
|
187
|
+
if (user.isSystemUser) {
|
|
188
|
+
throw new SimpleError({
|
|
189
|
+
code: 'internal_error',
|
|
190
|
+
message: 'Cannot create token for system user',
|
|
191
|
+
statusCode: 500,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const token = new Token().setRelation(Token.user, user);
|
|
196
|
+
token.id = tokenId;
|
|
197
|
+
token.sessionId = session.id;
|
|
198
|
+
token.accessTokenValidUntil = new Date(Date.now() + ACCESS_TOKEN_DURATION);
|
|
199
|
+
token.accessTokenValidUntil.setMilliseconds(0);
|
|
200
|
+
token.refreshTokenValidUntil = new Date(token.accessTokenValidUntil);
|
|
201
|
+
token.accessToken = (await randomBytes(192)).toString('base64').toUpperCase();
|
|
202
|
+
token.refreshToken = (await randomBytes(192)).toString('base64').toUpperCase();
|
|
203
|
+
return token;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private static async createUserSession(user: User, { tokenId, clientType, loginMethod, metaData }: { tokenId: string; clientType: SessionClientType; loginMethod: SessionLoginMethod; metaData: SessionMetaData }): Promise<UserSession> {
|
|
207
|
+
const session = new UserSession();
|
|
208
|
+
session.id = uuidv4();
|
|
209
|
+
session.userId = user.id;
|
|
210
|
+
session.clientType = clientType;
|
|
211
|
+
session.loginMethod = loginMethod;
|
|
212
|
+
session.startedAt = new Date();
|
|
213
|
+
session.startedAt.setMilliseconds(0);
|
|
214
|
+
session.lastUsedTokenId = session.lastActiveTokenId = tokenId;
|
|
215
|
+
this.applyMetaData(session, metaData);
|
|
216
|
+
|
|
217
|
+
await Database.delete(
|
|
218
|
+
'DELETE FROM `user_sessions` WHERE `id` IN (SELECT `id` FROM (SELECT `id` FROM `user_sessions` WHERE `userId` = ? ORDER BY `startedAt` DESC LIMIT ? OFFSET ?) old_sessions)',
|
|
219
|
+
[user.id, MAX_SESSIONS - 1, MAX_SESSIONS - 1],
|
|
220
|
+
);
|
|
221
|
+
await session.save();
|
|
222
|
+
return session;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private static applyMetaData(session: UserSession, metaData: SessionMetaData): void {
|
|
226
|
+
session.deviceType = metaData.deviceType;
|
|
227
|
+
session.deviceName = metaData.deviceName;
|
|
228
|
+
session.osName = metaData.osName;
|
|
229
|
+
session.osVersion = metaData.osVersion;
|
|
230
|
+
session.appVersion = metaData.appVersion;
|
|
231
|
+
session.nativeAppVersion = metaData.nativeAppVersion;
|
|
232
|
+
session.browserName = metaData.browserName;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private static async replaceLastUsedToken({ sessionId, previousTokenId, tokenId }: { sessionId: string; previousTokenId: string; tokenId: string }): Promise<boolean> {
|
|
236
|
+
const [result] = await Database.update(
|
|
237
|
+
'UPDATE `user_sessions` SET `lastUsedTokenId` = ? WHERE `id` = ? AND (`lastUsedTokenId` = ? OR `lastActiveTokenId` = ?)',
|
|
238
|
+
[tokenId, sessionId, previousTokenId, previousTokenId],
|
|
239
|
+
);
|
|
240
|
+
if (result.affectedRows === 0) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
await Database.delete('DELETE FROM `tokens` WHERE `sessionId` = ? AND `id` != ? AND `id` != (SELECT `lastActiveTokenId` FROM `user_sessions` WHERE `id` = ?)', [sessionId, tokenId, sessionId]);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private static async consumeSSOHandoff({ sessionId, handoffTokenId, tokenId }: { sessionId: string; handoffTokenId: string; tokenId: string }): Promise<boolean> {
|
|
248
|
+
const [result] = await Database.update(
|
|
249
|
+
'UPDATE `user_sessions` SET `lastUsedTokenId` = ? WHERE `id` = ? AND `lastUsedTokenId` = ?',
|
|
250
|
+
[tokenId, sessionId, handoffTokenId],
|
|
251
|
+
);
|
|
252
|
+
if (result.affectedRows === 0) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
await Database.delete('DELETE FROM `tokens` WHERE `sessionId` = ? AND `id` != ?', [sessionId, tokenId]);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Only read when a session is created: it is a client provided header, and a rotation
|
|
261
|
+
* has to keep the value of the login instead of trusting the header again.
|
|
262
|
+
*/
|
|
263
|
+
private static getClientType(): SessionClientType {
|
|
264
|
+
const platform = contextStorage.getStore()?.request.headers['x-platform'];
|
|
265
|
+
if (platform === 'ios') {
|
|
266
|
+
return SessionClientType.iOS;
|
|
267
|
+
}
|
|
268
|
+
if (platform === 'android') {
|
|
269
|
+
return SessionClientType.Android;
|
|
270
|
+
}
|
|
271
|
+
return SessionClientType.Browser;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Recalculated on every rotation, so a user that becomes an administrator during a
|
|
276
|
+
* session is limited from that moment on (counted from their login, as if they had the
|
|
277
|
+
* permissions all along).
|
|
278
|
+
*/
|
|
279
|
+
private static getDurations(session: UserSession, user: User): SessionDurations {
|
|
280
|
+
return SESSION_DURATIONS[this.getSessionType(session, user)][session.clientType];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private static getSessionType(session: UserSession, user: User): SessionType {
|
|
284
|
+
// Before the permissions: whoever signs in through the identity provider stays
|
|
285
|
+
// dependent on it, member or administrator.
|
|
286
|
+
if (session.loginMethod === SessionLoginMethod.SSO) {
|
|
287
|
+
return 'sso';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const permissions = user.permissions;
|
|
291
|
+
if (!permissions || permissions.isEmpty) {
|
|
292
|
+
return 'user';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (permissions.globalPermissions !== null) {
|
|
296
|
+
return 'platformAdmin';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return 'admin';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private static applyLimits({ token, session, user }: { token: Token; session: UserSession; user: User }) {
|
|
303
|
+
const durations = this.getDurations(session, user);
|
|
304
|
+
const sessionEndsAt = durations.session !== null ? (session.startedAt.getTime() + durations.session) : (Date.now() + durations.refreshToken);
|
|
305
|
+
|
|
306
|
+
token.refreshTokenValidUntil = new Date(Math.min(Date.now() + durations.refreshToken, sessionEndsAt));
|
|
307
|
+
token.refreshTokenValidUntil.setMilliseconds(0);
|
|
308
|
+
|
|
309
|
+
token.accessTokenValidUntil = new Date(Math.min(token.accessTokenValidUntil.getTime(), sessionEndsAt));
|
|
310
|
+
token.accessTokenValidUntil.setMilliseconds(0);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
static getMetaData(): SessionMetaData {
|
|
314
|
+
const value = contextStorage.getStore()?.request.headers['x-session-metadata'];
|
|
315
|
+
return this.parseMetaData(typeof value === 'string' ? value : null);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static parseMetaData(value: string | null): SessionMetaData {
|
|
319
|
+
const fallback = this.getFallbackMetaData();
|
|
320
|
+
if (typeof value !== 'string' || value.length > 4096) {
|
|
321
|
+
return fallback;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
let parsed: Partial<SessionMetaData>;
|
|
326
|
+
try {
|
|
327
|
+
parsed = JSON.parse(value) as Partial<SessionMetaData>;
|
|
328
|
+
} catch {
|
|
329
|
+
parsed = JSON.parse(decodeURIComponent(value)) as Partial<SessionMetaData>;
|
|
330
|
+
}
|
|
331
|
+
const string = (candidate: unknown, max: number): string | null => typeof candidate === 'string' && candidate.length > 0 ? candidate.slice(0, max) : null;
|
|
332
|
+
return {
|
|
333
|
+
deviceType: Object.values(SessionDeviceType).includes(parsed.deviceType as SessionDeviceType) ? parsed.deviceType as SessionDeviceType : fallback.deviceType,
|
|
334
|
+
deviceName: string(parsed.deviceName, 255),
|
|
335
|
+
osName: Object.values(SessionOS).includes(parsed.osName as SessionOS) ? parsed.osName as SessionOS : null,
|
|
336
|
+
osVersion: string(parsed.osVersion, 64),
|
|
337
|
+
appVersion: string(parsed.appVersion, 64),
|
|
338
|
+
nativeAppVersion: string(parsed.nativeAppVersion, 64),
|
|
339
|
+
browserName: string(parsed.browserName, 64),
|
|
340
|
+
};
|
|
341
|
+
} catch {
|
|
342
|
+
return fallback;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private static getFallbackMetaData(): SessionMetaData {
|
|
347
|
+
return {
|
|
348
|
+
deviceType: SessionDeviceType.Desktop,
|
|
349
|
+
deviceName: null,
|
|
350
|
+
osName: null,
|
|
351
|
+
osVersion: null,
|
|
352
|
+
appVersion: null,
|
|
353
|
+
nativeAppVersion: null,
|
|
354
|
+
browserName: null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -9,7 +9,7 @@ import { BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, Pay
|
|
|
9
9
|
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
10
10
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
11
11
|
import { v4 as uuidv4 } from 'uuid';
|
|
12
|
-
import {
|
|
12
|
+
import { SettlementService } from './SettlementService.js';
|
|
13
13
|
|
|
14
14
|
describe('SettlementService', () => {
|
|
15
15
|
let organization: Organization;
|
|
@@ -272,79 +272,7 @@ describe('SettlementService', () => {
|
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
describe('
|
|
276
|
-
test('deletes payout-only rows, but unlinks what outlives the payout', async () => {
|
|
277
|
-
const payment = await createPayment();
|
|
278
|
-
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
279
|
-
|
|
280
|
-
const keptLine = await SettlementService.upsertPaymentLine(settlement, {
|
|
281
|
-
paymentId: payment.id, amount: 50_00_00, externalId: 'txn_kept', occurredAt: new Date(2026, 0, 14),
|
|
282
|
-
});
|
|
283
|
-
const movedLine = await SettlementService.upsertPaymentLine(settlement, {
|
|
284
|
-
paymentId: payment.id, amount: 10_00_00, externalId: 'txn_moved', occurredAt: new Date(2026, 0, 14),
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// A derived fee line is never reported by a walk: it belongs to the fee payment
|
|
288
|
-
const derivedLine = await SettlementService.upsertPaymentLine(settlement, {
|
|
289
|
-
paymentId: payment.id, amount: 2_00_00, externalId: null, occurredAt: new Date(2026, 0, 14),
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
const { fee, charge: referencedCharge } = await createApplicationFee({ settlement });
|
|
293
|
-
const providerFee = await SettlementService.upsertCharge({
|
|
294
|
-
type: SettlementChargeType.ProviderTransactionFee,
|
|
295
|
-
externalId: 'txn_moved:fee:0',
|
|
296
|
-
amount: -30_00,
|
|
297
|
-
settlementId: settlement.id,
|
|
298
|
-
organizationId: organization.id,
|
|
299
|
-
occurredAt: new Date(2026, 0, 14),
|
|
300
|
-
});
|
|
301
|
-
const keptFee = await SettlementService.upsertCharge({
|
|
302
|
-
type: SettlementChargeType.ProviderTransactionFee,
|
|
303
|
-
externalId: 'txn_kept:fee:0',
|
|
304
|
-
amount: -25_00,
|
|
305
|
-
settlementId: settlement.id,
|
|
306
|
-
organizationId: organization.id,
|
|
307
|
-
occurredAt: new Date(2026, 0, 14),
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
// The deduction charge is linked to this settlement too, but no longer reported
|
|
311
|
-
referencedCharge.settlementId = settlement.id;
|
|
312
|
-
await referencedCharge.save();
|
|
313
|
-
|
|
314
|
-
const reported = new ReportedRows();
|
|
315
|
-
reported.paymentLine(keptLine);
|
|
316
|
-
reported.charge(keptFee);
|
|
317
|
-
const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
|
|
318
|
-
|
|
319
|
-
expect(await PaymentSettlement.getByID(keptLine.id)).toBeDefined();
|
|
320
|
-
expect(await PaymentSettlement.getByID(movedLine.id)).toBeUndefined();
|
|
321
|
-
expect(await PaymentSettlement.getByID(derivedLine.id)).toBeDefined();
|
|
322
|
-
expect(await SettlementCharge.getByID(providerFee.id)).toBeUndefined();
|
|
323
|
-
expect((await SettlementCharge.getByID(keptFee.id))?.settlementId).toBe(settlement.id);
|
|
324
|
-
|
|
325
|
-
// A charge an application fee points at may never be deleted (foreign key), only unlinked
|
|
326
|
-
const unlinkedCharge = await SettlementCharge.getByID(referencedCharge.id);
|
|
327
|
-
expect(unlinkedCharge).toBeDefined();
|
|
328
|
-
expect(unlinkedCharge!.settlementId).toBe(null);
|
|
329
|
-
|
|
330
|
-
expect(unlinkedFees.map(f => f.id)).toEqual([fee.id]);
|
|
331
|
-
expect((await ApplicationFee.getByID(fee.id))!.settlementId).toBe(null);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test('keeps the application fees the walk reported', async () => {
|
|
335
|
-
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
336
|
-
const { fee } = await createApplicationFee({ settlement });
|
|
337
|
-
|
|
338
|
-
const reported = new ReportedRows();
|
|
339
|
-
reported.applicationFee(fee);
|
|
340
|
-
const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
|
|
341
|
-
|
|
342
|
-
expect(unlinkedFees).toHaveLength(0);
|
|
343
|
-
expect((await ApplicationFee.getByID(fee.id))!.settlementId).toBe(settlement.id);
|
|
344
|
-
});
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
describe('updatePaymentSettlementsForAccountDeductionPayment', () => {
|
|
275
|
+
describe('updatePaymentSettlementsForApplicationFeePayment', () => {
|
|
348
276
|
test('one line per payout, summing the fees it contained', async () => {
|
|
349
277
|
const { payment, balanceItem } = await createFeePayment(3_25_00);
|
|
350
278
|
const first = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 10) }));
|
|
@@ -357,7 +285,7 @@ describe('SettlementService', () => {
|
|
|
357
285
|
// A fee that isn't paid out yet doesn't produce a line
|
|
358
286
|
const { fee: pending } = await createApplicationFee({ balanceItemId: balanceItem.id, amount: 25_00 });
|
|
359
287
|
|
|
360
|
-
await SettlementService.
|
|
288
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
361
289
|
|
|
362
290
|
const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
|
|
363
291
|
expect(lines).toHaveLength(2);
|
|
@@ -374,7 +302,7 @@ describe('SettlementService', () => {
|
|
|
374
302
|
// Paying out the last fee completes the payment
|
|
375
303
|
pending.settlementId = second.id;
|
|
376
304
|
await pending.save();
|
|
377
|
-
await SettlementService.
|
|
305
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
378
306
|
|
|
379
307
|
const completed = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
|
|
380
308
|
expect(completed.reduce((total, line) => total + line.amount, 0)).toBe(payment.price);
|
|
@@ -387,10 +315,10 @@ describe('SettlementService', () => {
|
|
|
387
315
|
await createApplicationFee({ settlement, balanceItemId: first.balanceItem.id, amount: 1_00_00 });
|
|
388
316
|
await createApplicationFee({ settlement, balanceItemId: second.balanceItem.id, amount: 2_00_00 });
|
|
389
317
|
|
|
390
|
-
await SettlementService.
|
|
391
|
-
await SettlementService.
|
|
318
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(first.payment);
|
|
319
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(second.payment);
|
|
392
320
|
// Re-running may not duplicate: the lines have no externalId to upsert on
|
|
393
|
-
await SettlementService.
|
|
321
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(first.payment);
|
|
394
322
|
|
|
395
323
|
const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
|
|
396
324
|
expect(lines).toHaveLength(2);
|
|
@@ -403,19 +331,19 @@ describe('SettlementService', () => {
|
|
|
403
331
|
const settlement = await SettlementService.upsertSettlement(settlementData());
|
|
404
332
|
const { fee } = await createApplicationFee({ settlement, balanceItemId: balanceItem.id, amount: 1_00_00 });
|
|
405
333
|
|
|
406
|
-
await SettlementService.
|
|
334
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
407
335
|
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(1);
|
|
408
336
|
|
|
409
337
|
fee.settlementId = null;
|
|
410
338
|
await fee.save();
|
|
411
|
-
await SettlementService.
|
|
339
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
412
340
|
|
|
413
341
|
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(0);
|
|
414
342
|
});
|
|
415
343
|
|
|
416
344
|
test('other payment methods are left alone', async () => {
|
|
417
345
|
const payment = await createPayment();
|
|
418
|
-
await SettlementService.
|
|
346
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
419
347
|
expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(0);
|
|
420
348
|
});
|
|
421
349
|
});
|
|
@@ -464,7 +392,7 @@ describe('SettlementService', () => {
|
|
|
464
392
|
const { payment, balanceItem } = await createFeePayment(1_00_00);
|
|
465
393
|
fee.balanceItemId = balanceItem.id;
|
|
466
394
|
await fee.save();
|
|
467
|
-
await SettlementService.
|
|
395
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
468
396
|
|
|
469
397
|
const invoiced = await Settlement.getByID(settlement.id);
|
|
470
398
|
expect(invoiced!.pendingFees).toBe(0);
|
|
@@ -476,7 +404,7 @@ describe('SettlementService', () => {
|
|
|
476
404
|
const { payment, balanceItem } = await createFeePayment(1_00_00);
|
|
477
405
|
const { fee } = await createApplicationFee({ settlement, balanceItemId: balanceItem.id, amount: 1_00_00 });
|
|
478
406
|
|
|
479
|
-
await SettlementService.
|
|
407
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
480
408
|
await SettlementService.finishSync(settlement, { transactionCount: 1 });
|
|
481
409
|
expect(settlement.pendingFees).toBe(0);
|
|
482
410
|
expect(settlement.unexplainedAmount).toBe(0);
|
|
@@ -484,7 +412,7 @@ describe('SettlementService', () => {
|
|
|
484
412
|
// The payout no longer contains the fee: its line goes, and so does what it explained
|
|
485
413
|
fee.settlementId = null;
|
|
486
414
|
await fee.save();
|
|
487
|
-
await SettlementService.
|
|
415
|
+
await SettlementService.updatePaymentSettlementsForApplicationFeePayment(payment);
|
|
488
416
|
|
|
489
417
|
const unlinked = await Settlement.getByID(settlement.id);
|
|
490
418
|
expect(unlinked!.pendingFees).toBe(0);
|