@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.
- 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 +3 -2
- 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/helpers/StripeInvoicer.ts +32 -56
- 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,13 +1,11 @@
|
|
|
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';
|
|
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
|
|
121
|
+
const payments: Payment[] = [];
|
|
124
122
|
for (const [account, details] of this.report.applicationFeesPerAccount) {
|
|
125
123
|
try {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
|
137
|
+
return payments;
|
|
140
138
|
}
|
|
141
139
|
|
|
142
|
-
static async
|
|
143
|
-
return !!await
|
|
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
|
|
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('
|
|
179
|
+
.where('status', PaymentStatus.Succeeded)
|
|
182
180
|
.fetch();
|
|
183
181
|
|
|
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) {
|
|
182
|
+
for (const i of existingPayments) {
|
|
183
|
+
if (i.price !== applicationFee.amount) {
|
|
193
184
|
throw new SimpleError({
|
|
194
|
-
code: '
|
|
195
|
-
message: '
|
|
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
|
-
|
|
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
|
-
}
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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
|
}
|