@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.
- package/package.json +17 -17
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -6
- package/src/crons/mollie-refunds.test.ts +310 -0
- package/src/crons/mollie-refunds.ts +157 -0
- package/src/crons/stripe-invoices.ts +4 -3
- package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +3 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.ts +12 -1
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +148 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.test.ts +490 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +72 -9
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +209 -0
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +97 -136
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +12 -2
- package/src/helpers/StripeInvoicer.ts +52 -59
- package/src/services/BalanceItemService.ts +19 -2
- package/src/services/InvoiceService.ts +9 -0
- package/src/services/MollieService.ts +108 -1
- package/src/services/PaymentService.ts +441 -34
- package/src/services/data/invoice.hbs.html +8 -6
- package/src/sql-filters/invoiced-balance-items.ts +5 -0
- package/tests/helpers/MollieMocker.ts +141 -1
- package/tests/vitest.setup.ts +5 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import { Email } from '@stamhoofd/email';
|
|
3
|
-
import { BalanceItem, BalanceItemPayment,
|
|
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,
|
|
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
|
|
122
|
+
const payments: Payment[] = [];
|
|
124
123
|
for (const [account, details] of this.report.applicationFeesPerAccount) {
|
|
125
124
|
try {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
|
138
|
+
return payments;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
static async
|
|
143
|
-
return !!await
|
|
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
|
|
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('
|
|
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
|
|
185
|
-
if (
|
|
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: '
|
|
195
|
-
message: '
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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.
|
|
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
|
}
|