@stamhoofd/backend 2.131.0 → 2.132.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.131.0",
3
+ "version": "2.132.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.131.0",
61
- "@stamhoofd/backend-i18n": "2.131.0",
62
- "@stamhoofd/backend-middleware": "2.131.0",
63
- "@stamhoofd/crons": "2.131.0",
64
- "@stamhoofd/email": "2.131.0",
65
- "@stamhoofd/excel-writer": "2.131.0",
66
- "@stamhoofd/logging": "2.131.0",
67
- "@stamhoofd/models": "2.131.0",
68
- "@stamhoofd/object-differ": "2.131.0",
69
- "@stamhoofd/queues": "2.131.0",
70
- "@stamhoofd/sql": "2.131.0",
71
- "@stamhoofd/structures": "2.131.0",
72
- "@stamhoofd/types": "2.131.0",
73
- "@stamhoofd/utility": "2.131.0",
60
+ "@stamhoofd/backend-env": "2.132.0",
61
+ "@stamhoofd/backend-i18n": "2.132.0",
62
+ "@stamhoofd/backend-middleware": "2.132.0",
63
+ "@stamhoofd/crons": "2.132.0",
64
+ "@stamhoofd/email": "2.132.0",
65
+ "@stamhoofd/excel-writer": "2.132.0",
66
+ "@stamhoofd/logging": "2.132.0",
67
+ "@stamhoofd/models": "2.132.0",
68
+ "@stamhoofd/object-differ": "2.132.0",
69
+ "@stamhoofd/queues": "2.132.0",
70
+ "@stamhoofd/sql": "2.132.0",
71
+ "@stamhoofd/structures": "2.132.0",
72
+ "@stamhoofd/types": "2.132.0",
73
+ "@stamhoofd/utility": "2.132.0",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.131.0",
94
+ "@stamhoofd/test-utils": "2.132.0",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "51de524c831dac65960440d9a5aabe9cad1feae8"
110
+ "gitHead": "f7143688bb7be0cd9f73c209f4b55c9bacdd59b8"
111
111
  }
@@ -198,7 +198,7 @@ export class GetInvoicesEndpoint extends Endpoint<Params, Query, Body, ResponseB
198
198
  }
199
199
 
200
200
  return new PaginatedResponse<InvoiceStruct[], LimitedFilteredRequest>({
201
- results: await AuthenticatedStructures.invoices(invoices),
201
+ results: await AuthenticatedStructures.invoices(invoices, true),
202
202
  next,
203
203
  });
204
204
  }
@@ -58,7 +58,7 @@ export class PatchInvoicesEndpoint extends Endpoint<Params, Query, Body, Respons
58
58
  }
59
59
 
60
60
  return new Response(
61
- await AuthenticatedStructures.invoices(invoices),
61
+ await AuthenticatedStructures.invoices(invoices, true),
62
62
  );
63
63
  }
64
64
  }
@@ -83,7 +83,7 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
83
83
  const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type);
84
84
  const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels);
85
85
  const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false);
86
- const invoices = await AuthenticatedStructures.invoices(invoiceModels);
86
+ const invoices = await AuthenticatedStructures.invoices(invoiceModels, true);
87
87
 
88
88
  const balances = await CachedBalance.getForObjects([request.params.id], organization.id, request.params.type);
89
89
 
@@ -70,7 +70,7 @@ export class AuthenticatedStructures {
70
70
  });
71
71
  }
72
72
 
73
- static async invoices(invoices: Invoice[]): Promise<InvoiceStruct[]> {
73
+ static async invoices(invoices: Invoice[], incldueSettlements = false): Promise<InvoiceStruct[]> {
74
74
  if (invoices.length === 0) {
75
75
  return [];
76
76
  }
@@ -78,7 +78,7 @@ export class AuthenticatedStructures {
78
78
  const { invoicedBalanceItems } = await Invoice.loadBalanceItems(invoices);
79
79
 
80
80
  const paymentModels = await Payment.select().where('invoiceId', invoices.map(i => i.id)).fetch();
81
- const payments = await this.paymentsGeneral(paymentModels, false);
81
+ const payments = await this.paymentsGeneral(paymentModels, incldueSettlements);
82
82
 
83
83
  return invoices.map((invoice) => {
84
84
  const items = invoicedBalanceItems.filter(i => i.invoiceId === invoice.id);
@@ -0,0 +1,190 @@
1
+ import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
2
+ import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
3
+ import type { MollieMockPayment, MollieMockRefund } from '../../tests/helpers/MollieMocker.js';
4
+ import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
5
+ import { checkMollieSettlementsFor } from './CheckSettlements.js';
6
+
7
+ describe('Helper.CheckSettlements', () => {
8
+ let mollieMocker: MollieMocker;
9
+
10
+ beforeAll(() => {
11
+ mollieMocker = new MollieMocker();
12
+ mollieMocker.start();
13
+ });
14
+
15
+ afterAll(() => {
16
+ mollieMocker.stop();
17
+ });
18
+
19
+ beforeEach(() => {
20
+ mollieMocker.reset();
21
+ });
22
+
23
+ /**
24
+ * Create an organization with a Mollie token, a succeeded Mollie payment and a Mollie refund
25
+ * payment reversing it. Both are linked to their Mollie ids (tr_... / re_...) like the real crons do.
26
+ */
27
+ const init = async () => {
28
+ const organization = await new OrganizationFactory({}).create();
29
+ const token = await mollieMocker.setupToken(organization);
30
+
31
+ // Source payment
32
+ const payment = new Payment();
33
+ payment.organizationId = organization.id;
34
+ payment.method = PaymentMethod.Bancontact;
35
+ payment.provider = PaymentProvider.Mollie;
36
+ payment.status = PaymentStatus.Succeeded;
37
+ payment.type = PaymentType.Payment;
38
+ payment.price = 50_0000;
39
+ payment.paidAt = new Date();
40
+ await payment.save();
41
+
42
+ const mockPayment: MollieMockPayment = {
43
+ id: mollieMocker.createId('tr'),
44
+ status: 'paid',
45
+ amount: { currency: 'EUR', value: '50.00' },
46
+ internalPaymentId: payment.id,
47
+ redirectUrl: null,
48
+ sequenceType: 'oneoff',
49
+ customerId: null,
50
+ mandateId: null,
51
+ isCancelable: false,
52
+ details: null,
53
+ };
54
+ mollieMocker.payments.push(mockPayment);
55
+
56
+ const paymentLink = new MolliePayment();
57
+ paymentLink.paymentId = payment.id;
58
+ paymentLink.mollieId = mockPayment.id;
59
+ await paymentLink.save();
60
+
61
+ // Refund payment reversing the source payment
62
+ const refundPayment = new Payment();
63
+ refundPayment.organizationId = organization.id;
64
+ refundPayment.method = PaymentMethod.Bancontact;
65
+ refundPayment.provider = PaymentProvider.Mollie;
66
+ refundPayment.status = PaymentStatus.Succeeded;
67
+ refundPayment.type = PaymentType.Refund;
68
+ refundPayment.price = -20_0000;
69
+ refundPayment.reversingPaymentId = payment.id;
70
+ refundPayment.paidAt = new Date();
71
+ await refundPayment.save();
72
+
73
+ const mockRefund = mollieMocker.createRefund(mockPayment, { value: '20.00', status: 'refunded' });
74
+
75
+ const refundLink = new MolliePayment();
76
+ refundLink.paymentId = refundPayment.id;
77
+ refundLink.mollieId = mockRefund.id;
78
+ await refundLink.save();
79
+
80
+ return { organization, token, payment, refundPayment, mockPayment, mockRefund };
81
+ };
82
+
83
+ const runCron = async (token: { accessToken: string }) => {
84
+ await checkMollieSettlementsFor(token.accessToken, true);
85
+ };
86
+
87
+ /**
88
+ * Add a Mollie chargeback payment reversing the given source payment, linked to its Mollie
89
+ * chargeback id (chb_...) like the mollie-chargebacks cron does.
90
+ */
91
+ const addChargeback = async (organizationId: string, sourcePayment: Payment, mockPayment: MollieMockPayment) => {
92
+ const chargebackPayment = new Payment();
93
+ chargebackPayment.organizationId = organizationId;
94
+ chargebackPayment.method = PaymentMethod.Bancontact;
95
+ chargebackPayment.provider = PaymentProvider.Mollie;
96
+ chargebackPayment.status = PaymentStatus.Succeeded;
97
+ chargebackPayment.type = PaymentType.Chargeback;
98
+ chargebackPayment.price = -sourcePayment.price;
99
+ chargebackPayment.reversingPaymentId = sourcePayment.id;
100
+ chargebackPayment.paidAt = new Date();
101
+ await chargebackPayment.save();
102
+
103
+ const mockChargeback = mollieMocker.createChargeback(mockPayment);
104
+
105
+ const chargebackLink = new MolliePayment();
106
+ chargebackLink.paymentId = chargebackPayment.id;
107
+ chargebackLink.mollieId = mockChargeback.id;
108
+ await chargebackLink.save();
109
+
110
+ return { chargebackPayment, mockChargeback };
111
+ };
112
+
113
+ test('The settlement of a refund settled at Mollie is stored on the refund payment', async () => {
114
+ const { token, payment, refundPayment, mockPayment, mockRefund } = await init();
115
+
116
+ const settlement = mollieMocker.createSettlement({
117
+ payments: [mockPayment],
118
+ refunds: [mockRefund],
119
+ value: '100.00',
120
+ });
121
+
122
+ await runCron(token);
123
+
124
+ // The source payment gets the settlement metadata (existing behaviour)
125
+ const updatedPayment = await Payment.getByID(payment.id);
126
+ expect(updatedPayment!.settlement).toMatchObject({
127
+ id: settlement.id,
128
+ reference: settlement.reference,
129
+ });
130
+
131
+ // The refund payment gets the same settlement metadata (new behaviour)
132
+ const updatedRefund = await Payment.getByID(refundPayment.id);
133
+ expect(updatedRefund!.settlement).toMatchObject({
134
+ id: settlement.id,
135
+ reference: settlement.reference,
136
+ amount: 100_0000,
137
+ });
138
+ });
139
+
140
+ test('A refund that is not part of any settlement keeps no settlement', async () => {
141
+ const { token, refundPayment, mockPayment } = await init();
142
+
143
+ // A settlement that only contains the source payment, not the refund
144
+ mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00' });
145
+
146
+ await runCron(token);
147
+
148
+ const updatedRefund = await Payment.getByID(refundPayment.id);
149
+ expect(updatedRefund!.settlement).toBeNull();
150
+ });
151
+
152
+ test('The settlement of a chargeback settled at Mollie is stored on the chargeback payment', async () => {
153
+ const { organization, token, payment, mockPayment } = await init();
154
+ const { chargebackPayment, mockChargeback } = await addChargeback(organization.id, payment, mockPayment);
155
+
156
+ const settlement = mollieMocker.createSettlement({
157
+ payments: [mockPayment],
158
+ chargebacks: [mockChargeback],
159
+ value: '100.00',
160
+ });
161
+
162
+ await runCron(token);
163
+
164
+ const updatedChargeback = await Payment.getByID(chargebackPayment.id);
165
+ expect(updatedChargeback!.settlement).toMatchObject({
166
+ id: settlement.id,
167
+ reference: settlement.reference,
168
+ amount: 100_0000,
169
+ });
170
+ });
171
+
172
+ test('An unlinked refund entry in a settlement is skipped without affecting the known refund', async () => {
173
+ const { token, refundPayment, mockPayment, mockRefund } = await init();
174
+
175
+ // A refund that belongs to a different system: it exists at Mollie but has no MolliePayment link
176
+ const unlinkedRefund: MollieMockRefund = mollieMocker.createRefund(mockPayment, { value: '5.00', status: 'refunded' });
177
+
178
+ const settlement = mollieMocker.createSettlement({
179
+ payments: [mockPayment],
180
+ refunds: [unlinkedRefund, mockRefund],
181
+ value: '100.00',
182
+ });
183
+
184
+ await runCron(token);
185
+
186
+ // The known refund still gets its settlement, the unlinked one is silently ignored
187
+ const updatedRefund = await Payment.getByID(refundPayment.id);
188
+ expect(updatedRefund!.settlement).toMatchObject({ id: settlement.id });
189
+ });
190
+ });
@@ -16,7 +16,11 @@ type MollieSettlement = {
16
16
  };
17
17
  };
18
18
 
19
- type MolliePaymentJSON = {
19
+ /**
20
+ * Both payments (tr_...) and refunds (re_...) settled in a settlement are matched to a local
21
+ * payment through the mollieId of their MolliePayment link, so we only need their id here.
22
+ */
23
+ type MollieSettlementEntryJSON = {
20
24
  id: string;
21
25
  };
22
26
 
@@ -146,64 +150,88 @@ export async function checkMollieSettlementsFor(token: string, checkAll = false)
146
150
  }
147
151
  }
148
152
 
149
- async function updateSettlement(token: string, settlement: MollieSettlement, fromPaymentId?: string) {
153
+ async function updateSettlement(token: string, settlement: MollieSettlement) {
154
+ // Regular payments settled in this settlement
155
+ await updateSettlementResource(token, settlement, 'payments');
156
+
157
+ // Refunds settled in this settlement. These are negative entries linked to a refund payment
158
+ // (created by the mollie-refunds cron), so we can set their settlement metadata too.
159
+ await updateSettlementResource(token, settlement, 'refunds');
160
+
161
+ // Chargebacks settled in this settlement. Like refunds, these are negative entries linked to a
162
+ // chargeback payment (created by the mollie-chargebacks cron).
163
+ await updateSettlementResource(token, settlement, 'chargebacks');
164
+ }
165
+
166
+ /**
167
+ * Loop over all entries (payments, refunds or chargebacks) that are part of a settlement and set the
168
+ * settlement metadata on the matching local payment. Every resource exposes its entries under
169
+ * `_embedded[resource]` and uses the same pagination, so they share this logic.
170
+ */
171
+ async function updateSettlementResource(token: string, settlement: MollieSettlement, resource: 'payments' | 'refunds' | 'chargebacks', fromId?: string) {
150
172
  const limit = 250;
151
173
 
152
- // Loop all payments of this settlement
153
- const request = await axios.get('https://api.mollie.com/v2/settlements/' + settlement.id + '/payments?limit=' + limit + (fromPaymentId ? ('&from=' + encodeURIComponent(fromPaymentId)) : ''), {
174
+ const request = await axios.get('https://api.mollie.com/v2/settlements/' + settlement.id + '/' + resource + '?limit=' + limit + (fromId ? ('&from=' + encodeURIComponent(fromId)) : ''), {
154
175
  headers: {
155
176
  Authorization: 'Bearer ' + token,
156
177
  },
157
178
  });
158
179
 
159
180
  if (request.status === 200) {
160
- const molliePayments = request.data._embedded.payments as MolliePaymentJSON[];
161
-
162
- for (const mollie of molliePayments) {
163
- // Search payment
164
- const mps = await MolliePayment.where({ mollieId: mollie.id });
165
- if (mps.length == 1) {
166
- const mp = mps[0];
167
- const payment = await Payment.getByID(mp.paymentId);
168
- if (payment) {
169
- payment.settlement = Settlement.create({
170
- id: settlement.id,
171
- reference: settlement.reference,
172
- settledAt: new Date(settlement.settledAt),
173
- amount: Math.round(parseFloat(settlement.amount.value) * 100) * 100,
174
- });
175
- const saved = await payment.save();
176
-
177
- if (saved) {
178
- // Mark order as 'updated', or the frontend won't pull in the updates
179
- const order = await Order.getForPayment(null, payment.id);
180
- if (order) {
181
- order.updatedAt = new Date();
182
- order.forceSaveProperty('updatedAt');
183
- await order.save();
184
- }
185
-
186
- // TODO: Mark registrations as 'saved'
187
- }
181
+ const entries = request.data._embedded[resource] as MollieSettlementEntryJSON[];
188
182
 
189
- if (STAMHOOFD.environment === 'development') {
190
- console.log('Updated settlement of payment ' + payment.id);
191
- console.log(payment.settlement);
192
- }
193
- } else {
194
- console.log('Missing payment ' + mp.paymentId);
195
- }
196
- } else {
197
- // Probably a payment in a different system/platform
198
- // console.log("No mollie payment found for id "+mollie.id)
199
- }
183
+ for (const entry of entries) {
184
+ await applySettlementToPayment(settlement, entry.id);
200
185
  }
201
186
 
202
187
  // Check next page
203
- if (request.data._links.next) {
204
- await updateSettlement(token, settlement, molliePayments[molliePayments.length - 1].id);
188
+ if (request.data._links.next && entries.length > 0) {
189
+ await updateSettlementResource(token, settlement, resource, entries[entries.length - 1].id);
205
190
  }
206
191
  } else {
207
192
  console.error(request.data);
208
193
  }
209
194
  }
195
+
196
+ /**
197
+ * Find the local payment linked to a Mollie payment or refund id and store the settlement metadata.
198
+ */
199
+ async function applySettlementToPayment(settlement: MollieSettlement, mollieId: string) {
200
+ // Search payment
201
+ const mps = await MolliePayment.where({ mollieId });
202
+ if (mps.length === 1) {
203
+ const mp = mps[0];
204
+ const payment = await Payment.getByID(mp.paymentId);
205
+ if (payment) {
206
+ payment.settlement = Settlement.create({
207
+ id: settlement.id,
208
+ reference: settlement.reference,
209
+ settledAt: new Date(settlement.settledAt),
210
+ amount: Math.round(parseFloat(settlement.amount.value) * 100) * 100,
211
+ });
212
+ const saved = await payment.save();
213
+
214
+ if (saved) {
215
+ // Mark order as 'updated', or the frontend won't pull in the updates
216
+ const order = await Order.getForPayment(null, payment.id);
217
+ if (order) {
218
+ order.updatedAt = new Date();
219
+ order.forceSaveProperty('updatedAt');
220
+ await order.save();
221
+ }
222
+
223
+ // TODO: Mark registrations as 'saved'
224
+ }
225
+
226
+ if (STAMHOOFD.environment === 'development') {
227
+ console.log('Updated settlement of payment ' + payment.id);
228
+ console.log(payment.settlement);
229
+ }
230
+ } else {
231
+ console.log('Missing payment ' + mp.paymentId);
232
+ }
233
+ } else {
234
+ // Probably a payment in a different system/platform
235
+ // console.log("No mollie payment found for id "+mollieId)
236
+ }
237
+ }
@@ -53,6 +53,22 @@ export type MollieMockRefund = {
53
53
  metadata: Record<string, unknown> | null;
54
54
  };
55
55
 
56
+ export type MollieMockSettlement = {
57
+ id: string;
58
+ reference: string;
59
+ status: 'open' | 'pending' | 'paidout' | 'failed';
60
+ amount: { currency: string; value: string };
61
+ createdAt: string;
62
+ /** null for the still-open settlement, a date once it has been paid out */
63
+ settledAt: string | null;
64
+ /** Mollie payment ids (tr_...) settled in this settlement */
65
+ paymentIds: string[];
66
+ /** Mollie refund ids (re_...) settled in this settlement */
67
+ refundIds: string[];
68
+ /** Mollie chargeback ids (chb_...) settled in this settlement */
69
+ chargebackIds: string[];
70
+ };
71
+
56
72
  const MOLLIE_CHECKOUT_URL = 'https://molliecheckout/';
57
73
 
58
74
  /**
@@ -76,6 +92,7 @@ export class MollieMocker {
76
92
  mandates: MollieMockMandate[] = [];
77
93
  chargebacks: MollieMockChargeback[] = [];
78
94
  refunds: MollieMockRefund[] = [];
95
+ settlements: MollieMockSettlement[] = [];
79
96
 
80
97
  #forceFailure = false;
81
98
 
@@ -85,6 +102,7 @@ export class MollieMocker {
85
102
  this.mandates = [];
86
103
  this.chargebacks = [];
87
104
  this.refunds = [];
105
+ this.settlements = [];
88
106
  this.#forceFailure = false;
89
107
  }
90
108
 
@@ -206,6 +224,26 @@ export class MollieMocker {
206
224
  return this.#listResource('chargebacks', this.chargebacks.map(c => this.#chargebackResource(c)));
207
225
  }
208
226
 
227
+ // settlements + nested payments/refunds (drives the settlements cron)
228
+ if (parts[0] === 'settlements' && method === 'GET') {
229
+ if (parts.length === 1) {
230
+ return this.#listResource('settlements', this.settlements.map(s => this.#settlementResource(s)));
231
+ }
232
+ const settlement = this.settlements.find(s => s.id === parts[1]);
233
+ if (settlement && parts.length === 3 && parts[2] === 'payments') {
234
+ const items = this.payments.filter(p => settlement.paymentIds.includes(p.id));
235
+ return this.#listResource('payments', items.map(p => this.#paymentResource(p)));
236
+ }
237
+ if (settlement && parts.length === 3 && parts[2] === 'refunds') {
238
+ const items = this.refunds.filter(r => settlement.refundIds.includes(r.id));
239
+ return this.#listResource('refunds', items.map(r => this.#refundResource(r)));
240
+ }
241
+ if (settlement && parts.length === 3 && parts[2] === 'chargebacks') {
242
+ const items = this.chargebacks.filter(c => settlement.chargebackIds.includes(c.id));
243
+ return this.#listResource('chargebacks', items.map(c => this.#chargebackResource(c)));
244
+ }
245
+ }
246
+
209
247
  console.error('MollieMocker: unhandled request', method, uri);
210
248
  return [500, { detail: 'MollieMocker: unhandled request ' + method + ' ' + uri }];
211
249
  }
@@ -512,6 +550,41 @@ export class MollieMocker {
512
550
  };
513
551
  }
514
552
 
553
+ // ---- Settlements ------------------------------------------------------
554
+
555
+ /**
556
+ * Register a settled (paid out) settlement that groups the given payments and refunds.
557
+ * Used to drive the settlements cron (CheckSettlements).
558
+ */
559
+ createSettlement(options: { payments?: MollieMockPayment[]; refunds?: MollieMockRefund[]; chargebacks?: MollieMockChargeback[]; value?: string; settledAt?: Date } = {}): MollieMockSettlement {
560
+ const settlement: MollieMockSettlement = {
561
+ id: this.createId('stl'),
562
+ reference: '1234567.' + (this.settlements.length + 1).toString().padStart(4, '0') + '.01',
563
+ status: 'paidout',
564
+ amount: { currency: 'EUR', value: options.value ?? '0.00' },
565
+ createdAt: new Date().toISOString(),
566
+ settledAt: (options.settledAt ?? new Date()).toISOString(),
567
+ paymentIds: (options.payments ?? []).map(p => p.id),
568
+ refundIds: (options.refunds ?? []).map(r => r.id),
569
+ chargebackIds: (options.chargebacks ?? []).map(c => c.id),
570
+ };
571
+ this.settlements.push(settlement);
572
+ return settlement;
573
+ }
574
+
575
+ #settlementResource(settlement: MollieMockSettlement) {
576
+ return {
577
+ resource: 'settlement',
578
+ id: settlement.id,
579
+ reference: settlement.reference,
580
+ status: settlement.status,
581
+ amount: settlement.amount,
582
+ createdAt: settlement.createdAt,
583
+ settledAt: settlement.settledAt ?? null,
584
+ _links: { self: { href: 'https://api.mollie.com/v2/settlements/' + settlement.id, type: 'application/hal+json' } },
585
+ };
586
+ }
587
+
515
588
  // ---- List helper ------------------------------------------------------
516
589
 
517
590
  #listResource(binderName: string, items: unknown[]): [number, unknown] {