@stamhoofd/backend 2.126.2 → 2.127.1

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,14 +1,13 @@
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';
10
+ import { SQL } from '@stamhoofd/sql';
12
11
 
13
12
  export class ApplicationFeeDetails {
14
13
  transferFee = 0;
@@ -120,12 +119,12 @@ export class StripeReportInvoicer {
120
119
  }
121
120
 
122
121
  async generateInvoices(sellingOrganization: Organization, reference: string) {
123
- const invoices: Invoice[] = [];
122
+ const payments: Payment[] = [];
124
123
  for (const [account, details] of this.report.applicationFeesPerAccount) {
125
124
  try {
126
- const invoice = await this.generateInvoice(sellingOrganization, reference, account, details);
127
- if (invoice) {
128
- invoices.push(invoice);
125
+ const payment = await this.generateInvoice(sellingOrganization, reference, account, details);
126
+ if (payment) {
127
+ payments.push(payment);
129
128
  }
130
129
  } catch (e) {
131
130
  console.error(e);
@@ -136,11 +135,17 @@ export class StripeReportInvoicer {
136
135
  });
137
136
  }
138
137
  }
139
- return invoices;
138
+ return payments;
140
139
  }
141
140
 
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);
141
+ static async hasPayment(sellingOrganization: Organization, reference: string) {
142
+ return !!await Payment.select()
143
+ .where('organizationId', sellingOrganization.id)
144
+ .where('reference', reference)
145
+ .where('status', PaymentStatus.Succeeded)
146
+ .where('method', PaymentMethod.AccountDeductions)
147
+ .where('provider', PaymentProvider.Stripe)
148
+ .first(false);
144
149
  }
145
150
 
146
151
  async generateInvoice(sellingOrganization: Organization, reference: string, accountId: string, applicationFee: ApplicationFeeDetails) {
@@ -174,46 +179,45 @@ export class StripeReportInvoicer {
174
179
  });
175
180
  }
176
181
 
177
- const existingInvoices = await Invoice.select()
182
+ const existingPayments = await Payment.select()
178
183
  .where('organizationId', sellingOrganization.id)
179
184
  .where('payingOrganizationId', organization.id)
185
+ .where(
186
+ // required because the same organization can have multiple stripe accounts. Null = legacy
187
+ SQL.where('stripeAccountId', stripeAccount.id)
188
+ .or('stripeAccountId', null),
189
+ )
180
190
  .where('reference', reference)
181
- .where('number', '!=', null)
191
+ .where('method', PaymentMethod.AccountDeductions)
192
+ .where('provider', PaymentProvider.Stripe)
193
+ .where('status', PaymentStatus.Succeeded)
182
194
  .fetch();
183
195
 
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) {
196
+ for (const i of existingPayments) {
197
+ if (i.price !== applicationFee.amount) {
193
198
  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,
199
+ code: 'payment_already_exists_with_different_amount',
200
+ message: 'Payment already exists with different amount ' + organization.id + ' expected ' + applicationFee.amount + ' got ' + i.price + ' in payment ' + i.id,
196
201
  });
197
202
  }
203
+ }
198
204
 
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
- }
205
+ if (existingPayments.length) {
206
+ console.warn('Tried to create an already created stripe invoice payment ' + existingPayments.map(p => p.id).join(', '));
207
+ return;
211
208
  }
212
209
 
213
210
  const customer = PaymentCustomer.create({
214
- company: sellingOrganization.defaultCompanies[0],
211
+ company: organization.defaultCompanies[0],
215
212
  });
216
213
 
214
+ if (customer.company!.isSameEntity(seller)) {
215
+ throw new SimpleError({
216
+ code: 'same_customer',
217
+ message: 'Cannot invoice self',
218
+ });
219
+ }
220
+
217
221
  const balanceItems: BalanceItem[] = [];
218
222
 
219
223
  if (applicationFee.serviceFee !== 0) {
@@ -292,9 +296,10 @@ export class StripeReportInvoicer {
292
296
  payment.status = PaymentStatus.Pending;
293
297
  payment.price = applicationFee.amount;
294
298
  payment.roundingAmount = 0;
295
- payment.method = PaymentMethod.Unknown;
299
+ payment.method = PaymentMethod.AccountDeductions;
296
300
  payment.type = PaymentType.Payment;
297
301
  payment.createMandate = null;
302
+ payment.reference = reference;
298
303
 
299
304
  payment.provider = PaymentProvider.Stripe;
300
305
  payment.stripeAccountId = stripeAccount.id;
@@ -310,23 +315,8 @@ export class StripeReportInvoicer {
310
315
  await balanceItemPayment.save();
311
316
  }
312
317
 
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;
318
+ await PaymentService.handlePaymentStatusUpdate(payment, sellingOrganization, PaymentStatus.Succeeded, this.start);
319
+ return payment;
330
320
  }
331
321
  }
332
322
 
@@ -344,7 +334,7 @@ export class StripeInvoicer {
344
334
  }
345
335
 
346
336
  // Loops all months until all invoices are generated
347
- async generateAllInvoices(sellingOrganization: Organization, options?: { force?: boolean; start?: Date }) {
337
+ async generateAllInvoices(sellingOrganization: Organization, options?: { force?: boolean; start?: Date; forceLast?: boolean }) {
348
338
  const startMonth = options?.start ?? new Date(2026, 0, 1);
349
339
  const stopAt = new Date(Date.now() + (STAMHOOFD.environment === 'production' ? (-60 * 60 * 24 * 1000) : (60 * 60 * 24 * 1000 * 31))); // One day margin before creating invoices
350
340
  let currentMonth = new Date(startMonth);
@@ -356,8 +346,11 @@ export class StripeInvoicer {
356
346
  // Stop
357
347
  break;
358
348
  }
359
- await this.generateInvoices(sellingOrganization, currentMonth, options);
360
- currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
349
+ const nextMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
350
+ const { end: endNext } = StripeInvoicer.getMonthUnixStartEnd(currentMonth);
351
+ const isLastMonth = endNext >= stopAt.getTime() / 1000;
352
+ await this.generateInvoices(sellingOrganization, currentMonth, { ...options, force: (isLastMonth && !!options?.forceLast) || !!options?.force });
353
+ currentMonth = nextMonth;
361
354
  }
362
355
  }
363
356
 
@@ -367,7 +360,7 @@ export class StripeInvoicer {
367
360
 
368
361
  try {
369
362
  await QueueHandler.schedule(reference, async () => {
370
- if (!options?.force && await StripeReportInvoicer.hasInvoice(sellingOrganization, reference)) {
363
+ if (!options?.force && await StripeReportInvoicer.hasPayment(sellingOrganization, reference)) {
371
364
  console.log('Already invoiced month', reference);
372
365
  return;
373
366
  }
@@ -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
  }