@stamhoofd/backend 2.126.0 → 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
|
@@ -43,6 +43,16 @@ export type MollieMockChargeback = {
|
|
|
43
43
|
createdAt: string;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
export type MollieMockRefund = {
|
|
47
|
+
id: string;
|
|
48
|
+
paymentId: string;
|
|
49
|
+
amount: { currency: string; value: string };
|
|
50
|
+
status: 'queued' | 'pending' | 'canceled' | 'processing' | 'failed' | 'refunded';
|
|
51
|
+
description: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
metadata: Record<string, unknown> | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
46
56
|
const MOLLIE_CHECKOUT_URL = 'https://molliecheckout/';
|
|
47
57
|
|
|
48
58
|
/**
|
|
@@ -65,6 +75,7 @@ export class MollieMocker {
|
|
|
65
75
|
customers: { id: string }[] = [];
|
|
66
76
|
mandates: MollieMockMandate[] = [];
|
|
67
77
|
chargebacks: MollieMockChargeback[] = [];
|
|
78
|
+
refunds: MollieMockRefund[] = [];
|
|
68
79
|
|
|
69
80
|
#forceFailure = false;
|
|
70
81
|
|
|
@@ -73,6 +84,7 @@ export class MollieMocker {
|
|
|
73
84
|
this.customers = [];
|
|
74
85
|
this.mandates = [];
|
|
75
86
|
this.chargebacks = [];
|
|
87
|
+
this.refunds = [];
|
|
76
88
|
this.#forceFailure = false;
|
|
77
89
|
}
|
|
78
90
|
|
|
@@ -150,6 +162,18 @@ export class MollieMocker {
|
|
|
150
162
|
if (method === 'DELETE' && parts.length === 2) {
|
|
151
163
|
return this.#cancelPayment(parts[1]);
|
|
152
164
|
}
|
|
165
|
+
if (method === 'POST' && parts.length === 3 && parts[2] === 'refunds') {
|
|
166
|
+
return this.#createRefund(parts[1], decoded);
|
|
167
|
+
}
|
|
168
|
+
if (method === 'GET' && parts.length === 4 && parts[2] === 'refunds') {
|
|
169
|
+
return this.#getRefund(parts[1], parts[3]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (parts[0] === 'refunds' && method === 'GET') {
|
|
174
|
+
// The cron iterates newest first (sort desc)
|
|
175
|
+
const sorted = this.refunds.slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
176
|
+
return this.#listResource('refunds', sorted.map(r => this.#refundResource(r)));
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
// customers + nested mandates
|
|
@@ -194,7 +218,9 @@ export class MollieMocker {
|
|
|
194
218
|
id,
|
|
195
219
|
status: 'open',
|
|
196
220
|
amount: body.amount ?? { currency: 'EUR', value: '0.00' },
|
|
197
|
-
|
|
221
|
+
// The webshop checkout (PlaceOrderEndpoint) uses the 'payment' metadata key,
|
|
222
|
+
// other flows (MollieService.createPayment) use 'paymentId'
|
|
223
|
+
internalPaymentId: body.metadata?.paymentId ?? body.metadata?.payment ?? null,
|
|
198
224
|
redirectUrl: body.redirectUrl ?? null,
|
|
199
225
|
sequenceType: body.sequenceType ?? 'oneoff',
|
|
200
226
|
customerId: body.customerId ?? null,
|
|
@@ -372,6 +398,120 @@ export class MollieMocker {
|
|
|
372
398
|
};
|
|
373
399
|
}
|
|
374
400
|
|
|
401
|
+
// ---- Refunds ----------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The remaining refundable amount for a payment (in euro), like Mollie's amountRemaining.
|
|
405
|
+
*/
|
|
406
|
+
getRemainingAmount(payment: MollieMockPayment): number {
|
|
407
|
+
const refunded = this.refunds
|
|
408
|
+
.filter(r => r.paymentId === payment.id && r.status !== 'canceled' && r.status !== 'failed')
|
|
409
|
+
.reduce((total, r) => total + parseFloat(r.amount.value), 0);
|
|
410
|
+
return parseFloat(payment.amount.value) - refunded;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Handle POST /payments/:id/refunds like Mollie: refuse refunds on unpaid payments and
|
|
415
|
+
* refunds that are higher than the remaining refundable amount.
|
|
416
|
+
*/
|
|
417
|
+
#createRefund(paymentId: string, body: Record<string, any>): [number, unknown] {
|
|
418
|
+
const payment = this.payments.find(p => p.id === paymentId);
|
|
419
|
+
if (!payment) {
|
|
420
|
+
return [404, { status: 404, title: 'Not Found', detail: 'No payment exists with token ' + paymentId }];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (payment.status !== 'paid') {
|
|
424
|
+
return [422, { status: 422, title: 'Unprocessable Entity', detail: 'The payment is not paid; you can only refund paid payments' }];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const value = parseFloat(String(body.amount?.value ?? '0'));
|
|
428
|
+
if (!(value > 0)) {
|
|
429
|
+
return [422, { status: 422, title: 'Unprocessable Entity', detail: 'The amount is invalid', field: 'amount' }];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (value > this.getRemainingAmount(payment) + 0.000001) {
|
|
433
|
+
return [422, { status: 422, title: 'Unprocessable Entity', detail: 'The amount is higher than the remaining payment amount', field: 'amount' }];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const refund: MollieMockRefund = {
|
|
437
|
+
id: this.createId('re'),
|
|
438
|
+
paymentId: payment.id,
|
|
439
|
+
amount: body.amount,
|
|
440
|
+
status: 'pending',
|
|
441
|
+
description: body.description ?? '',
|
|
442
|
+
createdAt: new Date().toISOString(),
|
|
443
|
+
metadata: body.metadata ?? null,
|
|
444
|
+
};
|
|
445
|
+
this.refunds.push(refund);
|
|
446
|
+
return [201, this.#refundResource(refund)];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#getRefund(paymentId: string, refundId: string): [number, unknown] {
|
|
450
|
+
const refund = this.refunds.find(r => r.id === refundId && r.paymentId === paymentId);
|
|
451
|
+
if (!refund) {
|
|
452
|
+
return [404, { status: 404, title: 'Not Found', detail: 'No refund exists with token ' + refundId }];
|
|
453
|
+
}
|
|
454
|
+
return [200, this.#refundResource(refund)];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Register a refund directly (simulating a refund created in the Mollie dashboard).
|
|
459
|
+
* Used to drive the mollie-refunds cron.
|
|
460
|
+
*/
|
|
461
|
+
createRefund(payment: MollieMockPayment, options: { value?: string; status?: MollieMockRefund['status']; createdAt?: Date; metadata?: Record<string, unknown> } = {}): MollieMockRefund {
|
|
462
|
+
const refund: MollieMockRefund = {
|
|
463
|
+
id: this.createId('re'),
|
|
464
|
+
paymentId: payment.id,
|
|
465
|
+
amount: { currency: payment.amount.currency, value: options.value ?? payment.amount.value },
|
|
466
|
+
status: options.status ?? 'pending',
|
|
467
|
+
description: '',
|
|
468
|
+
createdAt: (options.createdAt ?? new Date()).toISOString(),
|
|
469
|
+
metadata: options.metadata ?? null,
|
|
470
|
+
};
|
|
471
|
+
this.refunds.push(refund);
|
|
472
|
+
return refund;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Mark a refund as executed by Mollie and trigger the webhook of the original payment
|
|
477
|
+
* (like Mollie does when the status of a refund changes).
|
|
478
|
+
*/
|
|
479
|
+
async settleRefund(refund: MollieMockRefund = this.refunds[this.refunds.length - 1]) {
|
|
480
|
+
refund.status = 'refunded';
|
|
481
|
+
await this.#exchangeRefund(refund);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Mark a refund as failed at Mollie (e.g. the bank refused the transfer) and trigger the
|
|
486
|
+
* webhook of the original payment.
|
|
487
|
+
*/
|
|
488
|
+
async failRefund(refund: MollieMockRefund = this.refunds[this.refunds.length - 1]) {
|
|
489
|
+
refund.status = 'failed';
|
|
490
|
+
await this.#exchangeRefund(refund);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async #exchangeRefund(refund: MollieMockRefund) {
|
|
494
|
+
const payment = this.payments.find(p => p.id === refund.paymentId);
|
|
495
|
+
if (payment) {
|
|
496
|
+
await this.#exchange(payment);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#refundResource(refund: MollieMockRefund) {
|
|
501
|
+
return {
|
|
502
|
+
resource: 'refund',
|
|
503
|
+
id: refund.id,
|
|
504
|
+
mode: 'test',
|
|
505
|
+
amount: refund.amount,
|
|
506
|
+
status: refund.status,
|
|
507
|
+
description: refund.description,
|
|
508
|
+
paymentId: refund.paymentId,
|
|
509
|
+
createdAt: refund.createdAt,
|
|
510
|
+
metadata: refund.metadata ?? undefined,
|
|
511
|
+
_links: { self: { href: 'https://api.mollie.com/v2/payments/' + refund.paymentId + '/refunds/' + refund.id, type: 'application/hal+json' } },
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
375
515
|
// ---- List helper ------------------------------------------------------
|
|
376
516
|
|
|
377
517
|
#listResource(binderName: string, items: unknown[]): [number, unknown] {
|
package/tests/vitest.setup.ts
CHANGED
|
@@ -55,6 +55,11 @@ beforeAll(async () => {
|
|
|
55
55
|
await Database.delete('DELETE FROM `webshops`');
|
|
56
56
|
await Database.delete('DELETE FROM `groups`');
|
|
57
57
|
await Database.delete('DELETE FROM `email_addresses`');
|
|
58
|
+
|
|
59
|
+
// invoiced_balance_items restricts deleting balance items, which blocks the organizations
|
|
60
|
+
// cascade below, so it has to be cleared first
|
|
61
|
+
await Database.delete('DELETE FROM `invoiced_balance_items`');
|
|
62
|
+
await Database.delete('DELETE FROM `invoices`');
|
|
58
63
|
await Database.update('UPDATE registration_periods set organizationId = null, customName = ? where organizationId is not null', ['delete']);
|
|
59
64
|
await Database.delete('DELETE FROM `organizations`');
|
|
60
65
|
await Database.delete('DELETE FROM `registration_periods` where customName = ?', ['delete']);
|