@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.
Files changed (61) hide show
  1. package/package.json +12 -12
  2. package/src/audit-logs/RegistrationInvitationLogger.ts +46 -0
  3. package/src/audit-logs/init.ts +2 -0
  4. package/src/crons/index.ts +2 -0
  5. package/src/crons/invoices.ts +166 -0
  6. package/src/crons/mollie-chargebacks.ts +87 -0
  7. package/src/crons.ts +47 -10
  8. package/src/email-recipient-loaders/payments.ts +84 -41
  9. package/src/endpoints/global/groups/GetGroupsCountEndpoint.ts +51 -0
  10. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +22 -3
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -0
  12. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsCountEndpoint.ts +45 -0
  13. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +495 -0
  14. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.ts +216 -0
  15. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +405 -0
  16. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +168 -0
  17. package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +15 -0
  18. package/src/endpoints/{global → organization/dashboard}/billing/DeactivatePackageEndpoint.ts +3 -4
  19. package/src/endpoints/organization/dashboard/billing/DeleteOrganizationMandateEndpoint.ts +62 -0
  20. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceCollectionEndpoint.ts +56 -0
  21. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceEndpoint.ts +42 -19
  22. package/src/endpoints/organization/dashboard/billing/GetOrganizationMandatesEndpoint.ts +64 -0
  23. package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.ts +11 -3
  24. package/src/endpoints/organization/dashboard/billing/OrganizationCheckoutEndpoint.ts +308 -0
  25. package/src/endpoints/organization/dashboard/billing/PatchOrganizationMandatesEndpoint.ts +94 -0
  26. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +7 -0
  27. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +5 -4
  28. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +7 -2
  29. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +17 -8
  30. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +3 -3
  31. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +127 -0
  32. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +13 -4
  33. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +7 -1
  34. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  35. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +13 -11
  36. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +14 -19
  37. package/src/helpers/AdminPermissionChecker.ts +11 -3
  38. package/src/helpers/AuthenticatedStructures.ts +94 -6
  39. package/src/helpers/FinancialSupportHelper.ts +21 -0
  40. package/src/helpers/RecordAnswerHelper.test.ts +746 -0
  41. package/src/helpers/RecordAnswerHelper.ts +116 -0
  42. package/src/helpers/StripeHelper.ts +2 -3
  43. package/src/helpers/ViesHelper.ts +7 -3
  44. package/src/seeds/1750090030-records-configuration.ts +68 -3
  45. package/src/seeds/1752848561-groups-registration-periods.ts +26 -2
  46. package/src/seeds/1779121239-default-invoice-email-template.sql +3 -0
  47. package/src/services/BalanceItemService.ts +12 -16
  48. package/src/services/InvoiceService.ts +372 -72
  49. package/src/services/MollieService.ts +537 -0
  50. package/src/services/PaymentMandateService.ts +214 -0
  51. package/src/services/PaymentService.ts +578 -222
  52. package/src/services/PlatformMembershipService.ts +1 -1
  53. package/src/services/RegistrationService.ts +66 -5
  54. package/src/services/STPackageService.ts +0 -7
  55. package/src/services/data/invoice.hbs.html +686 -0
  56. package/src/sql-filters/groups.ts +11 -1
  57. package/src/sql-filters/payments.ts +5 -0
  58. package/src/sql-filters/registration-invitations.ts +90 -0
  59. package/src/sql-sorters/registration-invitations.ts +36 -0
  60. package/vitest.config.js +1 -0
  61. package/src/endpoints/global/billing/ActivatePackagesEndpoint.ts +0 -216
@@ -1,19 +1,26 @@
1
- import { createMollieClient, PaymentMethod as molliePaymentMethod, PaymentStatus as MolliePaymentStatus } from '@mollie/api-client';
2
- import { SimpleError } from '@simonbackx/simple-errors';
3
- import type { BalanceItem, Member, User } from '@stamhoofd/models';
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 ?? await Organization.getByID(payment.organizationId);
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
- // check status via mollie
162
- const molliePayments = await MolliePayment.where({ paymentId: payment.id }, { limit: 1 });
163
- if (molliePayments.length === 1) {
164
- const molliePayment = molliePayments[0];
165
- // check status
166
- const token = await MollieToken.getTokenFor(organization.id);
167
-
168
- if (token) {
169
- try {
170
- const mollieClient = createMollieClient({ accessToken: await token.getAccessToken() });
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 without mollie token as expired', payment.id);
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
- else {
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 without mollie payments as expired', payment.id);
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 == PaymentProvider.Buckaroo) {
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.Stripe) {
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 balance item with the difference. So the rounding always matches.
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 round(payment: Payment) {
364
- const amount = payment.price;
365
- const rounded = Payment.roundPrice(payment.price);
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 payment ' + payment.id);
390
+ throw new Error('Unexpected rounding difference of ' + difference + ' for price ' + amount.toString());
374
391
  }
375
392
 
376
- payment.roundingAmount = difference;
377
-
378
- // Change payment total price
379
- payment.price += difference;
393
+ return {
394
+ price: amount + difference,
395
+ roundingAmount: difference
396
+ }
380
397
  }
381
398
 
382
- static async createPayment({ balanceItems, organization, user, members, checkout, payingOrganization, serviceFeeType }: {
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 (totalPrice < 0) {
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
- if (checkout.totalPrice !== null && totalPrice !== checkout.totalPrice) {
450
- // Changed!
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(totalPrice) }),
476
+ message: $t(`%vk`, { total: Formatter.price(price) }),
454
477
  });
455
478
  }
479
+ }
456
480
 
457
- const payment = new Payment();
458
-
459
- // Who will receive this money?
460
- payment.organizationId = organization.id;
461
-
462
- // Who paid
463
- payment.payingUserId = user.id;
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
- payment.customer = PaymentCustomer.create({
468
- firstName: user.firstName,
469
- lastName: user.lastName,
470
- email: user.email,
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 (totalPrice !== 0 || hasNegative || checkout.customer) {
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
- payment.customer.company = foundCompany;
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.meta.companies[0];
547
+ const company = this.getDefaultCompanyForOrganization(payingOrganization);
518
548
  if (company) {
519
- payment.customer.company = company;
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
- // Validate VAT rates for this customer
525
- await this.validateVATRates({ customer: payment.customer, organization, balanceItems });
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
- payment.status = PaymentStatus.Created;
528
- payment.paidAt = null;
529
- payment.price = totalPrice;
530
- PaymentService.round(payment);
531
- totalPrice = payment.price;
567
+ return { customer, prefix };
568
+ }
532
569
 
533
- if (totalPrice === 0) {
534
- payment.status = PaymentStatus.Succeeded;
535
- payment.paidAt = new Date();
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
- // Validate payment method after customer is defined
539
- const paymentConfiguration = organization.meta.registrationPaymentConfiguration;
540
- const privatePaymentConfiguration = organization.privateMeta.registrationPaymentConfiguration;
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
- payment.method = checkout.paymentMethod ?? PaymentMethod.Unknown;
543
- await this.validatePaymentMethod({ payment, balanceItems, paymentConfiguration });
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
- // Validate URL's for online payments before saving the payment
546
- if ((payment.method !== PaymentMethod.Transfer && payment.method !== PaymentMethod.PointOfSale && payment.method !== PaymentMethod.Unknown) && (!checkout.redirectUrl || !checkout.cancelUrl)) {
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: user.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
- balanceItemPayments.push(balanceItemPayment.setRelation(BalanceItemPayment.balanceItem, balanceItem));
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.id,
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: user.name ?? names[0]?.name ?? $t(`%Gr`),
655
- email: user.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
- // Mollie payment
662
- const token = await MollieToken.getTokenFor(organization.id);
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
- paymentId: payment.id,
878
+ organization: organization.id,
879
+ user: user?.id,
880
+ payment: payment.id,
691
881
  },
692
- locale: ['en_US', 'en_GB', 'nl_NL', 'nl_BE', 'fr_FR', 'fr_BE', 'de_DE', 'de_AT', 'de_CH', 'es_ES', 'ca_ES', 'pt_PT', 'it_IT', 'nb_NO', 'sv_SE', 'fi_FI', 'da_DK', 'is_IS', 'hu_HU', 'pl_PL', 'lv_LV', 'lt_LT'].includes(locale) ? (locale as any) : null,
882
+ sellingOrganization: organization,
883
+ payingOrganization: payingOrganization ?? null,
884
+ user,
885
+ customer,
886
+ mandate,
693
887
  });
694
- paymentUrl = molliePayment.getCheckoutUrl();
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
- // Mark valid if needed
729
- if (payment.method === PaymentMethod.Transfer || payment.method === PaymentMethod.PointOfSale || payment.method === PaymentMethod.Unknown) {
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(user: User, organization: Organization, payment: Payment) {
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: user.firstName,
765
- lastName: user.lastName,
766
- email: user.email,
767
- userId: user.id,
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({ payment, balanceItems, paymentConfiguration }: { payment: Payment; balanceItems: Map<BalanceItem, number>; paymentConfiguration: PaymentConfiguration }) {
790
- if (payment.price === 0) {
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
- return;
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
- payment.method = PaymentMethod.Unknown;
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
- if ([...balanceItems.values()].find(b => b < 0)) {
798
- payment.type = PaymentType.Reallocation;
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
- else if (payment.method === PaymentMethod.Unknown) {
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
- else {
808
- // Validate payment method
809
- const allowedPaymentMethods = paymentConfiguration.getAvailablePaymentMethods({
810
- amount: payment.price,
811
- customer: payment.customer,
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
- if (!allowedPaymentMethods.includes(payment.method)) {
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: 'invalid_payment_method',
817
- message: $t(`%vp`),
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 async validateVATRates({ customer, organization, balanceItems }: { customer: PaymentCustomer; organization: Organization; balanceItems: Map<BalanceItem, number> }) {
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.meta.companies[0];
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
  };