@stamhoofd/backend 1.0.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/.env.template.json +63 -0
- package/.eslintrc.js +61 -0
- package/README.md +40 -0
- package/index.ts +172 -0
- package/jest.config.js +11 -0
- package/migrations.ts +33 -0
- package/package.json +48 -0
- package/src/crons.ts +845 -0
- package/src/endpoints/admin/organizations/GetOrganizationsCountEndpoint.ts +42 -0
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +320 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +171 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +137 -0
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +68 -0
- package/src/endpoints/auth/CreateTokenEndpoint.ts +200 -0
- package/src/endpoints/auth/DeleteTokenEndpoint.ts +31 -0
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +70 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +64 -0
- package/src/endpoints/auth/GetUserEndpoint.ts +57 -0
- package/src/endpoints/auth/PatchApiUserEndpoint.ts +90 -0
- package/src/endpoints/auth/PatchUserEndpoint.ts +122 -0
- package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +37 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +41 -0
- package/src/endpoints/auth/SignupEndpoint.ts +107 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +89 -0
- package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +95 -0
- package/src/endpoints/global/addresses/ValidateAddressEndpoint.ts +31 -0
- package/src/endpoints/global/caddy/CheckDomainCertEndpoint.ts +101 -0
- package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +53 -0
- package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +57 -0
- package/src/endpoints/global/files/UploadFile.ts +147 -0
- package/src/endpoints/global/files/UploadImage.ts +119 -0
- package/src/endpoints/global/members/GetMemberFamilyEndpoint.ts +76 -0
- package/src/endpoints/global/members/GetMembersCountEndpoint.ts +43 -0
- package/src/endpoints/global/members/GetMembersEndpoint.ts +429 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +734 -0
- package/src/endpoints/global/organizations/CheckRegisterCodeEndpoint.ts +45 -0
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.test.ts +105 -0
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +146 -0
- package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.test.ts +52 -0
- package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.ts +80 -0
- package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +49 -0
- package/src/endpoints/global/organizations/SearchOrganizationEndpoint.test.ts +58 -0
- package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +62 -0
- package/src/endpoints/global/payments/ExchangeSTPaymentEndpoint.ts +153 -0
- package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +134 -0
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +44 -0
- package/src/endpoints/global/platform/GetPlatformEnpoint.ts +39 -0
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +63 -0
- package/src/endpoints/global/registration/GetPaymentRegistrations.ts +68 -0
- package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +39 -0
- package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +80 -0
- package/src/endpoints/global/registration/GetUserMembersEndpoint.ts +41 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +134 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +521 -0
- package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +37 -0
- package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +115 -0
- package/src/endpoints/global/webshops/GetWebshopFromDomainEndpoint.ts +187 -0
- package/src/endpoints/organization/dashboard/billing/ActivatePackagesEndpoint.ts +424 -0
- package/src/endpoints/organization/dashboard/billing/DeactivatePackageEndpoint.ts +67 -0
- package/src/endpoints/organization/dashboard/billing/GetBillingStatusEndpoint.ts +39 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +57 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentEndpoint.ts +129 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplateEndpoint.ts +114 -0
- package/src/endpoints/organization/dashboard/email/CheckEmailBouncesEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +234 -0
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +62 -0
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +85 -0
- package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +80 -0
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +54 -0
- package/src/endpoints/organization/dashboard/mollie/DisconnectMollieEndpoint.ts +49 -0
- package/src/endpoints/organization/dashboard/mollie/GetMollieDashboardEndpoint.ts +63 -0
- package/src/endpoints/organization/dashboard/nolt/CreateNoltTokenEndpoint.ts +61 -0
- package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.test.ts +64 -0
- package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.ts +84 -0
- package/src/endpoints/organization/dashboard/organization/GetOrganizationArchivedGroups.ts +43 -0
- package/src/endpoints/organization/dashboard/organization/GetOrganizationDeletedGroups.ts +42 -0
- package/src/endpoints/organization/dashboard/organization/GetOrganizationSSOEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/organization/GetRegisterCodeEndpoint.ts +65 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +281 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +338 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +196 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationSSOEndpoint.ts +50 -0
- package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +48 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +207 -0
- package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +202 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +233 -0
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +66 -0
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +210 -0
- package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +93 -0
- package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +59 -0
- package/src/endpoints/organization/dashboard/stripe/GetStripeAccountLinkEndpoint.ts +78 -0
- package/src/endpoints/organization/dashboard/stripe/GetStripeAccountsEndpoint.ts +40 -0
- package/src/endpoints/organization/dashboard/stripe/GetStripeLoginLinkEndpoint.ts +69 -0
- package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +52 -0
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +73 -0
- package/src/endpoints/organization/dashboard/users/DeleteUserEndpoint.ts +60 -0
- package/src/endpoints/organization/dashboard/users/GetApiUsersEndpoint.ts +47 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +41 -0
- package/src/endpoints/organization/dashboard/webshops/CreateWebshopEndpoint.ts +217 -0
- package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +51 -0
- package/src/endpoints/organization/dashboard/webshops/GetDiscountCodesEndpoint.ts +47 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +83 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +68 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopUriAvailabilityEndpoint.ts +69 -0
- package/src/endpoints/organization/dashboard/webshops/PatchDiscountCodesEndpoint.ts +125 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +204 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +278 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopTicketsEndpoint.ts +80 -0
- package/src/endpoints/organization/dashboard/webshops/VerifyWebshopDomainEndpoint.ts +60 -0
- package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +379 -0
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +54 -0
- package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +45 -0
- package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +78 -0
- package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.ts +34 -0
- package/src/endpoints/organization/shared/auth/OpenIDConnectCallbackEndpoint.ts +44 -0
- package/src/endpoints/organization/shared/auth/OpenIDConnectStartEndpoint.ts +82 -0
- package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +59 -0
- package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +51 -0
- package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +40 -0
- package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +124 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +130 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +50 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +450 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +335 -0
- package/src/helpers/AddressValidator.test.ts +40 -0
- package/src/helpers/AddressValidator.ts +256 -0
- package/src/helpers/AdminPermissionChecker.ts +1031 -0
- package/src/helpers/AuthenticatedStructures.ts +158 -0
- package/src/helpers/BuckarooHelper.ts +279 -0
- package/src/helpers/CheckSettlements.ts +215 -0
- package/src/helpers/Context.ts +202 -0
- package/src/helpers/CookieHelper.ts +45 -0
- package/src/helpers/ForwardHandler.test.ts +216 -0
- package/src/helpers/ForwardHandler.ts +140 -0
- package/src/helpers/OpenIDConnectHelper.ts +284 -0
- package/src/helpers/StripeHelper.ts +293 -0
- package/src/helpers/StripePayoutChecker.ts +188 -0
- package/src/middleware/ContextMiddleware.ts +16 -0
- package/src/migrations/1646578856-validate-addresses.ts +60 -0
- package/src/seeds/0000000000-example.ts +13 -0
- package/src/seeds/1715028563-user-permissions.ts +52 -0
- package/tests/e2e/stock.test.ts +2120 -0
- package/tests/e2e/tickets.test.ts +926 -0
- package/tests/helpers/StripeMocker.ts +362 -0
- package/tests/helpers/TestServer.ts +21 -0
- package/tests/jest.global.setup.ts +29 -0
- package/tests/jest.setup.ts +59 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { DecodedRequest, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
+
import { isSimpleError, isSimpleErrors, SimpleError } from "@simonbackx/simple-errors";
|
|
3
|
+
import { Organization, Token, User } from "@stamhoofd/models";
|
|
4
|
+
import { LoginProviderType, OpenIDClientConfiguration, Token as TokenStruct } from "@stamhoofd/structures";
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import { generators, Issuer } from 'openid-client';
|
|
7
|
+
|
|
8
|
+
import { CookieHelper } from "./CookieHelper";
|
|
9
|
+
|
|
10
|
+
async function randomBytes(size: number): Promise<Buffer> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
reject(err);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
resolve(buf);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type SessionContext = {
|
|
23
|
+
expires: Date,
|
|
24
|
+
code_verifier: string,
|
|
25
|
+
state: string,
|
|
26
|
+
nonce: string
|
|
27
|
+
redirectUri: string,
|
|
28
|
+
spaState: string,
|
|
29
|
+
providerType: LoginProviderType
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class OpenIDConnectHelper {
|
|
33
|
+
organization: Organization
|
|
34
|
+
configuration: OpenIDClientConfiguration
|
|
35
|
+
|
|
36
|
+
static sessionStorage = new Map<string, SessionContext>()
|
|
37
|
+
|
|
38
|
+
constructor(organization, configuration: OpenIDClientConfiguration) {
|
|
39
|
+
this.organization = organization
|
|
40
|
+
this.configuration = configuration
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get redirectUri() {
|
|
44
|
+
return 'https://' + this.organization.id + '.' + STAMHOOFD.domains.api + '/openid/callback'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getClient() {
|
|
48
|
+
const issuer = await Issuer.discover(this.configuration.issuer);
|
|
49
|
+
const client = new issuer.Client({
|
|
50
|
+
client_id: this.configuration.clientId,
|
|
51
|
+
client_secret: this.configuration.clientSecret,
|
|
52
|
+
redirect_uris: [this.redirectUri],
|
|
53
|
+
response_types: ['code'],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Todo: in the future we can add a cache here
|
|
57
|
+
|
|
58
|
+
return client;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static async storeSession(response: Response<any>, data: SessionContext) {
|
|
62
|
+
const sessionId = (await randomBytes(192)).toString("base64");
|
|
63
|
+
|
|
64
|
+
// Delete expired sessions
|
|
65
|
+
for (const [key, value] of this.sessionStorage) {
|
|
66
|
+
if (value.expires < new Date()) {
|
|
67
|
+
this.sessionStorage.delete(key)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.sessionStorage.set(sessionId, data);
|
|
72
|
+
|
|
73
|
+
// Store
|
|
74
|
+
CookieHelper.setCookie(response, "oid_session_id", sessionId, {
|
|
75
|
+
httpOnly: true,
|
|
76
|
+
secure: true,
|
|
77
|
+
expires: data.expires
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static getSession(request: DecodedRequest<any, any, any>): SessionContext | null {
|
|
82
|
+
const sessionId = CookieHelper.getCookie(request, "oid_session_id")
|
|
83
|
+
if (!sessionId) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const session = this.sessionStorage.get(sessionId)
|
|
88
|
+
if (!session) {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (session.expires < new Date()) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return session
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async startAuthCodeFlow(redirectUri: string, providerType: LoginProviderType, spaState: string, prompt: string | null = null): Promise<Response<undefined>> {
|
|
100
|
+
const code_verifier = generators.codeVerifier();
|
|
101
|
+
const state = generators.state();
|
|
102
|
+
const nonce = generators.nonce();
|
|
103
|
+
const code_challenge = generators.codeChallenge(code_verifier);
|
|
104
|
+
const expires = new Date(Date.now() + 1000 * 60 * 15);
|
|
105
|
+
|
|
106
|
+
const session: SessionContext = {
|
|
107
|
+
expires,
|
|
108
|
+
code_verifier,
|
|
109
|
+
state,
|
|
110
|
+
nonce,
|
|
111
|
+
redirectUri,
|
|
112
|
+
spaState,
|
|
113
|
+
providerType
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const response = new Response(undefined);
|
|
118
|
+
|
|
119
|
+
const client = await this.getClient()
|
|
120
|
+
await OpenIDConnectHelper.storeSession(response, session);
|
|
121
|
+
|
|
122
|
+
const redirect = client.authorizationUrl({
|
|
123
|
+
scope: 'openid email profile',
|
|
124
|
+
code_challenge,
|
|
125
|
+
code_challenge_method: 'S256',
|
|
126
|
+
response_mode: 'form_post',
|
|
127
|
+
response_type: 'code',
|
|
128
|
+
state,
|
|
129
|
+
nonce,
|
|
130
|
+
prompt: prompt ?? undefined
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
response.headers['location'] = redirect;
|
|
134
|
+
response.status = 302;
|
|
135
|
+
|
|
136
|
+
return response;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.')
|
|
139
|
+
console.error('Error in openID callback', e)
|
|
140
|
+
return OpenIDConnectHelper.getErrorRedirectResponse(session, message)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async callback(request: DecodedRequest<any, any, any>): Promise<Response<undefined>> {
|
|
145
|
+
const session = OpenIDConnectHelper.getSession(request)
|
|
146
|
+
|
|
147
|
+
if (!session) {
|
|
148
|
+
throw new Error("Missing session")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = new Response(undefined);
|
|
153
|
+
const client = await this.getClient()
|
|
154
|
+
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
156
|
+
const tokenSet = await client.callback(this.redirectUri, request.body, {
|
|
157
|
+
code_verifier: session.code_verifier,
|
|
158
|
+
state: session.state,
|
|
159
|
+
nonce: session.nonce
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
console.log('received and validated tokens %j', tokenSet);
|
|
163
|
+
|
|
164
|
+
const claims = tokenSet.claims();
|
|
165
|
+
console.log('validated ID Token claims %j', claims);
|
|
166
|
+
|
|
167
|
+
if (!claims.name) {
|
|
168
|
+
throw new SimpleError({
|
|
169
|
+
code: 'invalid_user',
|
|
170
|
+
message: "Missing name",
|
|
171
|
+
statusCode: 400
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let firstName = claims.name.split(" ")[0]
|
|
176
|
+
let lastName = claims.name.split(" ").slice(1).join(" ")
|
|
177
|
+
|
|
178
|
+
// Get from API
|
|
179
|
+
if (tokenSet.access_token) {
|
|
180
|
+
const userinfo = await client.userinfo(tokenSet.access_token);
|
|
181
|
+
console.log('userinfo', userinfo);
|
|
182
|
+
|
|
183
|
+
if (userinfo.given_name) {
|
|
184
|
+
console.log('userinfo given_name', userinfo.given_name);
|
|
185
|
+
firstName = userinfo.given_name
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (userinfo.family_name) {
|
|
189
|
+
console.log('userinfo family_name', userinfo.family_name);
|
|
190
|
+
lastName = userinfo.family_name
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!claims.email) {
|
|
195
|
+
throw new SimpleError({
|
|
196
|
+
code: 'invalid_user',
|
|
197
|
+
message: "Missing email address",
|
|
198
|
+
statusCode: 400
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!claims.sub) {
|
|
203
|
+
throw new SimpleError({
|
|
204
|
+
code: 'invalid_user',
|
|
205
|
+
message: "Missing sub",
|
|
206
|
+
statusCode: 400
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Get user from database
|
|
211
|
+
let user = await User.getOrganizationLevelUser(this.organization.id, claims.email)
|
|
212
|
+
if (!user) {
|
|
213
|
+
// Create a new user
|
|
214
|
+
user = await User.registerSSO(this.organization, {
|
|
215
|
+
id: undefined,
|
|
216
|
+
email: claims.email,
|
|
217
|
+
firstName,
|
|
218
|
+
lastName,
|
|
219
|
+
type: session.providerType,
|
|
220
|
+
sub: claims.sub,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if (!user) {
|
|
224
|
+
throw new SimpleError({
|
|
225
|
+
code: 'invalid_user',
|
|
226
|
+
message: "Failed to create user",
|
|
227
|
+
statusCode: 500
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
// Update name
|
|
232
|
+
if (!user.firstName || !user.hasPasswordBasedAccount()) {
|
|
233
|
+
user.firstName = firstName
|
|
234
|
+
}
|
|
235
|
+
if (!user.lastName || !user.hasPasswordBasedAccount()) {
|
|
236
|
+
user.lastName = lastName
|
|
237
|
+
}
|
|
238
|
+
user.linkLoginProvider(session.providerType, claims.sub)
|
|
239
|
+
await user.save()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const token = await Token.createExpiredToken(user);
|
|
243
|
+
|
|
244
|
+
if (!token) {
|
|
245
|
+
throw new SimpleError({
|
|
246
|
+
code: "error",
|
|
247
|
+
message: "Could not generate token",
|
|
248
|
+
human: "Er ging iets mis bij het aanmelden",
|
|
249
|
+
statusCode: 500
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const st = new TokenStruct(token);
|
|
254
|
+
|
|
255
|
+
// Redirect back to webshop
|
|
256
|
+
const redirectUri = new URL(session.redirectUri)
|
|
257
|
+
redirectUri.searchParams.set("oid_rt", st.refreshToken)
|
|
258
|
+
redirectUri.searchParams.set("s", session.spaState)
|
|
259
|
+
|
|
260
|
+
response.headers['location'] = redirectUri.toString();
|
|
261
|
+
response.status = 302;
|
|
262
|
+
|
|
263
|
+
return response;
|
|
264
|
+
} catch (e) {
|
|
265
|
+
const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.')
|
|
266
|
+
console.error('Error in openID callback', e)
|
|
267
|
+
return OpenIDConnectHelper.getErrorRedirectResponse(session, message)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
static getErrorRedirectResponse(session: SessionContext, errorMessage: string) {
|
|
272
|
+
const response = new Response(undefined);
|
|
273
|
+
|
|
274
|
+
// Redirect back to webshop
|
|
275
|
+
const redirectUri = new URL(session.redirectUri)
|
|
276
|
+
redirectUri.searchParams.set("s", session.spaState)
|
|
277
|
+
redirectUri.searchParams.set("error", errorMessage)
|
|
278
|
+
|
|
279
|
+
response.headers['location'] = redirectUri.toString();
|
|
280
|
+
response.status = 302;
|
|
281
|
+
|
|
282
|
+
return response;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { I18n } from '@stamhoofd/backend-i18n';
|
|
3
|
+
import { BalanceItem, BalanceItemPayment, Organization, Payment, StripeAccount, StripeCheckoutSession, StripePaymentIntent } from '@stamhoofd/models';
|
|
4
|
+
import { calculateVATPercentage, PaymentMethod, PaymentMethodHelper, PaymentStatus } from '@stamhoofd/structures';
|
|
5
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
+
import Stripe from 'stripe';
|
|
7
|
+
|
|
8
|
+
export class StripeHelper {
|
|
9
|
+
static getInstance() {
|
|
10
|
+
return new Stripe(STAMHOOFD.STRIPE_SECRET_KEY, {apiVersion: '2022-11-15', typescript: true, maxNetworkRetries: 0, timeout: 10000});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static async getStatus(payment: Payment, cancel = false, testMode = false): Promise<PaymentStatus> {
|
|
14
|
+
if (testMode && !STAMHOOFD.STRIPE_SECRET_KEY.startsWith("sk_test_")) {
|
|
15
|
+
// Do not query anything
|
|
16
|
+
return payment.status
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const [model] = await StripePaymentIntent.where({paymentId: payment.id}, {limit: 1})
|
|
20
|
+
|
|
21
|
+
if (!model) {
|
|
22
|
+
return await this.getStatusFromCheckoutSession(payment, cancel)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stripe = this.getInstance()
|
|
26
|
+
|
|
27
|
+
let intent = await stripe.paymentIntents.retrieve(model.stripeIntentId)
|
|
28
|
+
console.log(intent);
|
|
29
|
+
if (intent.status === "succeeded") {
|
|
30
|
+
if (intent.latest_charge) {
|
|
31
|
+
try {
|
|
32
|
+
const charge = await stripe.charges.retrieve(typeof intent.latest_charge === 'string' ? intent.latest_charge : intent.latest_charge.id)
|
|
33
|
+
if (charge.payment_method_details?.bancontact) {
|
|
34
|
+
if (charge.payment_method_details.bancontact.iban_last4) {
|
|
35
|
+
payment.iban = "xxxx " + charge.payment_method_details.bancontact.iban_last4
|
|
36
|
+
}
|
|
37
|
+
payment.ibanName = charge.payment_method_details.bancontact.verified_name
|
|
38
|
+
await payment.save()
|
|
39
|
+
}
|
|
40
|
+
if (charge.payment_method_details?.ideal) {
|
|
41
|
+
if (charge.payment_method_details.ideal.iban_last4) {
|
|
42
|
+
payment.iban = "xxxx " + charge.payment_method_details.ideal.iban_last4
|
|
43
|
+
}
|
|
44
|
+
payment.ibanName = charge.payment_method_details.ideal.verified_name
|
|
45
|
+
await payment.save()
|
|
46
|
+
}
|
|
47
|
+
if (charge.payment_method_details?.card) {
|
|
48
|
+
if (charge.payment_method_details.card.last4) {
|
|
49
|
+
payment.iban = "xxxx " + charge.payment_method_details.card.last4
|
|
50
|
+
}
|
|
51
|
+
await payment.save()
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('Failed fatching charge', e)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return PaymentStatus.Succeeded
|
|
58
|
+
}
|
|
59
|
+
if (intent.status === "canceled" || intent.status === "requires_payment_method") {
|
|
60
|
+
// For Bnaconctact/iDEAL the payment status is reverted to requires_payment_method when the user cancels the payment
|
|
61
|
+
// Don't ask me why...
|
|
62
|
+
return PaymentStatus.Failed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cancel) {
|
|
66
|
+
try {
|
|
67
|
+
// Cancel the intent
|
|
68
|
+
console.log('Cancelling payment intent')
|
|
69
|
+
intent = await stripe.paymentIntents.cancel(model.stripeIntentId)
|
|
70
|
+
console.log('Cancelled payment intent', intent)
|
|
71
|
+
|
|
72
|
+
if (intent.status === "succeeded") {
|
|
73
|
+
return PaymentStatus.Succeeded
|
|
74
|
+
}
|
|
75
|
+
if (intent.status === "canceled" || intent.status === "requires_payment_method") {
|
|
76
|
+
return PaymentStatus.Failed
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error('Error cancelling payment intent', e)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (intent.status === "processing") {
|
|
84
|
+
return PaymentStatus.Pending
|
|
85
|
+
}
|
|
86
|
+
return PaymentStatus.Created
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static async getStatusFromCheckoutSession(payment: Payment, cancel = false): Promise<PaymentStatus> {
|
|
90
|
+
const [model] = await StripeCheckoutSession.where({paymentId: payment.id}, {limit: 1})
|
|
91
|
+
|
|
92
|
+
if (!model) {
|
|
93
|
+
return PaymentStatus.Failed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stripe = this.getInstance()
|
|
97
|
+
const session = await stripe.checkout.sessions.retrieve(model.stripeSessionId)
|
|
98
|
+
console.log("session", session);
|
|
99
|
+
if (session.status === "complete") {
|
|
100
|
+
return PaymentStatus.Succeeded
|
|
101
|
+
}
|
|
102
|
+
if (session.status === "expired") {
|
|
103
|
+
return PaymentStatus.Failed
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cancel) {
|
|
107
|
+
// Cancel the session
|
|
108
|
+
const session = await stripe.checkout.sessions.expire(model.stripeSessionId)
|
|
109
|
+
|
|
110
|
+
if (session.status === "complete") {
|
|
111
|
+
return PaymentStatus.Succeeded
|
|
112
|
+
}
|
|
113
|
+
if (session.status === "expired") {
|
|
114
|
+
return PaymentStatus.Failed
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return PaymentStatus.Created
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static async createPayment(
|
|
122
|
+
{payment, stripeAccount, redirectUrl, cancelUrl, customer, statementDescriptor, i18n, metadata, organization, lineItems}: {
|
|
123
|
+
payment: Payment,
|
|
124
|
+
stripeAccount: StripeAccount | null,
|
|
125
|
+
redirectUrl: string,
|
|
126
|
+
cancelUrl: string,
|
|
127
|
+
customer: {
|
|
128
|
+
name: string,
|
|
129
|
+
email: string,
|
|
130
|
+
},
|
|
131
|
+
statementDescriptor: string,
|
|
132
|
+
i18n: I18n,
|
|
133
|
+
metadata: {[key: string]: string},
|
|
134
|
+
organization: Organization,
|
|
135
|
+
lineItems: (BalanceItemPayment & {balanceItem: BalanceItem})[],
|
|
136
|
+
}
|
|
137
|
+
): Promise<{paymentUrl: string}> {
|
|
138
|
+
if (!stripeAccount) {
|
|
139
|
+
throw new SimpleError({
|
|
140
|
+
code: "",
|
|
141
|
+
message: "Betaling via " + PaymentMethodHelper.getName(payment.method) + " is onbeschikbaar"
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const totalPrice = payment.price;
|
|
146
|
+
|
|
147
|
+
let fee = 0;
|
|
148
|
+
const vat = calculateVATPercentage(organization.address, organization.meta.VATNumber)
|
|
149
|
+
function calculateFee(fixed: number, percentageTimes100: number) {
|
|
150
|
+
return Math.round(Math.round(fixed + Math.max(1, totalPrice * percentageTimes100 / 100 / 100)) * (100 + vat) / 100); // € 0,21 + 0,2%
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (payment.method === PaymentMethod.iDEAL) {
|
|
154
|
+
fee = calculateFee(21, 20); // € 0,21 + 0,2%
|
|
155
|
+
} else if (payment.method === PaymentMethod.Bancontact) {
|
|
156
|
+
fee = calculateFee(24, 20); // € 0,24 + 0,2%
|
|
157
|
+
} else {
|
|
158
|
+
fee = calculateFee(25, 150); // € 0,25 + 1,5%
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
payment.transferFee = fee;
|
|
162
|
+
|
|
163
|
+
const fullMetadata = {
|
|
164
|
+
...(metadata ?? {}),
|
|
165
|
+
organizationVATNumber: organization.meta.VATNumber
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const stripe = StripeHelper.getInstance()
|
|
169
|
+
let paymentUrl: string
|
|
170
|
+
|
|
171
|
+
// Bancontact or iDEAL: use payment intends
|
|
172
|
+
if (payment.method === PaymentMethod.Bancontact || payment.method === PaymentMethod.iDEAL) {
|
|
173
|
+
const paymentMethod = await stripe.paymentMethods.create({
|
|
174
|
+
type: payment.method.toLowerCase() as 'bancontact',
|
|
175
|
+
billing_details: {
|
|
176
|
+
name: customer.name && customer.name.length > 2 ? customer.name : 'Onbekend',
|
|
177
|
+
email: customer.email
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const paymentIntent = await stripe.paymentIntents.create({
|
|
182
|
+
amount: totalPrice,
|
|
183
|
+
currency: 'eur',
|
|
184
|
+
payment_method: paymentMethod.id,
|
|
185
|
+
payment_method_types: [payment.method.toLowerCase()],
|
|
186
|
+
statement_descriptor: Formatter.slug(statementDescriptor).substring(0, 22).toUpperCase(),
|
|
187
|
+
application_fee_amount: fee,
|
|
188
|
+
on_behalf_of: stripeAccount.accountId,
|
|
189
|
+
confirm: true,
|
|
190
|
+
return_url: redirectUrl,
|
|
191
|
+
transfer_data: {
|
|
192
|
+
destination: stripeAccount.accountId,
|
|
193
|
+
},
|
|
194
|
+
metadata: fullMetadata,
|
|
195
|
+
payment_method_options: {bancontact: {preferred_language: ['nl', 'fr', 'de', 'en'].includes(i18n.language) ? i18n.language as 'en' : 'nl'}},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
console.log("Stripe payment intent", paymentIntent)
|
|
199
|
+
const url = paymentIntent.next_action?.redirect_to_url?.url
|
|
200
|
+
|
|
201
|
+
if (paymentIntent.status !== 'requires_action' || !url) {
|
|
202
|
+
console.error("Stripe payment intent status is not requires_action", paymentIntent)
|
|
203
|
+
throw new SimpleError({
|
|
204
|
+
code: "invalid_status",
|
|
205
|
+
message: "Betaling via " + PaymentMethodHelper.getName(payment.method) + " is onbeschikbaar"
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
paymentUrl = url
|
|
210
|
+
|
|
211
|
+
// Store in database
|
|
212
|
+
const paymentIntentModel = new StripePaymentIntent()
|
|
213
|
+
paymentIntentModel.paymentId = payment.id
|
|
214
|
+
paymentIntentModel.stripeIntentId = paymentIntent.id
|
|
215
|
+
paymentIntentModel.organizationId = organization.id
|
|
216
|
+
await paymentIntentModel.save()
|
|
217
|
+
} else {
|
|
218
|
+
// Build Stripe line items
|
|
219
|
+
const stripeLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = []
|
|
220
|
+
let lineItemsPrice = 0
|
|
221
|
+
for (const item of lineItems) {
|
|
222
|
+
const stripeLineItem = {
|
|
223
|
+
price_data: {
|
|
224
|
+
currency: 'eur',
|
|
225
|
+
unit_amount: item.price,
|
|
226
|
+
product_data: {
|
|
227
|
+
name: item.balanceItem.description,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
quantity: 1,
|
|
231
|
+
}
|
|
232
|
+
stripeLineItems.push(stripeLineItem)
|
|
233
|
+
lineItemsPrice += item.price
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (lineItemsPrice !== totalPrice) {
|
|
237
|
+
console.error('Total price of line items does not match total price of payment', lineItemsPrice, totalPrice, payment.id)
|
|
238
|
+
throw new SimpleError({
|
|
239
|
+
code: "invalid_price",
|
|
240
|
+
message: "De totale prijs van de betaling komt niet overeen met de prijs van de items",
|
|
241
|
+
human: "Er ging iets mis bij het aanmaken van de betaling. Probeer opnieuw of gebruik een andere betaalmethode.",
|
|
242
|
+
statusCode: 500
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Use checkout flow
|
|
247
|
+
const session = await stripe.checkout.sessions.create({
|
|
248
|
+
mode: 'payment',
|
|
249
|
+
success_url: redirectUrl,
|
|
250
|
+
cancel_url: cancelUrl,
|
|
251
|
+
payment_method_types: ["card"],
|
|
252
|
+
line_items: stripeLineItems,
|
|
253
|
+
currency: 'eur',
|
|
254
|
+
locale: i18n.language as 'nl',
|
|
255
|
+
payment_intent_data: {
|
|
256
|
+
on_behalf_of: stripeAccount.accountId,
|
|
257
|
+
application_fee_amount: fee,
|
|
258
|
+
transfer_data: {
|
|
259
|
+
destination: stripeAccount.accountId,
|
|
260
|
+
},
|
|
261
|
+
metadata: fullMetadata,
|
|
262
|
+
statement_descriptor: Formatter.slug(statementDescriptor).substring(0, 22).toUpperCase(),
|
|
263
|
+
},
|
|
264
|
+
customer_email: customer.email,
|
|
265
|
+
metadata: fullMetadata,
|
|
266
|
+
expires_at: Math.floor(Date.now() / 1000) + 30 * 60, // Expire in 30 minutes
|
|
267
|
+
});
|
|
268
|
+
console.log("Stripe session", session)
|
|
269
|
+
|
|
270
|
+
if (!session.url) {
|
|
271
|
+
console.error("Stripe session has no url", session)
|
|
272
|
+
throw new SimpleError({
|
|
273
|
+
code: "invalid_status",
|
|
274
|
+
message: "Betaling via " + PaymentMethodHelper.getName(payment.method) + " is onbeschikbaar"
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
paymentUrl = session.url
|
|
278
|
+
|
|
279
|
+
// Store in database
|
|
280
|
+
const paymentIntentModel = new StripeCheckoutSession()
|
|
281
|
+
paymentIntentModel.paymentId = payment.id
|
|
282
|
+
paymentIntentModel.stripeSessionId = session.id
|
|
283
|
+
paymentIntentModel.organizationId = organization.id
|
|
284
|
+
await paymentIntentModel.save()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await payment.save()
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
paymentUrl
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|