@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,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()
|