@stamhoofd/backend 2.120.5 → 2.121.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -12
- package/src/audit-logs/RegistrationInvitationLogger.ts +46 -0
- package/src/audit-logs/init.ts +2 -0
- package/src/crons/index.ts +2 -0
- package/src/crons/invoices.ts +166 -0
- package/src/crons/mollie-chargebacks.ts +87 -0
- package/src/crons.ts +47 -10
- package/src/email-recipient-loaders/payments.ts +84 -41
- package/src/endpoints/global/groups/GetGroupsCountEndpoint.ts +51 -0
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +22 -3
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -0
- package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsCountEndpoint.ts +45 -0
- package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +495 -0
- package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.ts +216 -0
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +405 -0
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +168 -0
- package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +15 -0
- package/src/endpoints/{global → organization/dashboard}/billing/DeactivatePackageEndpoint.ts +3 -4
- package/src/endpoints/organization/dashboard/billing/DeleteOrganizationMandateEndpoint.ts +62 -0
- package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceCollectionEndpoint.ts +56 -0
- package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceEndpoint.ts +42 -19
- package/src/endpoints/organization/dashboard/billing/GetOrganizationMandatesEndpoint.ts +64 -0
- package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.ts +11 -3
- package/src/endpoints/organization/dashboard/billing/OrganizationCheckoutEndpoint.ts +308 -0
- package/src/endpoints/organization/dashboard/billing/PatchOrganizationMandatesEndpoint.ts +94 -0
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +7 -0
- package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +5 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +7 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +17 -8
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +3 -3
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +127 -0
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +13 -4
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +7 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
- package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +13 -11
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +14 -19
- package/src/helpers/AdminPermissionChecker.ts +11 -3
- package/src/helpers/AuthenticatedStructures.ts +94 -6
- package/src/helpers/FinancialSupportHelper.ts +21 -0
- package/src/helpers/RecordAnswerHelper.test.ts +746 -0
- package/src/helpers/RecordAnswerHelper.ts +116 -0
- package/src/helpers/StripeHelper.ts +2 -3
- package/src/helpers/ViesHelper.ts +7 -3
- package/src/seeds/1750090030-records-configuration.ts +68 -3
- package/src/seeds/1752848561-groups-registration-periods.ts +26 -2
- package/src/seeds/1779121239-default-invoice-email-template.sql +3 -0
- package/src/services/BalanceItemService.ts +12 -16
- package/src/services/InvoiceService.ts +372 -72
- package/src/services/MollieService.ts +537 -0
- package/src/services/PaymentMandateService.ts +214 -0
- package/src/services/PaymentService.ts +578 -222
- package/src/services/PlatformMembershipService.ts +1 -1
- package/src/services/RegistrationService.ts +66 -5
- package/src/services/STPackageService.ts +0 -7
- package/src/services/data/invoice.hbs.html +686 -0
- package/src/sql-filters/groups.ts +11 -1
- package/src/sql-filters/payments.ts +5 -0
- package/src/sql-filters/registration-invitations.ts +90 -0
- package/src/sql-sorters/registration-invitations.ts +36 -0
- package/vitest.config.js +1 -0
- package/src/endpoints/global/billing/ActivatePackagesEndpoint.ts +0 -216
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import { createMollieClient,
|
|
2
|
-
import { SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
-
import type {
|
|
1
|
+
import { createMollieClient, PaymentStatus as MolliePaymentStatus } from '@mollie/api-client';
|
|
2
|
+
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
+
import type { Member, User } from '@stamhoofd/models';
|
|
4
|
+
import { BalanceItem } from '@stamhoofd/models';
|
|
4
5
|
import { BalanceItemPayment, Group, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, sendEmailTemplate } from '@stamhoofd/models';
|
|
5
6
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
|
-
import type { Checkoutable, PaymentConfiguration } from '@stamhoofd/structures';
|
|
7
|
-
import { AuditLogSource, BalanceItemType, EmailTemplateType, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PaymentType, Recipient, VATExcemptReason, Version } from '@stamhoofd/structures';
|
|
7
|
+
import type { Checkoutable, PaymentConfiguration, PrivatePaymentConfiguration } from '@stamhoofd/structures';
|
|
8
|
+
import { AuditLogSource, BalanceItemPaymentDetailed, BalanceItemType, EmailTemplateType, Invoice, PaymentCustomer, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PaymentType, Recipient, VATExcemptReason, Version } from '@stamhoofd/structures';
|
|
9
|
+
import type { PaymentMandate } from '@stamhoofd/structures/PaymentMandate.js';
|
|
10
|
+
import { PaymentMandateStatus, PaymentMandateType } from '@stamhoofd/structures/PaymentMandate.js';
|
|
8
11
|
import { Formatter } from '@stamhoofd/utility';
|
|
9
12
|
import { buildReplacementOptions, getEmailReplacementsForPayment } from '../email-replacements/getEmailReplacementsForPayment.js';
|
|
10
13
|
import { BuckarooHelper } from '../helpers/BuckarooHelper.js';
|
|
11
14
|
import { Context } from '../helpers/Context.js';
|
|
12
15
|
import { ServiceFeeHelper } from '../helpers/ServiceFeeHelper.js';
|
|
13
16
|
import { StripeHelper } from '../helpers/StripeHelper.js';
|
|
17
|
+
import { ViesHelper } from '../helpers/ViesHelper.js';
|
|
14
18
|
import { AuditLogService } from './AuditLogService.js';
|
|
15
19
|
import { BalanceItemPaymentService } from './BalanceItemPaymentService.js';
|
|
16
20
|
import { BalanceItemService } from './BalanceItemService.js';
|
|
21
|
+
import { MollieService } from './MollieService.js';
|
|
22
|
+
import { PaymentMandateService } from './PaymentMandateService.js';
|
|
23
|
+
import type { CreateMandateSettings } from '@stamhoofd/structures/checkout/CreateMandateSettings.js';
|
|
17
24
|
|
|
18
25
|
export class PaymentService {
|
|
19
26
|
static async handlePaymentStatusUpdate(payment: Payment, organization: Organization, status: PaymentStatus) {
|
|
@@ -21,6 +28,14 @@ export class PaymentService {
|
|
|
21
28
|
return;
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
if (status === PaymentStatus.Failed && payment.invoiceId) {
|
|
32
|
+
throw new SimpleError({
|
|
33
|
+
code: 'cannot_fail',
|
|
34
|
+
message: 'A payment that has been invoiced cannot be marked as failed. Instead create a separate chargeback payment with a negative amount.',
|
|
35
|
+
human: $t('%1RI')
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
await AuditLogService.setContext({ fallbackUserId: payment.payingUserId, source: AuditLogSource.Payment, fallbackOrganizationId: payment.organizationId }, async () => {
|
|
25
40
|
if (status === PaymentStatus.Succeeded) {
|
|
26
41
|
payment.status = PaymentStatus.Succeeded;
|
|
@@ -43,6 +58,15 @@ export class PaymentService {
|
|
|
43
58
|
// Flush caches so data is up to date in response
|
|
44
59
|
await BalanceItemService.flushCaches(organization.id);
|
|
45
60
|
});
|
|
61
|
+
|
|
62
|
+
// It is possible the mandate succeeds immediately, in which case we might
|
|
63
|
+
// need to save it as the default payment method
|
|
64
|
+
if (payment.status === PaymentStatus.Succeeded) {
|
|
65
|
+
await this.saveMandateIfNeeded({
|
|
66
|
+
payment,
|
|
67
|
+
sellingOrganization: organization,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
46
70
|
return;
|
|
47
71
|
}
|
|
48
72
|
|
|
@@ -110,6 +134,25 @@ export class PaymentService {
|
|
|
110
134
|
});
|
|
111
135
|
}
|
|
112
136
|
|
|
137
|
+
static async saveMandateIfNeeded({payment, sellingOrganization}: {payment: Payment, sellingOrganization: Organization}) {
|
|
138
|
+
// Save as default
|
|
139
|
+
if (payment.createMandate && payment.createMandate.saveAsDefault) {
|
|
140
|
+
if (payment.mandateId && payment.status === PaymentStatus.Succeeded) {
|
|
141
|
+
try {
|
|
142
|
+
await PaymentMandateService.setDefaultMandate({
|
|
143
|
+
mandateId: payment.mandateId,
|
|
144
|
+
payingOrganizationId: payment.payingOrganizationId,
|
|
145
|
+
sellingOrganization,
|
|
146
|
+
payingUserId: payment.payingUserId
|
|
147
|
+
})
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// Ignore as setting the payment status is more important
|
|
150
|
+
console.error(e);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
113
156
|
/**
|
|
114
157
|
* ID of payment is needed because of race conditions (need to fetch payment in a race condition save queue)
|
|
115
158
|
*/
|
|
@@ -129,11 +172,15 @@ export class PaymentService {
|
|
|
129
172
|
return;
|
|
130
173
|
}
|
|
131
174
|
|
|
132
|
-
const organization = org
|
|
175
|
+
const organization = org && org.id === payment.organizationId ? org : await Organization.getByID(payment.organizationId);
|
|
133
176
|
if (!organization) {
|
|
134
177
|
console.error('Organization not found for payment', payment.id);
|
|
135
178
|
return;
|
|
136
179
|
}
|
|
180
|
+
if (org && org.id !== payment.organizationId && org.id !== payment.payingOrganizationId) {
|
|
181
|
+
console.error('Non-matching organization found for payment', payment.id, 'org', org.id);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
137
184
|
|
|
138
185
|
const testMode = organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production';
|
|
139
186
|
|
|
@@ -158,89 +205,36 @@ export class PaymentService {
|
|
|
158
205
|
}
|
|
159
206
|
}
|
|
160
207
|
else if (payment.provider === PaymentProvider.Mollie) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
let mollieData = await mollieClient.payments.get(molliePayment.mollieId, {
|
|
172
|
-
testmode: organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production',
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
console.log(mollieData); // log to log files to check issues
|
|
176
|
-
|
|
177
|
-
const details = mollieData.details as any;
|
|
178
|
-
if (details?.consumerName) {
|
|
179
|
-
payment.ibanName = details.consumerName;
|
|
180
|
-
}
|
|
181
|
-
if (details?.consumerAccount) {
|
|
182
|
-
payment.iban = details.consumerAccount;
|
|
183
|
-
}
|
|
184
|
-
if (details?.cardHolder) {
|
|
185
|
-
payment.ibanName = details.cardHolder;
|
|
186
|
-
}
|
|
187
|
-
if (details?.cardNumber) {
|
|
188
|
-
payment.iban = 'xxxx xxxx xxxx ' + details.cardNumber;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (mollieData.status === MolliePaymentStatus.paid) {
|
|
192
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
|
|
193
|
-
}
|
|
194
|
-
else if (mollieData.status === MolliePaymentStatus.failed || mollieData.status === MolliePaymentStatus.expired || mollieData.status === MolliePaymentStatus.canceled) {
|
|
195
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
196
|
-
}
|
|
197
|
-
else if ((cancel || this.shouldTryToCancel(payment.status, payment)) && mollieData.isCancelable) {
|
|
198
|
-
console.log('Cancelling Mollie payment on request', payment.id);
|
|
199
|
-
mollieData = await mollieClient.payments.cancel(molliePayment.mollieId);
|
|
200
|
-
|
|
201
|
-
if (mollieData.status === MolliePaymentStatus.paid) {
|
|
202
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Succeeded);
|
|
203
|
-
}
|
|
204
|
-
else if (mollieData.status === MolliePaymentStatus.failed || mollieData.status === MolliePaymentStatus.expired || mollieData.status === MolliePaymentStatus.canceled) {
|
|
205
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
206
|
-
}
|
|
207
|
-
else if (this.isManualExpired(payment.status, payment)) {
|
|
208
|
-
// Mollie still returning pending after 1 day: mark as failed
|
|
209
|
-
console.error('Manually marking Mollie payment as expired', payment.id);
|
|
210
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
else if (this.isManualExpired(payment.status, payment)) {
|
|
214
|
-
// Mollie still returning pending after 1 day: mark as failed
|
|
215
|
-
console.error('Manually marking Mollie payment as expired', payment.id);
|
|
216
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
catch (e) {
|
|
220
|
-
console.error('Payment check failed Mollie', payment.id, e);
|
|
221
|
-
if (this.isManualExpired(payment.status, payment)) {
|
|
222
|
-
console.error('Manually marking Mollie payment as expired', payment.id);
|
|
223
|
-
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
224
|
-
}
|
|
208
|
+
try {
|
|
209
|
+
const mollieClient = await MollieService.create({
|
|
210
|
+
sellingOrganization: organization
|
|
211
|
+
});
|
|
212
|
+
if (mollieClient) {
|
|
213
|
+
let {status} = await mollieClient.getStatus(payment, cancel || this.shouldTryToCancel(payment.status, payment));
|
|
214
|
+
|
|
215
|
+
if (this.isManualExpired(status, payment)) {
|
|
216
|
+
console.error('Manually marking Mollie payment as expired', payment.id);
|
|
217
|
+
status = PaymentStatus.Failed;
|
|
225
218
|
}
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
console.warn('Mollie payment is missing for organization ' + organization.id + ' while checking payment status...');
|
|
229
219
|
|
|
220
|
+
await this.handlePaymentStatusUpdate(payment, organization, status);
|
|
221
|
+
} else {
|
|
222
|
+
console.error('Missing Mollie Credentials for payment', payment.id);
|
|
230
223
|
if (this.isManualExpired(payment.status, payment)) {
|
|
231
|
-
console.error('Manually marking payment
|
|
224
|
+
console.error('Manually marking Mollie payment as expired', payment.id);
|
|
232
225
|
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
233
226
|
}
|
|
234
227
|
}
|
|
235
|
-
|
|
236
|
-
|
|
228
|
+
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.error('Payment check failed Mollie', payment.id, e);
|
|
237
231
|
if (this.isManualExpired(payment.status, payment)) {
|
|
238
|
-
console.error('Manually marking payment
|
|
232
|
+
console.error('Manually marking Mollie payment as expired', payment.id);
|
|
239
233
|
await this.handlePaymentStatusUpdate(payment, organization, PaymentStatus.Failed);
|
|
240
234
|
}
|
|
241
235
|
}
|
|
242
236
|
}
|
|
243
|
-
else if (payment.provider
|
|
237
|
+
else if (payment.provider === PaymentProvider.Buckaroo) {
|
|
244
238
|
const helper = new BuckarooHelper(organization.privateMeta.buckarooSettings?.key ?? '', organization.privateMeta.buckarooSettings?.secret ?? '', organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production');
|
|
245
239
|
try {
|
|
246
240
|
let status = await helper.getStatus(payment);
|
|
@@ -311,7 +305,7 @@ export class PaymentService {
|
|
|
311
305
|
else {
|
|
312
306
|
// Do a manual update if needed
|
|
313
307
|
if (payment.status === PaymentStatus.Succeeded) {
|
|
314
|
-
if (payment.provider === PaymentProvider.
|
|
308
|
+
if ( (payment.provider === PaymentProvider.Mollie && STAMHOOFD.environment === 'development')) {
|
|
315
309
|
// Update the status
|
|
316
310
|
await StripeHelper.getStatus(payment, false, testMode);
|
|
317
311
|
}
|
|
@@ -329,6 +323,18 @@ export class PaymentService {
|
|
|
329
323
|
return true;
|
|
330
324
|
}
|
|
331
325
|
}
|
|
326
|
+
|
|
327
|
+
if (STAMHOOFD.environment === 'development') {
|
|
328
|
+
// In development, we expire all direct debits and other paymetns after 1 hour, because they need manual changes
|
|
329
|
+
// otherwise they will remain stuck in the dev environment, poluting the UI
|
|
330
|
+
if ((status === PaymentStatus.Pending || status === PaymentStatus.Created)) {
|
|
331
|
+
// If payment is not succeeded after one day, mark as failed
|
|
332
|
+
if (payment.createdAt < new Date(new Date().getTime() - 60 * 1000 * 60)) {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
332
338
|
return false;
|
|
333
339
|
}
|
|
334
340
|
|
|
@@ -336,7 +342,7 @@ export class PaymentService {
|
|
|
336
342
|
* Try to cancel a payment that is still pending
|
|
337
343
|
*/
|
|
338
344
|
static shouldTryToCancel(status: PaymentStatus, payment: Payment): boolean {
|
|
339
|
-
if ((status === PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method !== PaymentMethod.DirectDebit) {
|
|
345
|
+
if ((status === PaymentStatus.Pending || status === PaymentStatus.Created) && (payment.method !== PaymentMethod.DirectDebit || STAMHOOFD.environment === 'development')) {
|
|
340
346
|
let timeout = STAMHOOFD.environment === 'development' ? 60 * 1000 * 2 : 60 * 1000 * 30;
|
|
341
347
|
|
|
342
348
|
// If payconiq and not yet 'identified' (scanned), cancel after 5 minutes
|
|
@@ -356,39 +362,45 @@ export class PaymentService {
|
|
|
356
362
|
* we'll need to round the payment to 1 cent. That can cause issues in the financial statements because
|
|
357
363
|
* the total amount of balances does not match the total amount received/paid.
|
|
358
364
|
*
|
|
359
|
-
* To fix that, we create an extra
|
|
360
|
-
*
|
|
361
|
-
* TODO: update this method to generate a virtual invoice and use the price of the invoice instead of the rounded payment price, so we don't get differences in calculation
|
|
365
|
+
* To fix that, we create an extra roundingAmount with the difference. So the rounding always matches.
|
|
362
366
|
*/
|
|
363
|
-
static
|
|
364
|
-
|
|
365
|
-
|
|
367
|
+
static roundPayment(payment: Payment) {
|
|
368
|
+
// Calculate total price of the balance items
|
|
369
|
+
// this fixes issus when the method is called multiple times
|
|
370
|
+
// should be subtracted, not added
|
|
371
|
+
const balanceItemsTotalPrice = payment.price - payment.roundingAmount;
|
|
372
|
+
|
|
373
|
+
const {roundingAmount, price} = this.round(balanceItemsTotalPrice);
|
|
374
|
+
payment.roundingAmount = roundingAmount;
|
|
375
|
+
payment.price = price;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
static round(amount: number) {
|
|
379
|
+
const rounded = Payment.roundPrice(amount);
|
|
366
380
|
const difference = rounded - amount;
|
|
367
381
|
|
|
368
382
|
if (difference === 0) {
|
|
369
|
-
return
|
|
383
|
+
return {
|
|
384
|
+
price: amount,
|
|
385
|
+
roundingAmount: 0
|
|
386
|
+
};
|
|
370
387
|
}
|
|
371
388
|
|
|
372
389
|
if (difference > 100 || difference < -100) {
|
|
373
|
-
throw new Error('Unexpected rounding difference of ' + difference + ' for
|
|
390
|
+
throw new Error('Unexpected rounding difference of ' + difference + ' for price ' + amount.toString());
|
|
374
391
|
}
|
|
375
392
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
393
|
+
return {
|
|
394
|
+
price: amount + difference,
|
|
395
|
+
roundingAmount: difference
|
|
396
|
+
}
|
|
380
397
|
}
|
|
381
398
|
|
|
382
|
-
static
|
|
399
|
+
static calculateTotalPrice({ balanceItems, organization, members}: {
|
|
383
400
|
balanceItems: Map<BalanceItem, number>;
|
|
384
401
|
organization: Organization;
|
|
385
|
-
user: User;
|
|
386
402
|
members?: Member[];
|
|
387
|
-
checkout: Pick<Checkoutable<never>, 'paymentMethod' | 'totalPrice' | 'customer' | 'cancelUrl' | 'redirectUrl'>;
|
|
388
|
-
payingOrganization?: Organization | null;
|
|
389
|
-
serviceFeeType: 'webshop' | 'members' | 'tickets' | 'system';
|
|
390
403
|
}) {
|
|
391
|
-
// Calculate total price to pay
|
|
392
404
|
let totalPrice = 0;
|
|
393
405
|
const names: {
|
|
394
406
|
firstName: string;
|
|
@@ -437,44 +449,55 @@ export class PaymentService {
|
|
|
437
449
|
});
|
|
438
450
|
}
|
|
439
451
|
}
|
|
452
|
+
const { price, roundingAmount } = this.round(totalPrice);
|
|
440
453
|
|
|
441
|
-
if (
|
|
442
|
-
// todo: try to make it non-negative by reducing some balance items
|
|
454
|
+
if (price < 0) {
|
|
443
455
|
throw new SimpleError({
|
|
444
456
|
code: 'negative_price',
|
|
445
457
|
message: $t(`%vl`),
|
|
446
458
|
});
|
|
447
459
|
}
|
|
448
460
|
|
|
449
|
-
|
|
450
|
-
|
|
461
|
+
return { hasNegative, price, roundingAmount, names }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
static validateTotalPrice({ price, roundingAmount, checkout}: {
|
|
465
|
+
price: number, // already rounded
|
|
466
|
+
roundingAmount: number,
|
|
467
|
+
checkout: Pick<Checkoutable<never>, 'totalPrice'>;
|
|
468
|
+
}) {
|
|
469
|
+
// total price without rounding
|
|
470
|
+
const balanceItemsPrice = price - roundingAmount;
|
|
471
|
+
|
|
472
|
+
// also accept rounding that might have happend in the frontend and that was correct
|
|
473
|
+
if (checkout.totalPrice !== null && checkout.totalPrice !== balanceItemsPrice && checkout.totalPrice !== price) {
|
|
451
474
|
throw new SimpleError({
|
|
452
475
|
code: 'changed_price',
|
|
453
|
-
message: $t(`%vk`, { total: Formatter.price(
|
|
476
|
+
message: $t(`%vk`, { total: Formatter.price(price) }),
|
|
454
477
|
});
|
|
455
478
|
}
|
|
479
|
+
}
|
|
456
480
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
payment.payingOrganizationId = payingOrganization?.id ?? null;
|
|
465
|
-
|
|
481
|
+
static async validateCustomer({ price, hasNegative, user, checkout, payingOrganization}: {
|
|
482
|
+
price: number,
|
|
483
|
+
hasNegative: boolean,
|
|
484
|
+
user: User | null;
|
|
485
|
+
checkout: Pick<Checkoutable<never>, 'customer'>;
|
|
486
|
+
payingOrganization?: Organization | null;
|
|
487
|
+
}) {
|
|
466
488
|
// Fill in customer default value
|
|
467
|
-
|
|
468
|
-
firstName: user
|
|
469
|
-
lastName: user
|
|
470
|
-
email: user
|
|
489
|
+
const customer = PaymentCustomer.create({
|
|
490
|
+
firstName: user?.firstName,
|
|
491
|
+
lastName: user?.lastName,
|
|
492
|
+
email: user?.email,
|
|
493
|
+
phone: checkout.customer?.phone
|
|
471
494
|
});
|
|
472
495
|
|
|
473
496
|
// Use structured transfer description prefix
|
|
474
497
|
let prefix = '';
|
|
475
498
|
|
|
476
499
|
if (payingOrganization) {
|
|
477
|
-
if (
|
|
500
|
+
if (price !== 0 || hasNegative || checkout.customer) {
|
|
478
501
|
if (!checkout.customer) {
|
|
479
502
|
throw new SimpleError({
|
|
480
503
|
code: 'missing_fields',
|
|
@@ -503,7 +526,14 @@ export class PaymentService {
|
|
|
503
526
|
});
|
|
504
527
|
}
|
|
505
528
|
|
|
506
|
-
|
|
529
|
+
if (!checkout.customer.company.equals(foundCompany)) {
|
|
530
|
+
throw new SimpleError({
|
|
531
|
+
code: 'invalid_data',
|
|
532
|
+
message: 'Cannot change company data. Please save the company data to the paying organization meta data before using it.'
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
customer.company = foundCompany;
|
|
507
537
|
|
|
508
538
|
const orgNumber = parseInt(payingOrganization.uri);
|
|
509
539
|
|
|
@@ -514,36 +544,171 @@ export class PaymentService {
|
|
|
514
544
|
else {
|
|
515
545
|
// Zero amount payment (without refunds) without specifying a company will just use the default company to link to the payment
|
|
516
546
|
// It doesn't really matter since the price is zero and we won't invoice it.
|
|
517
|
-
const company = payingOrganization
|
|
547
|
+
const company = this.getDefaultCompanyForOrganization(payingOrganization);
|
|
518
548
|
if (company) {
|
|
519
|
-
|
|
549
|
+
customer.company = company;
|
|
520
550
|
}
|
|
521
551
|
}
|
|
552
|
+
} else {
|
|
553
|
+
if (checkout.customer && checkout.customer.company) {
|
|
554
|
+
customer.company = checkout.customer.company.clone()
|
|
555
|
+
await ViesHelper.checkCompany(checkout.customer.company, customer.company);
|
|
556
|
+
}
|
|
522
557
|
}
|
|
523
558
|
|
|
524
|
-
|
|
525
|
-
|
|
559
|
+
if (price !== 0 && customer.company?.VATNumber !== checkout.customer?.company?.VATNumber) {
|
|
560
|
+
// Security check: because previous validation and generation might have used the VATNumber from the checkout
|
|
561
|
+
throw new SimpleError({
|
|
562
|
+
code: 'changed_VAT_number',
|
|
563
|
+
message: 'Unexpected VAT number change'
|
|
564
|
+
})
|
|
565
|
+
}
|
|
526
566
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
payment.price = totalPrice;
|
|
530
|
-
PaymentService.round(payment);
|
|
531
|
-
totalPrice = payment.price;
|
|
567
|
+
return { customer, prefix };
|
|
568
|
+
}
|
|
532
569
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
570
|
+
static async createProForma({ balanceItems, organization, user, members, checkout, payingOrganization}: {
|
|
571
|
+
balanceItems: Map<BalanceItem, number>;
|
|
572
|
+
organization: Organization;
|
|
573
|
+
user: User;
|
|
574
|
+
members?: Member[];
|
|
575
|
+
checkout: Pick<Checkoutable<never>, 'totalPrice' | 'customer' | 'cancelUrl' | 'redirectUrl'>;
|
|
576
|
+
payingOrganization?: Organization | null;
|
|
577
|
+
}) {
|
|
578
|
+
// Calculate total price to pay
|
|
579
|
+
const { price, hasNegative, roundingAmount } = PaymentService.calculateTotalPrice({ balanceItems, organization, members })
|
|
580
|
+
PaymentService.validateTotalPrice({ price, roundingAmount, checkout })
|
|
581
|
+
|
|
582
|
+
const { customer, } = await PaymentService.validateCustomer({ user, checkout, payingOrganization, price, hasNegative })
|
|
583
|
+
const { seller } = PaymentService.validateVATRates({ customer, organization, balanceItems });
|
|
584
|
+
|
|
585
|
+
// Create invoice instead from fictive PaymentGeneral
|
|
586
|
+
const fakePaymentGeneral = PaymentGeneral.create({
|
|
587
|
+
id: 'pro-forma',
|
|
588
|
+
price,
|
|
589
|
+
customer,
|
|
590
|
+
method: PaymentMethod.Unknown,
|
|
591
|
+
balanceItemPayments: [...balanceItems.entries()].map(([balanceItem, price]) => {
|
|
592
|
+
return BalanceItemPaymentDetailed.create({
|
|
593
|
+
id: 'pro-forma-' + balanceItem.id,
|
|
594
|
+
balanceItem: balanceItem.getStructure(),
|
|
595
|
+
price
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
let invoice: Invoice | null = null;
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
invoice = Invoice.create({
|
|
604
|
+
seller,
|
|
605
|
+
customer,
|
|
606
|
+
payments: [fakePaymentGeneral],
|
|
607
|
+
});
|
|
608
|
+
invoice.buildFromPayments();
|
|
609
|
+
} catch (e) {
|
|
610
|
+
if ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('balance_item_without_vat_percentage')) {
|
|
611
|
+
// ok
|
|
612
|
+
// Missing VAT that prevents invoice from being created
|
|
613
|
+
} else {
|
|
614
|
+
throw e;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
invoice,
|
|
620
|
+
payment: fakePaymentGeneral
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
static async registerChargeback(payment: Payment, amount: number) {
|
|
625
|
+
if (amount !== payment.price) {
|
|
626
|
+
// Creates issues to know what balance item was paid and what was not.
|
|
627
|
+
throw new Error('Cannot register chargeback with different amount than the payment for payment ' + payment.id)
|
|
628
|
+
}
|
|
629
|
+
const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payment.id).fetch()
|
|
630
|
+
const items = await BalanceItem.getByIDs(...Formatter.uniqueArray(balanceItemPayments.map(b => b.balanceItemId)))
|
|
631
|
+
|
|
632
|
+
// Done validation
|
|
633
|
+
const chargeback = new Payment();
|
|
634
|
+
|
|
635
|
+
// Who will receive this money?
|
|
636
|
+
chargeback.organizationId = payment.organizationId!;
|
|
637
|
+
|
|
638
|
+
// Who paid
|
|
639
|
+
chargeback.payingUserId = payment.payingUserId
|
|
640
|
+
chargeback.payingOrganizationId = payment.payingOrganizationId
|
|
641
|
+
chargeback.customer = payment.customer;
|
|
642
|
+
|
|
643
|
+
chargeback.status = PaymentStatus.Succeeded;
|
|
644
|
+
chargeback.paidAt = new Date();
|
|
645
|
+
chargeback.price = -payment.price;
|
|
646
|
+
chargeback.roundingAmount = -payment.roundingAmount;
|
|
647
|
+
chargeback.method = payment.method;
|
|
648
|
+
chargeback.type = PaymentType.Chargeback;
|
|
649
|
+
|
|
650
|
+
chargeback.provider = payment.provider;
|
|
651
|
+
chargeback.stripeAccountId = payment.stripeAccountId
|
|
652
|
+
|
|
653
|
+
await chargeback.save();
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
for (const original of balanceItemPayments) {
|
|
657
|
+
// Create one balance item payment to pay it in one payment
|
|
658
|
+
const balanceItemPayment = new BalanceItemPayment();
|
|
659
|
+
balanceItemPayment.balanceItemId = original.balanceItemId
|
|
660
|
+
balanceItemPayment.paymentId = chargeback.id;
|
|
661
|
+
balanceItemPayment.organizationId = chargeback.organizationId;
|
|
662
|
+
balanceItemPayment.price = -original.price;
|
|
663
|
+
await balanceItemPayment.save();
|
|
536
664
|
}
|
|
537
665
|
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
666
|
+
// Update cached balance items pending amount (only created balance items, because those are involved in the payment)
|
|
667
|
+
await BalanceItemService.updatePaidAndPending(items);
|
|
668
|
+
|
|
669
|
+
return chargeback;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
static async createPayment({ balanceItems, organization, user, members, checkout, payingOrganization, serviceFeeType, createMandate, useMandate, paymentConfiguration, privatePaymentConfiguration}: {
|
|
673
|
+
balanceItems: Map<BalanceItem, number>;
|
|
674
|
+
organization: Organization;
|
|
675
|
+
user: User | null;
|
|
676
|
+
members?: Member[];
|
|
677
|
+
checkout: Pick<Checkoutable<never>, 'paymentMethod' | 'totalPrice' | 'customer' | 'cancelUrl' | 'redirectUrl'>;
|
|
678
|
+
payingOrganization?: Organization | null;
|
|
679
|
+
serviceFeeType: 'webshop' | 'members' | 'tickets' | 'system';
|
|
680
|
+
createMandate: CreateMandateSettings | null,
|
|
681
|
+
useMandate: PaymentMandate | null,
|
|
682
|
+
paymentConfiguration: PaymentConfiguration,
|
|
683
|
+
privatePaymentConfiguration: PrivatePaymentConfiguration,
|
|
684
|
+
}) {
|
|
685
|
+
if (balanceItems.size === 0) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
541
688
|
|
|
542
|
-
|
|
543
|
-
|
|
689
|
+
// Calculate total price to pay
|
|
690
|
+
const { price, roundingAmount, hasNegative, names } = this.calculateTotalPrice({ balanceItems, organization, members })
|
|
691
|
+
PaymentService.validateTotalPrice({ price, roundingAmount, checkout })
|
|
692
|
+
|
|
693
|
+
const { customer, prefix } = await this.validateCustomer({ user, checkout, payingOrganization, price, hasNegative })
|
|
694
|
+
this.validateVATRates({ customer, organization, balanceItems });
|
|
695
|
+
|
|
696
|
+
const {method, type, mandate} = await this.validatePaymentMethod({
|
|
697
|
+
method: checkout.paymentMethod ?? PaymentMethod.Unknown,
|
|
698
|
+
mandate: useMandate,
|
|
699
|
+
createMandate: !!createMandate,
|
|
700
|
+
customer,
|
|
701
|
+
price,
|
|
702
|
+
hasNegative,
|
|
703
|
+
balanceItems,
|
|
704
|
+
paymentConfiguration,
|
|
705
|
+
user,
|
|
706
|
+
payingOrganization: payingOrganization ?? null,
|
|
707
|
+
sellingOrganization: organization
|
|
708
|
+
});
|
|
544
709
|
|
|
545
|
-
//
|
|
546
|
-
if ((
|
|
710
|
+
// Check URL's set fro online payments
|
|
711
|
+
if ((method !== PaymentMethod.Transfer && method !== PaymentMethod.PointOfSale && method !== PaymentMethod.Unknown) && (!checkout.redirectUrl || !checkout.cancelUrl) && !mandate) {
|
|
547
712
|
throw new SimpleError({
|
|
548
713
|
code: 'missing_fields',
|
|
549
714
|
message: 'redirectUrl or cancelUrl is missing and is required for non-zero online payments',
|
|
@@ -551,6 +716,47 @@ export class PaymentService {
|
|
|
551
716
|
});
|
|
552
717
|
}
|
|
553
718
|
|
|
719
|
+
// Determine the payment provider (throws if invalid)
|
|
720
|
+
const { provider, stripeAccount } = await organization.getPaymentProviderFor(method, mandate, privatePaymentConfiguration);
|
|
721
|
+
|
|
722
|
+
if (createMandate && !mandate) {
|
|
723
|
+
if (provider !== PaymentProvider.Mollie) {
|
|
724
|
+
throw new SimpleError({
|
|
725
|
+
code: 'cannot_create_mandate_for_provider',
|
|
726
|
+
message: 'Saving a payment method is not yet supported for this payment method',
|
|
727
|
+
human: $t('%1U0')
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Done validation
|
|
733
|
+
const payment = new Payment();
|
|
734
|
+
|
|
735
|
+
// Who will receive this money?
|
|
736
|
+
payment.organizationId = organization.id;
|
|
737
|
+
|
|
738
|
+
// Who paid
|
|
739
|
+
payment.payingUserId = !payingOrganization ? (user?.id ?? null) : null;
|
|
740
|
+
payment.payingOrganizationId = payingOrganization?.id ?? null;
|
|
741
|
+
payment.customer = customer;
|
|
742
|
+
|
|
743
|
+
payment.status = PaymentStatus.Created;
|
|
744
|
+
payment.paidAt = null;
|
|
745
|
+
payment.price = price;
|
|
746
|
+
payment.roundingAmount = roundingAmount;
|
|
747
|
+
payment.method = method;
|
|
748
|
+
payment.type = type;
|
|
749
|
+
payment.createMandate = createMandate
|
|
750
|
+
|
|
751
|
+
if (price === 0) {
|
|
752
|
+
payment.status = PaymentStatus.Succeeded;
|
|
753
|
+
payment.paidAt = new Date();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
payment.provider = provider;
|
|
757
|
+
payment.stripeAccountId = stripeAccount?.id ?? null;
|
|
758
|
+
ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...balanceItems.entries()].map(([_, p]) => p));
|
|
759
|
+
|
|
554
760
|
// Add transfer description
|
|
555
761
|
if (payment.method === PaymentMethod.Transfer) {
|
|
556
762
|
// remark: we cannot add the lastnames, these will get added in the frontend when it is decrypted
|
|
@@ -571,27 +777,19 @@ export class PaymentService {
|
|
|
571
777
|
{
|
|
572
778
|
name: groupedNames,
|
|
573
779
|
naam: groupedNames,
|
|
574
|
-
email:
|
|
780
|
+
email: customer.email ?? user?.email ?? '',
|
|
575
781
|
prefix,
|
|
576
782
|
},
|
|
577
783
|
);
|
|
578
784
|
}
|
|
579
785
|
|
|
580
|
-
// Determine the payment provider
|
|
581
|
-
// Throws if invalid
|
|
582
|
-
const { provider, stripeAccount } = await organization.getPaymentProviderFor(payment.method, privatePaymentConfiguration);
|
|
583
|
-
payment.provider = provider;
|
|
584
|
-
payment.stripeAccountId = stripeAccount?.id ?? null;
|
|
585
|
-
ServiceFeeHelper.setServiceFee(payment, organization, serviceFeeType, [...balanceItems.entries()].map(([_, p]) => p));
|
|
586
|
-
|
|
587
786
|
await payment.save();
|
|
787
|
+
|
|
788
|
+
// Create online payment and balance item payments
|
|
588
789
|
let paymentUrl: string | null = null;
|
|
589
790
|
let paymentQRCode: string | null = null;
|
|
590
791
|
const description = organization.name + ' ' + payment.id;
|
|
591
792
|
|
|
592
|
-
// Create balance item payments
|
|
593
|
-
const balanceItemPayments: (BalanceItemPayment & { balanceItem: BalanceItem })[] = [];
|
|
594
|
-
|
|
595
793
|
try {
|
|
596
794
|
for (const [balanceItem, price] of balanceItems) {
|
|
597
795
|
// Create one balance item payment to pay it in one payment
|
|
@@ -600,16 +798,18 @@ export class PaymentService {
|
|
|
600
798
|
balanceItemPayment.paymentId = payment.id;
|
|
601
799
|
balanceItemPayment.organizationId = organization.id;
|
|
602
800
|
balanceItemPayment.price = price;
|
|
603
|
-
await balanceItemPayment.save();
|
|
604
801
|
|
|
605
|
-
|
|
802
|
+
await balanceItemPayment.save();
|
|
606
803
|
}
|
|
607
804
|
|
|
805
|
+
// Update cached balance items pending amount (only created balance items, because those are involved in the payment)
|
|
806
|
+
await BalanceItemService.updatePaidAndPending([...balanceItems.keys()]);
|
|
807
|
+
|
|
608
808
|
// Update balance items
|
|
609
809
|
if (payment.method === PaymentMethod.Transfer) {
|
|
610
810
|
// Send a small reminder email
|
|
611
811
|
try {
|
|
612
|
-
await this.sendTransferEmail(user, organization, payment);
|
|
812
|
+
await this.sendTransferEmail(customer, user?.id ?? null, organization, payment);
|
|
613
813
|
}
|
|
614
814
|
catch (e) {
|
|
615
815
|
console.error('Failed to send transfer email');
|
|
@@ -617,25 +817,36 @@ export class PaymentService {
|
|
|
617
817
|
}
|
|
618
818
|
}
|
|
619
819
|
else if (payment.method !== PaymentMethod.PointOfSale && payment.method !== PaymentMethod.Unknown) {
|
|
620
|
-
if (!checkout.redirectUrl || !checkout.cancelUrl) {
|
|
820
|
+
if ((!checkout.redirectUrl || !checkout.cancelUrl) && !mandate) {
|
|
621
821
|
throw new Error('Should have been caught earlier');
|
|
622
822
|
}
|
|
623
823
|
|
|
624
|
-
const _redirectUrl = new URL(checkout.redirectUrl);
|
|
824
|
+
const _redirectUrl = new URL(checkout.redirectUrl ?? ('https://' + STAMHOOFD.domains.dashboard));
|
|
625
825
|
_redirectUrl.searchParams.set('paymentId', payment.id);
|
|
626
|
-
_redirectUrl.searchParams.set('organizationId', organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
|
|
826
|
+
_redirectUrl.searchParams.set('organizationId', payment.payingOrganizationId ?? organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
|
|
627
827
|
|
|
628
|
-
const _cancelUrl = new URL(checkout.cancelUrl);
|
|
828
|
+
const _cancelUrl = new URL(checkout.cancelUrl?? ('https://' + STAMHOOFD.domains.dashboard));
|
|
629
829
|
_cancelUrl.searchParams.set('paymentId', payment.id);
|
|
630
830
|
_cancelUrl.searchParams.set('cancel', 'true');
|
|
631
|
-
_cancelUrl.searchParams.set('organizationId', organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
|
|
831
|
+
_cancelUrl.searchParams.set('organizationId', payment.payingOrganizationId ?? organization.id); // makes sure the client uses the token associated with this organization when fetching payment polling status
|
|
632
832
|
|
|
633
833
|
const redirectUrl = _redirectUrl.href;
|
|
634
834
|
const cancelUrl = _cancelUrl.href;
|
|
635
835
|
|
|
636
836
|
const webhookUrl = 'https://' + organization.getApiHost() + '/v' + Version + '/payments/' + encodeURIComponent(payment.id) + '?exchange=true';
|
|
637
|
-
|
|
837
|
+
|
|
638
838
|
if (payment.provider === PaymentProvider.Stripe) {
|
|
839
|
+
if (createMandate || mandate) {
|
|
840
|
+
// Already checked, but for security
|
|
841
|
+
throw new Error('Unsupported')
|
|
842
|
+
}
|
|
843
|
+
if (!customer.email) {
|
|
844
|
+
throw new SimpleError({
|
|
845
|
+
code: 'missing_email',
|
|
846
|
+
message: 'Email address is required for online payments via Stripe',
|
|
847
|
+
human: $t('%1SJ')
|
|
848
|
+
})
|
|
849
|
+
}
|
|
639
850
|
const stripeResult = await StripeHelper.createPayment({
|
|
640
851
|
payment,
|
|
641
852
|
stripeAccount,
|
|
@@ -644,65 +855,51 @@ export class PaymentService {
|
|
|
644
855
|
statementDescriptor: organization.name,
|
|
645
856
|
metadata: {
|
|
646
857
|
organization: organization.id,
|
|
647
|
-
user: user
|
|
858
|
+
user: user?.id,
|
|
648
859
|
payment: payment.id,
|
|
649
860
|
},
|
|
650
861
|
i18n: Context.i18n,
|
|
651
|
-
lineItems: balanceItemPayments,
|
|
652
862
|
organization,
|
|
653
863
|
customer: {
|
|
654
|
-
name:
|
|
655
|
-
email:
|
|
864
|
+
name: customer.name ?? names[0]?.name ?? $t(`%Gr`),
|
|
865
|
+
email: customer.email,
|
|
656
866
|
},
|
|
657
867
|
});
|
|
658
868
|
paymentUrl = stripeResult.paymentUrl;
|
|
659
869
|
}
|
|
660
870
|
else if (payment.provider === PaymentProvider.Mollie) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (!token) {
|
|
664
|
-
throw new SimpleError({
|
|
665
|
-
code: '',
|
|
666
|
-
message: $t(`%w3`, { method: PaymentMethodHelper.getName(payment.method) }),
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
const profileId = organization.privateMeta.mollieProfile?.id ?? await token.getProfileId(organization.getHost());
|
|
670
|
-
if (!profileId) {
|
|
671
|
-
throw new SimpleError({
|
|
672
|
-
code: '',
|
|
673
|
-
message: $t(`%w4`, { method: PaymentMethodHelper.getName(payment.method) }),
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
const mollieClient = createMollieClient({ accessToken: await token.getAccessToken() });
|
|
677
|
-
const locale = Context.i18n.locale.replace('-', '_');
|
|
678
|
-
const molliePayment = await mollieClient.payments.create({
|
|
679
|
-
amount: {
|
|
680
|
-
currency: 'EUR',
|
|
681
|
-
value: (totalPrice / 100).toFixed(2),
|
|
682
|
-
},
|
|
683
|
-
method: payment.method == PaymentMethod.Bancontact ? molliePaymentMethod.bancontact : (payment.method == PaymentMethod.iDEAL ? molliePaymentMethod.ideal : molliePaymentMethod.creditcard),
|
|
684
|
-
testmode: organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production',
|
|
685
|
-
profileId,
|
|
686
|
-
description,
|
|
871
|
+
const mollieResult = await MollieService.createPayment({
|
|
872
|
+
payment,
|
|
687
873
|
redirectUrl,
|
|
874
|
+
cancelUrl,
|
|
688
875
|
webhookUrl,
|
|
876
|
+
description,
|
|
689
877
|
metadata: {
|
|
690
|
-
|
|
878
|
+
organization: organization.id,
|
|
879
|
+
user: user?.id,
|
|
880
|
+
payment: payment.id,
|
|
691
881
|
},
|
|
692
|
-
|
|
882
|
+
sellingOrganization: organization,
|
|
883
|
+
payingOrganization: payingOrganization ?? null,
|
|
884
|
+
user,
|
|
885
|
+
customer,
|
|
886
|
+
mandate,
|
|
693
887
|
});
|
|
694
|
-
paymentUrl =
|
|
695
|
-
|
|
696
|
-
// Save payment
|
|
697
|
-
const dbPayment = new MolliePayment();
|
|
698
|
-
dbPayment.paymentId = payment.id;
|
|
699
|
-
dbPayment.mollieId = molliePayment.id;
|
|
700
|
-
await dbPayment.save();
|
|
888
|
+
paymentUrl = mollieResult.paymentUrl;
|
|
701
889
|
}
|
|
702
890
|
else if (payment.provider === PaymentProvider.Payconiq) {
|
|
891
|
+
if (createMandate || mandate) {
|
|
892
|
+
// Already checked, but for security
|
|
893
|
+
throw new Error('Unsupported')
|
|
894
|
+
}
|
|
703
895
|
({ paymentUrl, paymentQRCode } = await PayconiqPayment.createPayment(payment, organization, description, redirectUrl, webhookUrl));
|
|
704
896
|
}
|
|
705
897
|
else if (payment.provider === PaymentProvider.Buckaroo) {
|
|
898
|
+
if (createMandate || mandate) {
|
|
899
|
+
// Already checked, but for security
|
|
900
|
+
throw new Error('Unsupported')
|
|
901
|
+
}
|
|
902
|
+
|
|
706
903
|
// Increase request timeout because buckaroo is super slow (in development)
|
|
707
904
|
Context.request.request?.setTimeout(60 * 1000);
|
|
708
905
|
const buckaroo = new BuckarooHelper(organization.privateMeta?.buckarooSettings?.key ?? '', organization.privateMeta?.buckarooSettings?.secret ?? '', organization.privateMeta.useTestPayments ?? STAMHOOFD.environment !== 'production');
|
|
@@ -725,8 +922,15 @@ export class PaymentService {
|
|
|
725
922
|
throw e;
|
|
726
923
|
}
|
|
727
924
|
|
|
728
|
-
//
|
|
729
|
-
if (payment.
|
|
925
|
+
// TypeScript thinks status cannot change to Failed, but it can.
|
|
926
|
+
if (payment.status === PaymentStatus.Succeeded || (payment.status as PaymentStatus) === PaymentStatus.Failed) {
|
|
927
|
+
// force update
|
|
928
|
+
const updateTo = payment.status
|
|
929
|
+
payment.status = PaymentStatus.Created
|
|
930
|
+
await PaymentService.handlePaymentStatusUpdate(payment, organization, updateTo);
|
|
931
|
+
}
|
|
932
|
+
else if (payment.method === PaymentMethod.Transfer || payment.method === PaymentMethod.PointOfSale || payment.method === PaymentMethod.Unknown || (payment.method === PaymentMethod.DirectDebit && mandate)) {
|
|
933
|
+
// Mark valid (not same as paid) if needed
|
|
730
934
|
let hasBundleDiscount = false;
|
|
731
935
|
for (const [balanceItem] of balanceItems) {
|
|
732
936
|
// Mark valid
|
|
@@ -745,7 +949,6 @@ export class PaymentService {
|
|
|
745
949
|
|
|
746
950
|
return {
|
|
747
951
|
payment,
|
|
748
|
-
balanceItemPayments,
|
|
749
952
|
provider,
|
|
750
953
|
stripeAccount,
|
|
751
954
|
paymentUrl,
|
|
@@ -753,7 +956,12 @@ export class PaymentService {
|
|
|
753
956
|
};
|
|
754
957
|
}
|
|
755
958
|
|
|
756
|
-
static async sendTransferEmail(
|
|
959
|
+
static async sendTransferEmail(customer: PaymentCustomer, userId: string | null, organization: Organization, payment: Payment) {
|
|
960
|
+
const email = customer.dynamicEmail;
|
|
961
|
+
if (!email) {
|
|
962
|
+
console.warn('Skipped sending transfer email because of missing email address', payment.id)
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
757
965
|
const paymentGeneral = await payment.getGeneralStructure();
|
|
758
966
|
const groupIds = paymentGeneral.groupIds;
|
|
759
967
|
|
|
@@ -761,10 +969,10 @@ export class PaymentService {
|
|
|
761
969
|
|
|
762
970
|
const recipients = [
|
|
763
971
|
Recipient.create({
|
|
764
|
-
firstName:
|
|
765
|
-
lastName:
|
|
766
|
-
email:
|
|
767
|
-
userId
|
|
972
|
+
firstName: customer.firstName,
|
|
973
|
+
lastName: customer.lastName,
|
|
974
|
+
email: email,
|
|
975
|
+
userId,
|
|
768
976
|
replacements,
|
|
769
977
|
}),
|
|
770
978
|
];
|
|
@@ -786,43 +994,189 @@ export class PaymentService {
|
|
|
786
994
|
});
|
|
787
995
|
}
|
|
788
996
|
|
|
789
|
-
static async validatePaymentMethod({
|
|
790
|
-
|
|
997
|
+
static async validatePaymentMethod({
|
|
998
|
+
method,
|
|
999
|
+
customer,
|
|
1000
|
+
price,
|
|
1001
|
+
hasNegative,
|
|
1002
|
+
balanceItems,
|
|
1003
|
+
paymentConfiguration,
|
|
1004
|
+
mandate,
|
|
1005
|
+
payingOrganization,
|
|
1006
|
+
sellingOrganization,
|
|
1007
|
+
user,
|
|
1008
|
+
createMandate
|
|
1009
|
+
}: {
|
|
1010
|
+
method: PaymentMethod,
|
|
1011
|
+
customer: PaymentCustomer,
|
|
1012
|
+
price: number,
|
|
1013
|
+
hasNegative: boolean,
|
|
1014
|
+
balanceItems: Map<BalanceItem, number>;
|
|
1015
|
+
paymentConfiguration: PaymentConfiguration,
|
|
1016
|
+
mandate: PaymentMandate | null,
|
|
1017
|
+
payingOrganization: Organization | null,
|
|
1018
|
+
sellingOrganization: Organization,
|
|
1019
|
+
user: User | null,
|
|
1020
|
+
createMandate: boolean
|
|
1021
|
+
}) {
|
|
1022
|
+
if (price === 0) {
|
|
791
1023
|
if (balanceItems.size === 0) {
|
|
792
|
-
|
|
1024
|
+
throw new Error('Empty payment')
|
|
793
1025
|
}
|
|
1026
|
+
|
|
1027
|
+
if (createMandate) {
|
|
1028
|
+
throw new SimpleError({
|
|
1029
|
+
code: 'cannot_create_mandate_without_payment',
|
|
1030
|
+
message: 'Cannot create saved payment method without payment of a small amount',
|
|
1031
|
+
human: $t(`%1Ss`),
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
794
1035
|
// Create an egalizing payment
|
|
795
|
-
|
|
1036
|
+
if (hasNegative) {
|
|
1037
|
+
return {
|
|
1038
|
+
method: PaymentMethod.Unknown,
|
|
1039
|
+
type: PaymentType.Reallocation,
|
|
1040
|
+
mandate: null
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
// Free purchase
|
|
1046
|
+
method: PaymentMethod.Unknown,
|
|
1047
|
+
type: PaymentType.Payment,
|
|
1048
|
+
mandate: null
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (mandate) {
|
|
1053
|
+
if (!paymentConfiguration.enableMandates) {
|
|
1054
|
+
throw new SimpleError({
|
|
1055
|
+
code: 'mandates_disabled',
|
|
1056
|
+
message: 'Cannot pay with mandate',
|
|
1057
|
+
human: $t('%1SK')
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Validate mandate + update payment method based on the mandate
|
|
1062
|
+
if (method !== PaymentMethod.Unknown) {
|
|
1063
|
+
throw new SimpleError({
|
|
1064
|
+
code: 'invalid_data',
|
|
1065
|
+
message: 'Cannot combine setting mandate with method'
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
796
1068
|
|
|
797
|
-
|
|
798
|
-
|
|
1069
|
+
const allMandates = await PaymentMandateService.getMandates({
|
|
1070
|
+
payingOrganization,
|
|
1071
|
+
sellingOrganization,
|
|
1072
|
+
user,
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const existingMandate = allMandates.find(m => m.id === mandate.id && m.provider === mandate.provider);
|
|
1076
|
+
if (!existingMandate) {
|
|
1077
|
+
throw new SimpleError({
|
|
1078
|
+
code: 'not_found',
|
|
1079
|
+
message: 'Mandate not found',
|
|
1080
|
+
human: $t('%1TQ'),
|
|
1081
|
+
field: 'mandateId'
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (existingMandate.status !== PaymentMandateStatus.Valid) {
|
|
1086
|
+
throw new SimpleError({
|
|
1087
|
+
code: 'mandate_not_valid',
|
|
1088
|
+
message: 'Mandate not valid',
|
|
1089
|
+
human: $t('%1QA'),
|
|
1090
|
+
field: 'mandateId'
|
|
1091
|
+
});
|
|
799
1092
|
}
|
|
1093
|
+
|
|
1094
|
+
switch (existingMandate.type) {
|
|
1095
|
+
case PaymentMandateType.CreditCard:
|
|
1096
|
+
return {
|
|
1097
|
+
mandate: existingMandate,
|
|
1098
|
+
method: PaymentMethod.CreditCard,
|
|
1099
|
+
type: PaymentType.Payment
|
|
1100
|
+
}
|
|
1101
|
+
case PaymentMandateType.DirectDebit:
|
|
1102
|
+
return {
|
|
1103
|
+
mandate: existingMandate,
|
|
1104
|
+
method: PaymentMethod.DirectDebit,
|
|
1105
|
+
type: PaymentType.Payment
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
throw new Error('Unsupported mandate type')
|
|
800
1110
|
}
|
|
801
|
-
|
|
1111
|
+
|
|
1112
|
+
if (createMandate) {
|
|
1113
|
+
if (!paymentConfiguration.enableMandates) {
|
|
1114
|
+
throw new SimpleError({
|
|
1115
|
+
code: 'mandates_disabled',
|
|
1116
|
+
message: 'Cannot pay with mandate',
|
|
1117
|
+
human: $t('%1R1')
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (method === PaymentMethod.Unknown) {
|
|
802
1123
|
throw new SimpleError({
|
|
803
1124
|
code: 'invalid_data',
|
|
804
1125
|
message: $t(`%vy`),
|
|
805
1126
|
});
|
|
806
1127
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1128
|
+
|
|
1129
|
+
// Validate payment method
|
|
1130
|
+
const allowedPaymentMethods = paymentConfiguration.getAvailablePaymentMethods({
|
|
1131
|
+
amount: price,
|
|
1132
|
+
customer: customer,
|
|
1133
|
+
forMandate: createMandate
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
if (!allowedPaymentMethods.includes(method)) {
|
|
1137
|
+
throw new SimpleError({
|
|
1138
|
+
code: 'invalid_payment_method',
|
|
1139
|
+
message: $t(`%vp`),
|
|
812
1140
|
});
|
|
1141
|
+
}
|
|
813
1142
|
|
|
814
|
-
|
|
1143
|
+
return {
|
|
1144
|
+
method,
|
|
1145
|
+
type: PaymentType.Payment,
|
|
1146
|
+
mandate: null
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
static getDefaultCompanyForOrganization(sellingOrganization: Organization) {
|
|
1151
|
+
return sellingOrganization.meta.companies[0];
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
static getVATExcempt({ customer, sellingOrganization }: { customer: PaymentCustomer | null; sellingOrganization: Organization; }) {
|
|
1155
|
+
// Validate VAT rates for this customer
|
|
1156
|
+
const seller = this.getDefaultCompanyForOrganization(sellingOrganization)
|
|
1157
|
+
if (seller && seller.VATNumber && seller.address && customer && customer.company) {
|
|
1158
|
+
// B2B validation
|
|
1159
|
+
if (!customer.company.address) {
|
|
815
1160
|
throw new SimpleError({
|
|
816
|
-
code: '
|
|
817
|
-
message:
|
|
1161
|
+
code: 'missing_field',
|
|
1162
|
+
message: 'Company address missing',
|
|
1163
|
+
human: $t('%1LH'),
|
|
1164
|
+
field: 'customer.company.address',
|
|
818
1165
|
});
|
|
819
1166
|
}
|
|
1167
|
+
|
|
1168
|
+
// Reverse charged vat applicable?
|
|
1169
|
+
if (customer.company.address.country !== seller.address.country && customer.company.VATNumber) {
|
|
1170
|
+
return VATExcemptReason.IntraCommunity;
|
|
1171
|
+
}
|
|
820
1172
|
}
|
|
1173
|
+
|
|
1174
|
+
return null
|
|
821
1175
|
}
|
|
822
1176
|
|
|
823
|
-
static
|
|
1177
|
+
static validateVATRates({ customer, organization, balanceItems }: { customer: PaymentCustomer; organization: Organization; balanceItems: Map<BalanceItem, number> }) {
|
|
824
1178
|
// Validate VAT rates for this customer
|
|
825
|
-
const seller = organization
|
|
1179
|
+
const seller = this.getDefaultCompanyForOrganization(organization)
|
|
826
1180
|
if (seller && seller.VATNumber && seller.address && customer.company) {
|
|
827
1181
|
// B2B validation
|
|
828
1182
|
if (!customer.company.address) {
|
|
@@ -835,7 +1189,7 @@ export class PaymentService {
|
|
|
835
1189
|
}
|
|
836
1190
|
|
|
837
1191
|
// Reverse charged vat applicable?
|
|
838
|
-
if (customer.company.address.country !== seller.address.country) {
|
|
1192
|
+
if (customer.company.address.country !== seller.address.country && customer.company.VATNumber) {
|
|
839
1193
|
// Check VAT Exempt is set on each an every balance item with a non-zero price
|
|
840
1194
|
for (const [item] of balanceItems) {
|
|
841
1195
|
if (item.VATExcempt !== VATExcemptReason.IntraCommunity) {
|
|
@@ -902,5 +1256,7 @@ export class PaymentService {
|
|
|
902
1256
|
}
|
|
903
1257
|
}
|
|
904
1258
|
}
|
|
1259
|
+
|
|
1260
|
+
return {seller}
|
|
905
1261
|
}
|
|
906
1262
|
};
|