@stamhoofd/backend 2.126.2 → 2.127.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.
@@ -1,13 +1,11 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { Email } from '@stamhoofd/email';
3
- import { BalanceItem, BalanceItemPayment, Invoice, Organization, Payment, StripeAccount, User } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemPayment, Organization, Payment, StripeAccount, User } from '@stamhoofd/models';
4
4
  import { QueueHandler } from '@stamhoofd/queues';
5
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPaymentProviderName, InvoiceStruct, PaymentCustomer, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, TranslatedString } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPaymentProviderName, PaymentCustomer, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, TranslatedString } from '@stamhoofd/structures';
6
6
  import { Formatter } from '@stamhoofd/utility';
7
7
  import Stripe from 'stripe';
8
- import { InvoiceService } from '../services/InvoiceService.js';
9
8
  import { PaymentService } from '../services/PaymentService.js';
10
- import { AuthenticatedStructures } from './AuthenticatedStructures.js';
11
9
  import { VATService } from '../services/VATService.js';
12
10
 
13
11
  export class ApplicationFeeDetails {
@@ -120,12 +118,12 @@ export class StripeReportInvoicer {
120
118
  }
121
119
 
122
120
  async generateInvoices(sellingOrganization: Organization, reference: string) {
123
- const invoices: Invoice[] = [];
121
+ const payments: Payment[] = [];
124
122
  for (const [account, details] of this.report.applicationFeesPerAccount) {
125
123
  try {
126
- const invoice = await this.generateInvoice(sellingOrganization, reference, account, details);
127
- if (invoice) {
128
- invoices.push(invoice);
124
+ const payment = await this.generateInvoice(sellingOrganization, reference, account, details);
125
+ if (payment) {
126
+ payments.push(payment);
129
127
  }
130
128
  } catch (e) {
131
129
  console.error(e);
@@ -136,11 +134,11 @@ export class StripeReportInvoicer {
136
134
  });
137
135
  }
138
136
  }
139
- return invoices;
137
+ return payments;
140
138
  }
141
139
 
142
- static async hasInvoice(sellingOrganization: Organization, reference: string) {
143
- return !!await Invoice.select().where('organizationId', sellingOrganization.id).where('reference', reference).where('number', '!=', null).first(false);
140
+ static async hasPayment(sellingOrganization: Organization, reference: string) {
141
+ return !!await Payment.select().where('organizationId', sellingOrganization.id).where('reference', reference).where('status', PaymentStatus.Succeeded).first(false);
144
142
  }
145
143
 
146
144
  async generateInvoice(sellingOrganization: Organization, reference: string, accountId: string, applicationFee: ApplicationFeeDetails) {
@@ -174,46 +172,38 @@ export class StripeReportInvoicer {
174
172
  });
175
173
  }
176
174
 
177
- const existingInvoices = await Invoice.select()
175
+ const existingPayments = await Payment.select()
178
176
  .where('organizationId', sellingOrganization.id)
179
177
  .where('payingOrganizationId', organization.id)
180
178
  .where('reference', reference)
181
- .where('number', '!=', null)
179
+ .where('status', PaymentStatus.Succeeded)
182
180
  .fetch();
183
181
 
184
- for (const i of existingInvoices) {
185
- if (!i.stripeAccountId && i.totalWithVAT === applicationFee.amount) {
186
- i.stripeAccountId = accountId;
187
-
188
- console.log('Set stripe account id for invoice ' + i.number + ' to ' + accountId);
189
- await i.save();
190
- }
191
-
192
- if (!i.stripeAccountId) {
182
+ for (const i of existingPayments) {
183
+ if (i.price !== applicationFee.amount) {
193
184
  throw new SimpleError({
194
- code: 'invoice_already_exists_with_different_amount',
195
- message: 'Invoice without account id already exists with different amount ' + organization.id + ' expected ' + applicationFee.amount + ' got ' + i.totalWithVAT + ' in invoice ' + i.number,
185
+ code: 'payment_already_exists_with_different_amount',
186
+ message: 'Payment already exists with different amount ' + organization.id + ' expected ' + applicationFee.amount + ' got ' + i.price + ' in payment ' + i.id,
196
187
  });
197
188
  }
189
+ }
198
190
 
199
- if (i.stripeAccountId === accountId) {
200
- if (i.totalWithVAT !== applicationFee.amount) {
201
- throw new SimpleError({
202
- code: 'invoice_already_exists_with_different_amount',
203
- message: 'Invoice already exists with different amount ' + organization.id + ' expected ' + applicationFee.amount + ' got ' + i.totalWithVAT + ' in invoice ' + i.number,
204
- });
205
- }
206
-
207
- // Already invoiced
208
- console.warn('Tried to invoice an already invoiced.');
209
- return;
210
- }
191
+ if (existingPayments.length) {
192
+ console.warn('Tried to create an already created stripe invoice payment ' + existingPayments.map(p => p.id).join(', '));
193
+ return;
211
194
  }
212
195
 
213
196
  const customer = PaymentCustomer.create({
214
- company: sellingOrganization.defaultCompanies[0],
197
+ company: organization.defaultCompanies[0],
215
198
  });
216
199
 
200
+ if (customer.company!.isSameEntity(seller)) {
201
+ throw new SimpleError({
202
+ code: 'same_customer',
203
+ message: 'Cannot invoice self',
204
+ });
205
+ }
206
+
217
207
  const balanceItems: BalanceItem[] = [];
218
208
 
219
209
  if (applicationFee.serviceFee !== 0) {
@@ -292,9 +282,10 @@ export class StripeReportInvoicer {
292
282
  payment.status = PaymentStatus.Pending;
293
283
  payment.price = applicationFee.amount;
294
284
  payment.roundingAmount = 0;
295
- payment.method = PaymentMethod.Unknown;
285
+ payment.method = PaymentMethod.AccountDeductions;
296
286
  payment.type = PaymentType.Payment;
297
287
  payment.createMandate = null;
288
+ payment.reference = reference;
298
289
 
299
290
  payment.provider = PaymentProvider.Stripe;
300
291
  payment.stripeAccountId = stripeAccount.id;
@@ -310,23 +301,8 @@ export class StripeReportInvoicer {
310
301
  await balanceItemPayment.save();
311
302
  }
312
303
 
313
- // Create invoice (before payment, to prevent auto-invoicing)
314
- const generalStructs = await AuthenticatedStructures.paymentsGeneral([payment], false);
315
-
316
- const invoiceStruct = InvoiceStruct.create({
317
- seller,
318
- customer: payment.customer,
319
- payments: generalStructs,
320
- });
321
- invoiceStruct.buildFromPayments();
322
-
323
- invoiceStruct.reference = reference;
324
- invoiceStruct.stripeAccountId = accountId;
325
-
326
- await PaymentService.handlePaymentStatusUpdate(payment, sellingOrganization, PaymentStatus.Succeeded, this.end);
327
- const invoice = await InvoiceService.createFrom(sellingOrganization, invoiceStruct);
328
-
329
- return invoice;
304
+ await PaymentService.handlePaymentStatusUpdate(payment, sellingOrganization, PaymentStatus.Succeeded, this.start);
305
+ return payment;
330
306
  }
331
307
  }
332
308
 
@@ -367,7 +343,7 @@ export class StripeInvoicer {
367
343
 
368
344
  try {
369
345
  await QueueHandler.schedule(reference, async () => {
370
- if (!options?.force && await StripeReportInvoicer.hasInvoice(sellingOrganization, reference)) {
346
+ if (!options?.force && await StripeReportInvoicer.hasPayment(sellingOrganization, reference)) {
371
347
  console.log('Already invoiced month', reference);
372
348
  return;
373
349
  }
@@ -299,6 +299,23 @@ export class BalanceItemService {
299
299
  }
300
300
  }
301
301
 
302
+ /**
303
+ * Bump the updatedAt of the orders these balance items belong to. Clients keep a local copy
304
+ * of all orders (for offline support) and only pull in orders with a newer updatedAt, so this
305
+ * makes sure changes to the balance items of an order (or their payments) reach the clients.
306
+ */
307
+ static async markOrdersUpdated(items: BalanceItem[]) {
308
+ const orderIds = Formatter.uniqueArray(items.flatMap(item => item.orderId ? [item.orderId] : []));
309
+
310
+ for (const orderId of orderIds) {
311
+ const order = await Order.getByID(orderId);
312
+ if (order) {
313
+ order.markUpdated();
314
+ await order.save();
315
+ }
316
+ }
317
+ }
318
+
302
319
  static async undoPaid(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
303
320
  // If order
304
321
  if (balanceItem.orderId) {
@@ -327,7 +344,7 @@ export class BalanceItemService {
327
344
 
328
345
  static async markFailed(balanceItem: BalanceItem, payment: Payment, organization: Organization) {
329
346
  // If order
330
- if (balanceItem.orderId) {
347
+ if (balanceItem.orderId && payment.type === PaymentType.Payment) {
331
348
  const order = await Order.getByID(balanceItem.orderId);
332
349
  if (order) {
333
350
  await order.onPaymentFailed(payment, organization);
@@ -343,7 +360,7 @@ export class BalanceItemService {
343
360
 
344
361
  static async undoFailed(balanceItem: BalanceItem, payment: Payment, organization: Organization) {
345
362
  // If order
346
- if (balanceItem.orderId) {
363
+ if (balanceItem.orderId && payment.type === PaymentType.Payment) {
347
364
  const order = await Order.getByID(balanceItem.orderId);
348
365
  if (order) {
349
366
  await order.undoPaymentFailed(payment, organization);
@@ -61,6 +61,15 @@ export class InvoiceService {
61
61
  });
62
62
  }
63
63
 
64
+ if (struct.customer.company && struct.customer.company.isSameEntity(struct.seller)) {
65
+ throw new SimpleError({
66
+ code: 'invalid_customer',
67
+ message: 'Cannot self invoice',
68
+ human: $t('%ZaI'),
69
+ statusCode: 400,
70
+ });
71
+ }
72
+
64
73
  model.seller = struct.seller;
65
74
  model.organizationId = organization.id;
66
75
  model.payingOrganizationId = struct.payingOrganizationId;
@@ -1,5 +1,5 @@
1
1
  import type { Mandate, Payment as MolliePaymentType } from '@mollie/api-client';
2
- import { ApiMode, createMollieClient, MandateMethod, MandateStatus, PaymentMethod as molliePaymentMethod, PaymentStatus as molliePaymentStatus, OnboardingStatus, ProfileStatus, SequenceType } from '@mollie/api-client';
2
+ import { ApiMode, createMollieClient, MandateMethod, MandateStatus, MollieApiError, PaymentMethod as molliePaymentMethod, PaymentStatus as molliePaymentStatus, OnboardingStatus, ProfileStatus, RefundStatus, SequenceType } from '@mollie/api-client';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import type { Payment, User } from '@stamhoofd/models';
5
5
  import type { Organization } from '@stamhoofd/models';
@@ -513,6 +513,83 @@ export class MollieService {
513
513
  };
514
514
  }
515
515
 
516
+ /**
517
+ * Create a refund at Mollie for a payment that was paid via Mollie.
518
+ * The amount is a (positive) price in the internal format (per ten thousand).
519
+ * Provider errors (e.g. amount higher than the refundable remainder, insufficient balance)
520
+ * are converted to SimpleErrors that can be shown to the user.
521
+ */
522
+ /**
523
+ * Map a Mollie refund status to a payment status. A refund only succeeds once Mollie
524
+ * reports it as refunded: before that it can still fail or be canceled.
525
+ */
526
+ static refundStatusToPaymentStatus(status: RefundStatus): PaymentStatus {
527
+ switch (status) {
528
+ case RefundStatus.refunded:
529
+ return PaymentStatus.Succeeded;
530
+ case RefundStatus.failed:
531
+ case RefundStatus.canceled:
532
+ return PaymentStatus.Failed;
533
+ default:
534
+ // queued, pending, processing
535
+ return PaymentStatus.Pending;
536
+ }
537
+ }
538
+
539
+ async createRefund({ payment, amount, description, metadata }: {
540
+ payment: Payment;
541
+ amount: number;
542
+ description: string;
543
+ metadata?: { [key: string]: string | undefined };
544
+ }): Promise<{ mollieRefundId: string; createdAt: Date; status: PaymentStatus }> {
545
+ const molliePayment = await MolliePayment.select().where('paymentId', payment.id).first(false);
546
+ if (!molliePayment) {
547
+ throw new SimpleError({
548
+ code: 'refund_not_supported',
549
+ message: 'Mollie payment not found for payment ' + payment.id,
550
+ human: $t('%Za1'),
551
+ });
552
+ }
553
+
554
+ const data: Parameters<typeof this.client.paymentRefunds.create>[0] = {
555
+ paymentId: molliePayment.mollieId,
556
+ amount: {
557
+ currency: 'EUR',
558
+ value: (Math.round(amount / 100) / 100).toFixed(2),
559
+ },
560
+ description,
561
+ metadata,
562
+ testmode: this.testMode,
563
+ };
564
+
565
+ console.log('Creating Mollie refund', data);
566
+
567
+ try {
568
+ const refund = await this.client.paymentRefunds.create(data);
569
+ console.log('Mollie refund response', refund);
570
+
571
+ return {
572
+ mollieRefundId: refund.id,
573
+ createdAt: new Date(refund.createdAt),
574
+ status: MollieService.refundStatusToPaymentStatus(refund.status),
575
+ };
576
+ } catch (e) {
577
+ console.error('Failed to create Mollie refund for payment ' + payment.id, e);
578
+
579
+ if (e instanceof MollieApiError) {
580
+ // E.g. the amount is higher than the remaining refundable amount, the payment
581
+ // was already (fully) refunded, or there is not enough balance available
582
+ throw new SimpleError({
583
+ code: 'refund_failed',
584
+ message: 'Mollie refused to create the refund: ' + e.message,
585
+ human: $t('%ZZx', { message: e.message }),
586
+ statusCode: 400,
587
+ });
588
+ }
589
+ throw e;
590
+ }
591
+ }
592
+
516
593
  async getStatus(payment: Payment, cancel = false) {
517
594
  const molliePayment = await MolliePayment.select().where('paymentId', payment.id).first(false);
518
595
  if (!molliePayment) {
@@ -527,4 +604,34 @@ export class MollieService {
527
604
 
528
605
  return this.getStatusFor(mollieData, payment, cancel);
529
606
  }
607
+
608
+ /**
609
+ * Get the status of a refund payment (type Refund). The linked MolliePayment row contains the
610
+ * Mollie refund id (re_...), while the source payment's row contains the Mollie payment id
611
+ * (tr_...) that is needed to address the refund in the Mollie API.
612
+ */
613
+ async getRefundStatus(refundPayment: Payment): Promise<{ status: PaymentStatus }> {
614
+ const mollieRefund = await MolliePayment.select().where('paymentId', refundPayment.id).first(false);
615
+ if (!mollieRefund) {
616
+ throw new Error('Mollie refund not found for payment ' + refundPayment.id);
617
+ }
618
+
619
+ if (!refundPayment.reversingPaymentId) {
620
+ throw new Error('Refund payment ' + refundPayment.id + ' is missing a reversing payment id');
621
+ }
622
+
623
+ const sourceMolliePayment = await MolliePayment.select().where('paymentId', refundPayment.reversingPaymentId).first(false);
624
+ if (!sourceMolliePayment) {
625
+ throw new Error('Mollie payment not found for source payment ' + refundPayment.reversingPaymentId);
626
+ }
627
+
628
+ const refund = await this.client.paymentRefunds.get(mollieRefund.mollieId, {
629
+ paymentId: sourceMolliePayment.mollieId,
630
+ testmode: this.testMode,
631
+ });
632
+
633
+ return {
634
+ status: MollieService.refundStatusToPaymentStatus(refund.status),
635
+ };
636
+ }
530
637
  }