@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.
@@ -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
- internalPaymentId: body.metadata?.paymentId ?? null,
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] {
@@ -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']);