@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.
Files changed (150) hide show
  1. package/.env.template.json +63 -0
  2. package/.eslintrc.js +61 -0
  3. package/README.md +40 -0
  4. package/index.ts +172 -0
  5. package/jest.config.js +11 -0
  6. package/migrations.ts +33 -0
  7. package/package.json +48 -0
  8. package/src/crons.ts +845 -0
  9. package/src/endpoints/admin/organizations/GetOrganizationsCountEndpoint.ts +42 -0
  10. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +320 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +171 -0
  12. package/src/endpoints/auth/CreateAdminEndpoint.ts +137 -0
  13. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +68 -0
  14. package/src/endpoints/auth/CreateTokenEndpoint.ts +200 -0
  15. package/src/endpoints/auth/DeleteTokenEndpoint.ts +31 -0
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +70 -0
  17. package/src/endpoints/auth/GetUserEndpoint.test.ts +64 -0
  18. package/src/endpoints/auth/GetUserEndpoint.ts +57 -0
  19. package/src/endpoints/auth/PatchApiUserEndpoint.ts +90 -0
  20. package/src/endpoints/auth/PatchUserEndpoint.ts +122 -0
  21. package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +37 -0
  22. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +41 -0
  23. package/src/endpoints/auth/SignupEndpoint.ts +107 -0
  24. package/src/endpoints/auth/VerifyEmailEndpoint.ts +89 -0
  25. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +95 -0
  26. package/src/endpoints/global/addresses/ValidateAddressEndpoint.ts +31 -0
  27. package/src/endpoints/global/caddy/CheckDomainCertEndpoint.ts +101 -0
  28. package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +53 -0
  29. package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +57 -0
  30. package/src/endpoints/global/files/UploadFile.ts +147 -0
  31. package/src/endpoints/global/files/UploadImage.ts +119 -0
  32. package/src/endpoints/global/members/GetMemberFamilyEndpoint.ts +76 -0
  33. package/src/endpoints/global/members/GetMembersCountEndpoint.ts +43 -0
  34. package/src/endpoints/global/members/GetMembersEndpoint.ts +429 -0
  35. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +734 -0
  36. package/src/endpoints/global/organizations/CheckRegisterCodeEndpoint.ts +45 -0
  37. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.test.ts +105 -0
  38. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +146 -0
  39. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.test.ts +52 -0
  40. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.ts +80 -0
  41. package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +49 -0
  42. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.test.ts +58 -0
  43. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +62 -0
  44. package/src/endpoints/global/payments/ExchangeSTPaymentEndpoint.ts +153 -0
  45. package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +134 -0
  46. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +44 -0
  47. package/src/endpoints/global/platform/GetPlatformEnpoint.ts +39 -0
  48. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +63 -0
  49. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +68 -0
  50. package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +39 -0
  51. package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +80 -0
  52. package/src/endpoints/global/registration/GetUserMembersEndpoint.ts +41 -0
  53. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +134 -0
  54. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +521 -0
  55. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +37 -0
  56. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +115 -0
  57. package/src/endpoints/global/webshops/GetWebshopFromDomainEndpoint.ts +187 -0
  58. package/src/endpoints/organization/dashboard/billing/ActivatePackagesEndpoint.ts +424 -0
  59. package/src/endpoints/organization/dashboard/billing/DeactivatePackageEndpoint.ts +67 -0
  60. package/src/endpoints/organization/dashboard/billing/GetBillingStatusEndpoint.ts +39 -0
  61. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +57 -0
  62. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +50 -0
  63. package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +50 -0
  64. package/src/endpoints/organization/dashboard/documents/PatchDocumentEndpoint.ts +129 -0
  65. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplateEndpoint.ts +114 -0
  66. package/src/endpoints/organization/dashboard/email/CheckEmailBouncesEndpoint.ts +50 -0
  67. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +234 -0
  68. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +62 -0
  69. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +85 -0
  70. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +80 -0
  71. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +54 -0
  72. package/src/endpoints/organization/dashboard/mollie/DisconnectMollieEndpoint.ts +49 -0
  73. package/src/endpoints/organization/dashboard/mollie/GetMollieDashboardEndpoint.ts +63 -0
  74. package/src/endpoints/organization/dashboard/nolt/CreateNoltTokenEndpoint.ts +61 -0
  75. package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.test.ts +64 -0
  76. package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.ts +84 -0
  77. package/src/endpoints/organization/dashboard/organization/GetOrganizationArchivedGroups.ts +43 -0
  78. package/src/endpoints/organization/dashboard/organization/GetOrganizationDeletedGroups.ts +42 -0
  79. package/src/endpoints/organization/dashboard/organization/GetOrganizationSSOEndpoint.ts +43 -0
  80. package/src/endpoints/organization/dashboard/organization/GetRegisterCodeEndpoint.ts +65 -0
  81. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +281 -0
  82. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +338 -0
  83. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +196 -0
  84. package/src/endpoints/organization/dashboard/organization/SetOrganizationSSOEndpoint.ts +50 -0
  85. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +48 -0
  86. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +207 -0
  87. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +202 -0
  88. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +233 -0
  89. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +66 -0
  90. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +210 -0
  91. package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +93 -0
  92. package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +59 -0
  93. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountLinkEndpoint.ts +78 -0
  94. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountsEndpoint.ts +40 -0
  95. package/src/endpoints/organization/dashboard/stripe/GetStripeLoginLinkEndpoint.ts +69 -0
  96. package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +52 -0
  97. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +73 -0
  98. package/src/endpoints/organization/dashboard/users/DeleteUserEndpoint.ts +60 -0
  99. package/src/endpoints/organization/dashboard/users/GetApiUsersEndpoint.ts +47 -0
  100. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +41 -0
  101. package/src/endpoints/organization/dashboard/webshops/CreateWebshopEndpoint.ts +217 -0
  102. package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +51 -0
  103. package/src/endpoints/organization/dashboard/webshops/GetDiscountCodesEndpoint.ts +47 -0
  104. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +83 -0
  105. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +68 -0
  106. package/src/endpoints/organization/dashboard/webshops/GetWebshopUriAvailabilityEndpoint.ts +69 -0
  107. package/src/endpoints/organization/dashboard/webshops/PatchDiscountCodesEndpoint.ts +125 -0
  108. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +204 -0
  109. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +278 -0
  110. package/src/endpoints/organization/dashboard/webshops/PatchWebshopTicketsEndpoint.ts +80 -0
  111. package/src/endpoints/organization/dashboard/webshops/VerifyWebshopDomainEndpoint.ts +60 -0
  112. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +379 -0
  113. package/src/endpoints/organization/shared/GetDocumentHtml.ts +54 -0
  114. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +45 -0
  115. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +78 -0
  116. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.ts +34 -0
  117. package/src/endpoints/organization/shared/auth/OpenIDConnectCallbackEndpoint.ts +44 -0
  118. package/src/endpoints/organization/shared/auth/OpenIDConnectStartEndpoint.ts +82 -0
  119. package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +59 -0
  120. package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +51 -0
  121. package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +40 -0
  122. package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +124 -0
  123. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +130 -0
  124. package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +50 -0
  125. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +450 -0
  126. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +335 -0
  127. package/src/helpers/AddressValidator.test.ts +40 -0
  128. package/src/helpers/AddressValidator.ts +256 -0
  129. package/src/helpers/AdminPermissionChecker.ts +1031 -0
  130. package/src/helpers/AuthenticatedStructures.ts +158 -0
  131. package/src/helpers/BuckarooHelper.ts +279 -0
  132. package/src/helpers/CheckSettlements.ts +215 -0
  133. package/src/helpers/Context.ts +202 -0
  134. package/src/helpers/CookieHelper.ts +45 -0
  135. package/src/helpers/ForwardHandler.test.ts +216 -0
  136. package/src/helpers/ForwardHandler.ts +140 -0
  137. package/src/helpers/OpenIDConnectHelper.ts +284 -0
  138. package/src/helpers/StripeHelper.ts +293 -0
  139. package/src/helpers/StripePayoutChecker.ts +188 -0
  140. package/src/middleware/ContextMiddleware.ts +16 -0
  141. package/src/migrations/1646578856-validate-addresses.ts +60 -0
  142. package/src/seeds/0000000000-example.ts +13 -0
  143. package/src/seeds/1715028563-user-permissions.ts +52 -0
  144. package/tests/e2e/stock.test.ts +2120 -0
  145. package/tests/e2e/tickets.test.ts +926 -0
  146. package/tests/helpers/StripeMocker.ts +362 -0
  147. package/tests/helpers/TestServer.ts +21 -0
  148. package/tests/jest.global.setup.ts +29 -0
  149. package/tests/jest.setup.ts +59 -0
  150. 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
+ }