@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,335 @@
1
+ import { createMollieClient, PaymentMethod as molliePaymentMethod } from '@mollie/api-client';
2
+ import { Decoder } from '@simonbackx/simple-encoding';
3
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
4
+ import { SimpleError } from '@simonbackx/simple-errors';
5
+ import { I18n } from '@stamhoofd/backend-i18n';
6
+ import { Email } from '@stamhoofd/email';
7
+ import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Order, PayconiqPayment, Payment, RateLimiter, Webshop, WebshopDiscountCode } from '@stamhoofd/models';
8
+ import { QueueHandler } from '@stamhoofd/queues';
9
+ import { BalanceItemStatus, Order as OrderStruct, OrderData, OrderResponse, Payment as PaymentStruct, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Version, Webshop as WebshopStruct,WebshopAuthType } from "@stamhoofd/structures";
10
+ import { Formatter } from '@stamhoofd/utility';
11
+
12
+ import { BuckarooHelper } from '../../../helpers/BuckarooHelper';
13
+ import { Context } from '../../../helpers/Context';
14
+ import { StripeHelper } from '../../../helpers/StripeHelper';
15
+
16
+ type Params = { id: string };
17
+ type Query = undefined;
18
+ type Body = OrderData
19
+ type ResponseBody = OrderResponse
20
+
21
+ export const demoOrderLimiter = new RateLimiter({
22
+ limits: [
23
+ {
24
+ // Max 10 per hour
25
+ limit: STAMHOOFD.environment === 'development' ? 100 : 10,
26
+ duration: 60 * 1000 * 60
27
+ },
28
+ {
29
+ // Max 20 a day
30
+ limit: STAMHOOFD.environment === 'development' ? 1000 : 20,
31
+ duration: 24 * 60 * 1000 * 60
32
+ }
33
+ ]
34
+ });
35
+
36
+ /**
37
+ * Allow to add, patch and delete multiple members simultaneously, which is needed in order to sync relational data that is saved encrypted in multiple members (e.g. parents)
38
+ */
39
+ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
40
+ bodyDecoder = OrderData as Decoder<OrderData>
41
+
42
+ protected doesMatch(request: Request): [true, Params] | [false] {
43
+ if (request.method != "POST") {
44
+ return [false];
45
+ }
46
+
47
+ const params = Endpoint.parseParameters(request.url, "/webshop/@id/order", { id: String });
48
+
49
+ if (params) {
50
+ return [true, params as Params];
51
+ }
52
+ return [false];
53
+ }
54
+
55
+ async handle(request: DecodedRequest<Params, Query, Body>) {
56
+ const organization = await Context.setOrganizationScope();
57
+ await Context.optionalAuthenticate()
58
+
59
+ // Read + validate + update stock in one go, to prevent race conditions
60
+ const { webshop, order } = await QueueHandler.schedule("webshop-stock/"+request.params.id, async () => {
61
+ const webshopWithoutOrganization = await Webshop.getByID(request.params.id)
62
+ if (!webshopWithoutOrganization || webshopWithoutOrganization.organizationId !== organization.id) {
63
+ throw new SimpleError({
64
+ code: "not_found",
65
+ message: "Webshop not found",
66
+ human: "Deze webshop bestaat niet (meer)"
67
+ })
68
+ }
69
+
70
+ //const organization = (await Organization.getByID(webshopWithoutOrganization.organizationId))!
71
+ const webshop = webshopWithoutOrganization.setRelation(Webshop.organization, organization)
72
+
73
+ if (webshop.meta.authType === WebshopAuthType.Required && !Context.user) {
74
+ throw new SimpleError({
75
+ code: "not_authenticated",
76
+ message: "Not authenticated",
77
+ human: "Je moet inloggen om een bestelling te kunnen plaatsen.",
78
+ statusCode: 401
79
+ })
80
+ }
81
+
82
+ // For non paid organizations, the limit is 10
83
+ if (!organization.meta.packages.isPaid && STAMHOOFD.environment !== 'test') {
84
+ const limiter = demoOrderLimiter
85
+
86
+ try {
87
+ limiter.track(organization.id, 1);
88
+ } catch (e) {
89
+ Email.sendInternal({
90
+ to: "hallo@stamhoofd.be",
91
+ subject: "[Limiet] Limiet bereikt voor aantal bestellingen",
92
+ text: "Beste, \nDe limiet werd bereikt voor het aantal bestellingen per dag. \nVereniging: "+organization.id+" ("+organization.name+")" + "\n\n" + e.message + "\n\nStamhoofd"
93
+ }, new I18n("nl", "BE"))
94
+
95
+ throw new SimpleError({
96
+ code: "too_many_emails_period",
97
+ message: "Too many e-mails limited",
98
+ human: "Oeps! Om spam te voorkomen limiteren we het aantal test bestellingen die je per uur of dag kan plaatsen. Probeer over een uur opnieuw of schakel over naar een betaald abonnement.",
99
+ field: "recipients"
100
+ })
101
+ }
102
+ }
103
+
104
+ const webshopStruct = WebshopStruct.create(webshop)
105
+
106
+ const usedCodes = request.body.discountCodes.map(c => c.code)
107
+ const uniqueCodes = Formatter.uniqueArray(usedCodes);
108
+ if (uniqueCodes.length !== usedCodes.length) {
109
+ // Duplicate code usage is not allowed
110
+ throw new SimpleError({
111
+ code: "duplicate_codes",
112
+ message: "Duplicate usage of discount codes",
113
+ human: "Sommige kortingcodes werden dubbel toegepast op jouw bestelling. Kijk het even na, dit is niet toegestaan.",
114
+ field: "cart.discountCodes"
115
+ })
116
+ }
117
+ if (uniqueCodes.length > 0) {
118
+ // Fetch new and update them
119
+ const codeModels = await WebshopDiscountCode.getActiveCodes(webshop.id, uniqueCodes)
120
+
121
+ if (codeModels.length !== uniqueCodes.length) {
122
+ throw new SimpleError({
123
+ code: "invalid_code",
124
+ message: "Invalid discount code",
125
+ human: "De kortingscode die je hebt toegevoegd is niet (meer) geldig",
126
+ field: "cart.discountCodes"
127
+ })
128
+ }
129
+ request.body.discountCodes = codeModels.map(c => c.getStructure())
130
+ }
131
+
132
+ request.body.validate(webshopStruct, organization.meta, request.i18n, false, Context.user?.getStructure())
133
+ request.body.update(webshopStruct)
134
+
135
+ const order = new Order().setRelation(Order.webshop, webshop)
136
+ order.data = request.body // TODO: validate
137
+ order.organizationId = organization.id
138
+ order.createdAt = new Date()
139
+ order.createdAt.setMilliseconds(0)
140
+ order.userId = Context.user?.id ?? null
141
+
142
+ // Always reserve the stock
143
+ await order.updateStock()
144
+ return { webshop, order, organization }
145
+ })
146
+
147
+ // The order now is valid, the stock is reserved for now (until the payment fails or expires)
148
+ const totalPrice = request.body.totalPrice
149
+
150
+ try {
151
+ if (totalPrice == 0) {
152
+ // Force unknown payment method
153
+ order.data.paymentMethod = PaymentMethod.Unknown
154
+
155
+ // Mark this order as paid
156
+ await order.markPaid(null, organization, webshop)
157
+ await order.save()
158
+ } else {
159
+ const payment = new Payment()
160
+ payment.organizationId = organization.id
161
+ payment.method = request.body.paymentMethod
162
+ payment.status = PaymentStatus.Created
163
+ payment.price = totalPrice
164
+ payment.paidAt = null
165
+
166
+ // Determine the payment provider
167
+ // Throws if invalid
168
+ const {provider, stripeAccount} = await organization.getPaymentProviderFor(payment.method, webshop.privateMeta.paymentConfiguration)
169
+ payment.provider = provider
170
+ payment.stripeAccountId = stripeAccount?.id ?? null
171
+
172
+ await payment.save()
173
+
174
+ // Deprecated field
175
+ order.paymentId = payment.id
176
+ order.setRelation(Order.payment, payment)
177
+
178
+ // Save order to get the id
179
+ await order.save()
180
+
181
+ const balanceItemPayments: (BalanceItemPayment & { balanceItem: BalanceItem })[] = []
182
+
183
+ // Create balance item
184
+ const balanceItem = new BalanceItem();
185
+ balanceItem.orderId = order.id;
186
+ balanceItem.price = totalPrice
187
+ balanceItem.description = webshop.meta.name
188
+ balanceItem.pricePaid = 0
189
+ balanceItem.organizationId = organization.id;
190
+ balanceItem.status = BalanceItemStatus.Hidden;
191
+ await balanceItem.save();
192
+
193
+ // Create one balance item payment to pay it in one payment
194
+ const balanceItemPayment = new BalanceItemPayment()
195
+ balanceItemPayment.balanceItemId = balanceItem.id;
196
+ balanceItemPayment.paymentId = payment.id;
197
+ balanceItemPayment.organizationId = organization.id;
198
+ balanceItemPayment.price = balanceItem.price;
199
+ await balanceItemPayment.save();
200
+ balanceItemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem))
201
+
202
+ let paymentUrl: string | null = null
203
+ const description = webshop.meta.name+" - "+payment.id
204
+
205
+ if (payment.method == PaymentMethod.Transfer) {
206
+ await order.markValid(payment, [])
207
+
208
+ if (order.number) {
209
+ balanceItem.description = order.generateBalanceDescription(webshop)
210
+ }
211
+
212
+ balanceItem.status = BalanceItemStatus.Pending;
213
+ await balanceItem.save()
214
+ await payment.save()
215
+ } else if (payment.method == PaymentMethod.PointOfSale) {
216
+ // Not really paid, but needed to create the tickets if needed
217
+ await order.markPaid(payment, organization, webshop)
218
+
219
+ if (order.number) {
220
+ balanceItem.description = order.generateBalanceDescription(webshop)
221
+ }
222
+
223
+ balanceItem.status = BalanceItemStatus.Pending;
224
+ await balanceItem.save()
225
+ await payment.save()
226
+ } else {
227
+ const cancelUrl = "https://"+webshop.getHost()+'/payment?id='+encodeURIComponent(payment.id)+"&cancel=true"
228
+ const redirectUrl = "https://"+webshop.getHost()+'/payment?id='+encodeURIComponent(payment.id)
229
+ const exchangeUrl = 'https://'+organization.getApiHost()+"/v"+Version+"/payments/"+encodeURIComponent(payment.id)+"?exchange=true"
230
+
231
+ if (payment.provider === PaymentProvider.Stripe) {
232
+ const stripeResult = await StripeHelper.createPayment({
233
+ payment,
234
+ stripeAccount,
235
+ redirectUrl,
236
+ cancelUrl,
237
+ statementDescriptor: webshop.meta.name,
238
+ metadata: {
239
+ order: order.id,
240
+ organization: organization.id,
241
+ webshop: webshop.id,
242
+ payment: payment.id,
243
+ },
244
+ i18n: request.i18n,
245
+ lineItems: balanceItemPayments,
246
+ organization,
247
+ customer: {
248
+ name: order.data.customer.name,
249
+ email: order.data.customer.email,
250
+ }
251
+ });
252
+ paymentUrl = stripeResult.paymentUrl
253
+ } else if (payment.provider === PaymentProvider.Mollie) {
254
+ // Mollie payment
255
+ const token = await MollieToken.getTokenFor(webshop.organizationId)
256
+ if (!token) {
257
+ throw new SimpleError({
258
+ code: "",
259
+ message: "Betaling via " + PaymentMethodHelper.getName(payment.method) + " is onbeschikbaar"
260
+ })
261
+ }
262
+ const profileId = organization.privateMeta.mollieProfile?.id ?? await token.getProfileId(webshop.getHost())
263
+ if (!profileId) {
264
+ throw new SimpleError({
265
+ code: "",
266
+ message: "Betaling via " + PaymentMethodHelper.getName(payment.method) + " is tijdelijk onbeschikbaar"
267
+ })
268
+ }
269
+ const mollieClient = createMollieClient({ accessToken: await token.getAccessToken() });
270
+ const molliePayment = await mollieClient.payments.create({
271
+ amount: {
272
+ currency: 'EUR',
273
+ value: (totalPrice / 100).toFixed(2)
274
+ },
275
+ method: payment.method == PaymentMethod.Bancontact ? molliePaymentMethod.bancontact : (payment.method == PaymentMethod.iDEAL ? molliePaymentMethod.ideal : molliePaymentMethod.creditcard),
276
+ testmode: organization.privateMeta.useTestPayments ?? STAMHOOFD.environment != 'production',
277
+ profileId,
278
+ description,
279
+ redirectUrl,
280
+ webhookUrl: exchangeUrl,
281
+ metadata: {
282
+ order: order.id,
283
+ organization: organization.id,
284
+ webshop: webshop.id,
285
+ payment: payment.id
286
+ },
287
+ });
288
+ console.log(molliePayment)
289
+ paymentUrl = molliePayment.getCheckoutUrl()
290
+
291
+ // Save payment
292
+ const dbPayment = new MolliePayment()
293
+ dbPayment.paymentId = payment.id
294
+ dbPayment.mollieId = molliePayment.id
295
+ await dbPayment.save();
296
+ } else if (payment.provider == PaymentProvider.Payconiq) {
297
+ paymentUrl = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, exchangeUrl)
298
+ } else if (payment.provider == PaymentProvider.Buckaroo) {
299
+ // Increase request timeout because buckaroo is super slow
300
+ request.request.request?.setTimeout(60 * 1000)
301
+ const buckaroo = new BuckarooHelper(organization.privateMeta?.buckarooSettings?.key ?? "", organization.privateMeta?.buckarooSettings?.secret ?? "", organization.privateMeta.useTestPayments ?? STAMHOOFD.environment != 'production')
302
+ const ip = request.request.getIP()
303
+ paymentUrl = await buckaroo.createPayment(payment, ip, description, redirectUrl, exchangeUrl)
304
+ await payment.save()
305
+
306
+ // TypeScript doesn't understand that the status can change and isn't a const....
307
+ if ((payment.status as any) === PaymentStatus.Failed) {
308
+ throw new SimpleError({
309
+ code: "payment_failed",
310
+ message: "Betaling via " + PaymentMethodHelper.getName(payment.method) + " is onbeschikbaar"
311
+ })
312
+ }
313
+ } else {
314
+ throw new Error("Unknown payment provider")
315
+ }
316
+ }
317
+
318
+ return new Response(OrderResponse.create({
319
+ paymentUrl: paymentUrl,
320
+ order: OrderStruct.create({...order, payment: PaymentStruct.create(payment) })
321
+ }));
322
+ }
323
+ } catch (e) {
324
+ // Mark order as failed to release stock
325
+ if (order) {
326
+ await order.deleteOrderBecauseOfCreationError()
327
+ }
328
+ throw e;
329
+ }
330
+
331
+ return new Response(OrderResponse.create({
332
+ order: order.getStructureWithoutPayment()
333
+ }));
334
+ }
335
+ }
@@ -0,0 +1,40 @@
1
+ import { City, PostalCode, Province } from "@stamhoofd/models"
2
+ import { Address, Country } from "@stamhoofd/structures"
3
+
4
+ import { AddressValidator } from "./AddressValidator"
5
+
6
+ describe("AddressValidator", () => {
7
+ it("Can validate a city", async () => {
8
+ const province = new Province()
9
+ province.name = "Oost-Vlaanderen"
10
+ province.country = Country.Belgium
11
+ await province.save()
12
+
13
+ const city = new City()
14
+ city.name = "Wetteren"
15
+ city.country = Country.Belgium
16
+ city.provinceId = province.id
17
+ await city.save()
18
+
19
+ const postalCode = new PostalCode()
20
+ postalCode.cityId = city.id
21
+ postalCode.postalCode = "9230"
22
+ postalCode.country = Country.Belgium
23
+ await postalCode.save()
24
+
25
+ expect(true).toBe(true)
26
+
27
+ const validateAddress = Address.create({
28
+ country: Country.Belgium,
29
+ city: "Wetteren",
30
+ street: "Kerkstraat",
31
+ postalCode: "9230",
32
+ number: "12"
33
+ })
34
+ const validated = await AddressValidator.validate(validateAddress)
35
+ expect(validated.street).toEqual("Kerkstraat")
36
+ expect(validated.city).toEqual("Wetteren")
37
+ expect(validated.postalCode).toEqual("9230")
38
+ expect(validated.number).toEqual("12")
39
+ })
40
+ })
@@ -0,0 +1,256 @@
1
+ import { Database } from "@simonbackx/simple-database"
2
+ import { ArrayDecoder, AutoEncoder, Decoder, field, ObjectData, StringDecoder } from "@simonbackx/simple-encoding"
3
+ import { SimpleError } from "@simonbackx/simple-errors"
4
+ import { City, PostalCode, Street } from "@stamhoofd/models"
5
+ import { Address, Country, ValidatedAddress } from "@stamhoofd/structures"
6
+ import { sleep, StringCompare } from "@stamhoofd/utility"
7
+ import axios from "axios"
8
+ import { v4 as uuidv4 } from "uuid";
9
+
10
+ export class AddressValidatorStatic {
11
+ // TODO: hold street cache
12
+
13
+ async validate(address: Address): Promise<ValidatedAddress> {
14
+ address = address.clone()
15
+ let postalCode = address.postalCode
16
+ if (address.country == Country.Netherlands) {
17
+ // Check if we have the right syntax
18
+ const stripped = postalCode.replace(/\s/g, '')
19
+ if (stripped.length != 6) {
20
+ throw new SimpleError({
21
+ code: "invalid_field",
22
+ message: "Invalid postal code format (NL)",
23
+ human: "Ongeldig postcode formaat, voer in zoals '8011 PK'",
24
+ field: "postalCode"
25
+ })
26
+ }
27
+
28
+ const numbers = stripped.slice(0, 4)
29
+ if (!/[0-9]{4}/.test(numbers)) {
30
+ throw new SimpleError({
31
+ code: "invalid_field",
32
+ message: "Invalid postal code format (NL)",
33
+ human: "Ongeldig postcode formaat, voer in zoals '8011 PK'",
34
+ field: "postalCode"
35
+ })
36
+ }
37
+
38
+ // Don't do validation on last letters
39
+ postalCode = numbers
40
+ } else {
41
+ postalCode = postalCode.trim()
42
+ }
43
+
44
+ if (postalCode.length == 0) {
45
+ const numbers = address.city.substring(0, 4)
46
+ if (!/[0-9]{4}/.test(numbers)) {
47
+ postalCode = numbers
48
+ address.city = address.city.substring(4).trim()
49
+ } else {
50
+ throw new SimpleError({
51
+ code: "invalid_field",
52
+ message: "Postal code is required",
53
+ human: "Voer een postcode in",
54
+ field: "postalCode"
55
+ })
56
+ }
57
+ }
58
+
59
+ const city = await PostalCode.getCity(postalCode, address.city, address.country)
60
+
61
+ if (!city) {
62
+ throw new SimpleError({
63
+ code: "invalid_field",
64
+ message: "Invalid postal code or city",
65
+ human: "Deze postcode en/of gemeente bestaat niet, kijk je even na op een typfout?",
66
+ field: "postalCode"
67
+ })
68
+ }
69
+
70
+ // Validate street and try to correct it
71
+ if (address.country === Country.Belgium) {
72
+ // Also validate the street
73
+ let streets = await Street.where({ cityId: city.parentCityId ?? city.id })
74
+
75
+ if (streets.length == 0 && STAMHOOFD.environment === "development") {
76
+ console.log("Forcing sync of city")
77
+ const c = await City.getByID(city.parentCityId ?? city.id)
78
+ try {
79
+ await this.syncCity(c!)
80
+ } catch (e) {
81
+ console.error('Ignored error while syncing city')
82
+ console.error(e)
83
+ }
84
+ streets = await Street.where({ cityId: city.parentCityId ?? city.id })
85
+ }
86
+
87
+ if (STAMHOOFD.environment === "development" && streets.length > 0) {
88
+ // First search by typo count
89
+ let bestScore = 0
90
+ let bestStreet: Street | undefined = undefined
91
+ for (const street of streets) {
92
+ const score = StringCompare.typoCount(street.name, address.street)
93
+ if ((bestStreet === undefined || score < bestScore)) {
94
+ bestScore = score
95
+ bestStreet = street
96
+ }
97
+ }
98
+
99
+ if (bestStreet && bestScore < 3) {
100
+ address.street = bestStreet.name
101
+ } else {
102
+ // Search for the street
103
+ bestScore = 0
104
+ bestStreet = undefined
105
+ for (const street of streets) {
106
+ const score = StringCompare.compare(street.name, address.street)
107
+ if ((bestStreet === undefined || score > bestScore)) {
108
+ bestScore = score
109
+ bestStreet = street
110
+ }
111
+ }
112
+
113
+ if (!bestStreet || bestScore < 3 || bestScore < bestStreet.name.length/3) {
114
+ throw new SimpleError({
115
+ code: "invalid_field",
116
+ message: "Invalid street",
117
+ human: "Deze straat bestaat niet, kijk je deze even na op fouten? Formuleer de naam zonder afkortingen.",
118
+ field: "street"
119
+ })
120
+ }
121
+
122
+ throw new SimpleError({
123
+ code: "invalid_field",
124
+ message: "Invalid street, do you mean " + bestStreet.name + "?",
125
+ human: "Deze straat bestaat niet, bedoel je '" + bestStreet.name + "'?",
126
+ field: "street"
127
+ })
128
+ }
129
+ } else {
130
+ // Skip validation for some regions that don't support validation
131
+ }
132
+ }
133
+
134
+ return ValidatedAddress.create(Object.assign({ ... address }, {
135
+ postalCode: address.country === Country.Belgium ? postalCode : address.postalCode,
136
+ city: city.name, // override misspelled addresses
137
+ cityId: city.id,
138
+ parentCityId: city.parentCityId,
139
+ provinceId: city.provinceId,
140
+ }))
141
+ }
142
+
143
+ async downloadStreets(country: Country, city: string): Promise<string[]> {
144
+ let url: string | undefined = "https://api.basisregisters.vlaanderen.be/v2/straatnamen?gemeentenaam=" + encodeURIComponent(city)
145
+ const streetNames: string[] = []
146
+
147
+ try {
148
+ while (url) {
149
+ const response = await axios.request({
150
+ method: "GET",
151
+ url,
152
+ headers: {
153
+ "Accept": "application/ld+json",
154
+ }
155
+ })
156
+
157
+ // TypeScript is going insane here, hence the weird type casting
158
+ const result = new ObjectData(response.data, { version: 0 }).decode(StraatnamenResult as Decoder<StraatnamenResult>) as any as StraatnamenResult;
159
+ const streets = result.straatnamen.map(street => street.straatnaam.geografischeNaam.spelling);
160
+
161
+ streetNames.push(...streets);
162
+ url = result.volgende
163
+ }
164
+
165
+ } catch (e) {
166
+ console.error(e.response.data)
167
+ throw new Error("Failed to fetch streets")
168
+ }
169
+
170
+ return streetNames
171
+ }
172
+
173
+ async syncCity(city: City): Promise<void> {
174
+ const streetNames = await this.downloadStreets(city.country, city.name)
175
+ if (streetNames.length == 0) {
176
+ return
177
+ }
178
+
179
+ await Database.delete("DELETE from `streets` WHERE `cityId` = ?", [city.id])
180
+ await Database.insert("INSERT INTO `streets` (`id`, `name`, `cityId`) VALUES ?", [streetNames.map(street => [uuidv4(), street, city.id])])
181
+ }
182
+
183
+ async syncAll(): Promise<void> {
184
+ const cities = await City.where({ country: Country.Belgium, parentCityId: null })
185
+ for (const city of cities) {
186
+ await this.syncCity(city)
187
+
188
+ // Rate limit
189
+ await sleep(1000)
190
+ }
191
+ }
192
+
193
+ getSlowSync(): () => Promise<void> {
194
+ let lastFullCitySync: Date | null = null
195
+ let lastCityId = ""
196
+ async function syncNext() {
197
+ // Wait 24 hours between every full update
198
+ if (lastFullCitySync && lastFullCitySync > new Date(new Date().getTime() - 24 * 60 * 60 * 1000)) {
199
+ console.log("Skip city sync")
200
+ return
201
+ }
202
+
203
+ const cities = await City.where({
204
+ id: { sign: '>', value: lastCityId },
205
+ country: Country.Belgium,
206
+ parentCityId: null
207
+ }, {
208
+ limit: 1,
209
+ sort: ["id"]
210
+ })
211
+
212
+ if (cities.length == 0) {
213
+ // Wait an half hour before starting again
214
+ lastCityId = ""
215
+ lastFullCitySync = new Date()
216
+ return
217
+ }
218
+
219
+ for (const city of cities) {
220
+ try {
221
+ await this.syncCity(city)
222
+ } catch (e) {
223
+ console.error("Failed city sync for "+city.name, e)
224
+ }
225
+ }
226
+
227
+ lastCityId = cities[cities.length - 1].id
228
+ }
229
+
230
+ return syncNext
231
+ }
232
+ }
233
+ class GeografischeNaam extends AutoEncoder {
234
+ @field({ decoder: StringDecoder })
235
+ spelling: string
236
+ }
237
+
238
+ class Straatnaam extends AutoEncoder {
239
+ @field({ decoder: GeografischeNaam })
240
+ geografischeNaam: GeografischeNaam
241
+ }
242
+
243
+ class StraatnaamResult extends AutoEncoder {
244
+ @field({ decoder: Straatnaam })
245
+ straatnaam: Straatnaam
246
+ }
247
+
248
+ class StraatnamenResult extends AutoEncoder {
249
+ @field({ decoder: new ArrayDecoder(StraatnaamResult) })
250
+ straatnamen: StraatnaamResult[]
251
+
252
+ @field({ decoder: StringDecoder, optional: true })
253
+ volgende?: string
254
+ }
255
+
256
+ export const AddressValidator = new AddressValidatorStatic()