@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.
@@ -0,0 +1,148 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { BalanceItemFactory, Invoice, InvoicedBalanceItem, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import type { InvoiceStruct, PaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { LimitedFilteredRequest, PermissionLevel, Permissions } from '@stamhoofd/structures';
6
+ import { STExpect } from '@stamhoofd/test-utils';
7
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
8
+ import { GetInvoicesEndpoint } from './GetInvoicesEndpoint.js';
9
+
10
+ describe('Endpoint.GetInvoicesEndpoint', () => {
11
+ const endpoint = new GetInvoicesEndpoint();
12
+
13
+ const createBalanceItem = async ({ organization }: { organization: Organization }) => {
14
+ return await new BalanceItemFactory({
15
+ organizationId: organization.id,
16
+ amount: 1,
17
+ unitPrice: 10_00,
18
+ }).create();
19
+ };
20
+
21
+ const createInvoice = async ({ organization, balanceItemIds }: { organization: Organization; balanceItemIds: string[] }) => {
22
+ const invoice = new Invoice();
23
+ invoice.organizationId = organization.id;
24
+ await invoice.save();
25
+
26
+ for (const balanceItemId of balanceItemIds) {
27
+ const item = new InvoicedBalanceItem();
28
+ item.organizationId = organization.id;
29
+ item.invoiceId = invoice.id;
30
+ item.balanceItemId = balanceItemId;
31
+ item.name = 'Test item';
32
+ item.unitPrice = 10_00;
33
+ item.balanceInvoicedAmount = 10_00;
34
+ await item.save();
35
+ }
36
+
37
+ return invoice;
38
+ };
39
+
40
+ const getInvoices = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
41
+ const token = await Token.createToken(user);
42
+
43
+ const request = Request.get({
44
+ path: '/invoices',
45
+ host: organization.getApiHost(),
46
+ query: new LimitedFilteredRequest({
47
+ filter,
48
+ limit: 10,
49
+ }),
50
+ headers: {
51
+ authorization: 'Bearer ' + token.accessToken,
52
+ },
53
+ });
54
+
55
+ return testServer.test<PaginatedResponse<InvoiceStruct[], LimitedFilteredRequest>>(endpoint, request);
56
+ };
57
+
58
+ describe('Filtering on balance item', () => {
59
+ test('only returns invoices that invoiced the balance item', async () => {
60
+ const organization = await new OrganizationFactory({}).create();
61
+ const user = await new UserFactory({
62
+ organization,
63
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
64
+ }).create();
65
+
66
+ const balanceItemId1 = (await createBalanceItem({ organization })).id;
67
+ const balanceItemId2 = (await createBalanceItem({ organization })).id;
68
+
69
+ const invoiceWithBothItems = await createInvoice({ organization, balanceItemIds: [balanceItemId1, balanceItemId2] });
70
+ const invoiceWithSecondItem = await createInvoice({ organization, balanceItemIds: [balanceItemId2] });
71
+ const invoiceWithoutItems = await createInvoice({ organization, balanceItemIds: [] });
72
+
73
+ const response = await getInvoices({
74
+ filter: {
75
+ items: {
76
+ $elemMatch: {
77
+ balanceItemId: balanceItemId1,
78
+ },
79
+ },
80
+ },
81
+ organization,
82
+ user,
83
+ });
84
+
85
+ expect(response.status).toBe(200);
86
+ expect(response.body.results.map(r => r.id)).toEqual([invoiceWithBothItems.id]);
87
+
88
+ const response2 = await getInvoices({
89
+ filter: {
90
+ items: {
91
+ $elemMatch: {
92
+ balanceItemId: balanceItemId2,
93
+ },
94
+ },
95
+ },
96
+ organization,
97
+ user,
98
+ });
99
+
100
+ expect(response2.status).toBe(200);
101
+ expect(response2.body.results.map(r => r.id).sort()).toEqual([invoiceWithBothItems.id, invoiceWithSecondItem.id].sort());
102
+ expect(response2.body.results.map(r => r.id)).not.toContain(invoiceWithoutItems.id);
103
+ });
104
+
105
+ test('does not return invoices of other organizations for the same balance item', async () => {
106
+ const organization = await new OrganizationFactory({}).create();
107
+ const otherOrganization = await new OrganizationFactory({}).create();
108
+ const user = await new UserFactory({
109
+ organization,
110
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
111
+ }).create();
112
+
113
+ const balanceItemId = (await createBalanceItem({ organization: otherOrganization })).id;
114
+ await createInvoice({ organization: otherOrganization, balanceItemIds: [balanceItemId] });
115
+
116
+ const response = await getInvoices({
117
+ filter: {
118
+ items: {
119
+ $elemMatch: {
120
+ balanceItemId,
121
+ },
122
+ },
123
+ },
124
+ organization,
125
+ user,
126
+ });
127
+
128
+ expect(response.status).toBe(200);
129
+ expect(response.body.results).toHaveLength(0);
130
+ });
131
+ });
132
+
133
+ describe('Permission checking', () => {
134
+ test('user without finance permissions cannot list invoices', async () => {
135
+ const organization = await new OrganizationFactory({}).create();
136
+ const user = await new UserFactory({
137
+ organization,
138
+ permissions: Permissions.create({ level: PermissionLevel.Read }),
139
+ }).create();
140
+
141
+ await expect(getInvoices({
142
+ filter: null,
143
+ organization,
144
+ user,
145
+ })).rejects.toThrow(STExpect.errorWithCode('permission_denied'));
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,490 @@
1
+ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
+ import { PatchableArray } from '@simonbackx/simple-encoding';
3
+ import { Request } from '@simonbackx/simple-endpoints';
4
+ import type { Organization, Token } from '@stamhoofd/models';
5
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, MolliePayment, Order, OrganizationFactory, Payment, WebshopFactory } from '@stamhoofd/models';
6
+ import { BalanceItemDetailed, BalanceItemPaymentDetailed, Company, OrderData, OrderStatus, PaymentCustomer, PaymentGeneral, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType, Version } from '@stamhoofd/structures';
7
+ import { STExpect } from '@stamhoofd/test-utils';
8
+ import type { MollieMockPayment } from '../../../../../tests/helpers/MollieMocker.js';
9
+ import { MollieMocker } from '../../../../../tests/helpers/MollieMocker.js';
10
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
11
+ import { initAdmin } from '../../../../../tests/init/index.js';
12
+ import { PatchPaymentsEndpoint } from './PatchPaymentsEndpoint.js';
13
+
14
+ describe('Endpoint.PatchPaymentsEndpoint', () => {
15
+ const endpoint = new PatchPaymentsEndpoint();
16
+ let mollieMocker: MollieMocker;
17
+
18
+ beforeAll(() => {
19
+ mollieMocker = new MollieMocker();
20
+ mollieMocker.start();
21
+ });
22
+
23
+ afterAll(() => {
24
+ mollieMocker.stop();
25
+ });
26
+
27
+ beforeEach(() => {
28
+ mollieMocker.reset();
29
+ });
30
+
31
+ /**
32
+ * Put a new payment (e.g. a refund) via the patch endpoint and return the created payment
33
+ */
34
+ const post = async (body: PaymentGeneral, organization: Organization, token: Token) => {
35
+ const arr: PatchableArrayAutoEncoder<PaymentGeneral> = new PatchableArray();
36
+ arr.addPut(body);
37
+
38
+ const request = Request.buildJson('PATCH', `/v${Version}/organization/payments`, organization.getApiHost(), arr);
39
+ request.headers.authorization = 'Bearer ' + token.accessToken;
40
+ const response = await testServer.test(endpoint, request);
41
+ return { body: response.body[0] };
42
+ };
43
+
44
+ /**
45
+ * Create an organization with a Mollie token, an admin, a paid balance item and a succeeded
46
+ * Mollie payment for that balance item (including the linked mock payment at 'Mollie').
47
+ */
48
+ const init = async ({ price = 50_0000 }: { price?: number } = {}) => {
49
+ const organization = await new OrganizationFactory({}).create();
50
+ await mollieMocker.setupToken(organization);
51
+ const { admin, adminToken } = await initAdmin({ organization });
52
+
53
+ const balanceItem = await new BalanceItemFactory({
54
+ organizationId: organization.id,
55
+ amount: 1,
56
+ unitPrice: price,
57
+ pricePaid: price,
58
+ }).create();
59
+
60
+ const { payment, mockPayment } = await createMolliePayment({ organization, balanceItems: new Map([[balanceItem, price]]) });
61
+
62
+ return { organization, admin, adminToken, balanceItem, payment, mockPayment };
63
+ };
64
+
65
+ const createMolliePayment = async ({ organization, balanceItems }: { organization: Organization; balanceItems: Map<BalanceItem, number> }) => {
66
+ const price = [...balanceItems.values()].reduce((total, p) => total + p, 0);
67
+
68
+ const payment = new Payment();
69
+ payment.organizationId = organization.id;
70
+ payment.method = PaymentMethod.Bancontact;
71
+ payment.provider = PaymentProvider.Mollie;
72
+ payment.status = PaymentStatus.Succeeded;
73
+ payment.type = PaymentType.Payment;
74
+ payment.price = price;
75
+ payment.paidAt = new Date();
76
+ await payment.save();
77
+
78
+ for (const [balanceItem, itemPrice] of balanceItems) {
79
+ const balanceItemPayment = new BalanceItemPayment();
80
+ balanceItemPayment.balanceItemId = balanceItem.id;
81
+ balanceItemPayment.paymentId = payment.id;
82
+ balanceItemPayment.organizationId = organization.id;
83
+ balanceItemPayment.price = itemPrice;
84
+ await balanceItemPayment.save();
85
+ }
86
+
87
+ // Register the payment at 'Mollie'
88
+ const mockPayment: MollieMockPayment = {
89
+ id: mollieMocker.createId('tr'),
90
+ status: 'paid',
91
+ amount: { currency: 'EUR', value: (Math.round(price / 100) / 100).toFixed(2) },
92
+ internalPaymentId: payment.id,
93
+ redirectUrl: null,
94
+ sequenceType: 'oneoff',
95
+ customerId: null,
96
+ mandateId: null,
97
+ isCancelable: false,
98
+ details: null,
99
+ };
100
+ mollieMocker.payments.push(mockPayment);
101
+
102
+ const molliePayment = new MolliePayment();
103
+ molliePayment.paymentId = payment.id;
104
+ molliePayment.mollieId = mockPayment.id;
105
+ await molliePayment.save();
106
+
107
+ return { payment, mockPayment };
108
+ };
109
+
110
+ const buildRefund = ({ sourcePayment, items }: { sourcePayment: Payment; items: Map<BalanceItem, number> }) => {
111
+ return PaymentGeneral.create({
112
+ type: PaymentType.Refund,
113
+ method: PaymentMethod.Bancontact,
114
+ reversingPaymentId: sourcePayment.id,
115
+ balanceItemPayments: [...items.entries()].map(([balanceItem, price]) => BalanceItemPaymentDetailed.create({
116
+ balanceItem: BalanceItemDetailed.create({ ...balanceItem }),
117
+ price,
118
+ })),
119
+ });
120
+ };
121
+
122
+ test('Can fully refund a Mollie payment: the refund stays pending until Mollie executes it', async () => {
123
+ const { organization, adminToken, balanceItem, payment } = await init();
124
+
125
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
126
+ const response = await post(body, organization, adminToken);
127
+
128
+ // Mollie still has to execute the refund (it can fail until then), so it is pending
129
+ expect(response.body).toMatchObject({
130
+ type: PaymentType.Refund,
131
+ price: -50_0000,
132
+ status: PaymentStatus.Pending,
133
+ method: PaymentMethod.Bancontact,
134
+ provider: PaymentProvider.Mollie,
135
+ reversingPaymentId: payment.id,
136
+ });
137
+
138
+ // A refund has been created at Mollie
139
+ expect(mollieMocker.refunds).toHaveLength(1);
140
+ expect(mollieMocker.refunds[0].amount.value).toBe('50.00');
141
+
142
+ // The refund is linked to the Mollie refund id, so the cron won't register it twice
143
+ const link = await MolliePayment.select().where('mollieId', mollieMocker.refunds[0].id).first(false);
144
+ expect(link).not.toBeNull();
145
+ expect(link!.paymentId).toBe(response.body.id);
146
+
147
+ // The source payment tracks the pending refund amount, nothing is refunded yet
148
+ let updatedSource = await Payment.getByID(payment.id);
149
+ expect(updatedSource!.refundedAmount).toBe(0);
150
+ expect(updatedSource!.pendingRefundAmount).toBe(-50_0000);
151
+
152
+ // The balance item is still paid while the refund is pending
153
+ let updatedItem = await BalanceItem.getByID(balanceItem.id);
154
+ expect(updatedItem!.pricePaid).toBe(50_0000);
155
+ expect(updatedItem!.pricePending).toBe(-50_0000);
156
+
157
+ // Mollie executes the refund and calls the webhook of the original payment
158
+ await mollieMocker.settleRefund();
159
+
160
+ const updatedRefund = await Payment.getByID(response.body.id);
161
+ expect(updatedRefund!.status).toBe(PaymentStatus.Succeeded);
162
+
163
+ updatedSource = await Payment.getByID(payment.id);
164
+ expect(updatedSource!.refundedAmount).toBe(-50_0000);
165
+ expect(updatedSource!.pendingRefundAmount).toBe(0);
166
+
167
+ // The balance item is no longer marked as paid
168
+ updatedItem = await BalanceItem.getByID(balanceItem.id);
169
+ expect(updatedItem!.pricePaid).toBe(0);
170
+ expect(updatedItem!.pricePending).toBe(0);
171
+ });
172
+
173
+ test('A pending refund that fails at Mollie is marked as failed and restores the refundable amount', async () => {
174
+ const { organization, adminToken, balanceItem, payment } = await init();
175
+
176
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
177
+ const response = await post(body, organization, adminToken);
178
+ expect(response.body.status).toBe(PaymentStatus.Pending);
179
+
180
+ // While the refund is pending, a second refund is blocked
181
+ const blocked = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -10_0000]]) });
182
+ await expect(post(blocked, organization, adminToken)).rejects.toThrow(
183
+ STExpect.simpleError({ code: 'refund_amount_too_high' }),
184
+ );
185
+
186
+ // Mollie could not execute the refund (e.g. the bank refused it)
187
+ await mollieMocker.failRefund();
188
+
189
+ const updatedRefund = await Payment.getByID(response.body.id);
190
+ expect(updatedRefund!.status).toBe(PaymentStatus.Failed);
191
+
192
+ // Nothing was refunded: the balance item stays paid and the full amount is refundable again
193
+ const updatedSource = await Payment.getByID(payment.id);
194
+ expect(updatedSource!.refundedAmount).toBe(0);
195
+ expect(updatedSource!.pendingRefundAmount).toBe(0);
196
+
197
+ const updatedItem = await BalanceItem.getByID(balanceItem.id);
198
+ expect(updatedItem!.pricePaid).toBe(50_0000);
199
+ expect(updatedItem!.pricePending).toBe(0);
200
+
201
+ // A new refund of the full amount is possible again
202
+ const retry = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
203
+ const retryResponse = await post(retry, organization, adminToken);
204
+ expect(retryResponse.body.price).toBe(-50_0000);
205
+ });
206
+
207
+ test('Can partially refund a Mollie payment with different balance items', async () => {
208
+ const { organization, adminToken, payment } = await init();
209
+
210
+ // The refunded balance item differs from the balance item of the source payment
211
+ const otherBalanceItem = await new BalanceItemFactory({
212
+ organizationId: organization.id,
213
+ amount: 1,
214
+ unitPrice: 20_0000,
215
+ }).create();
216
+
217
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[otherBalanceItem, -20_0000]]) });
218
+ const response = await post(body, organization, adminToken);
219
+
220
+ expect(response.body.price).toBe(-20_0000);
221
+ expect(mollieMocker.refunds).toHaveLength(1);
222
+ expect(mollieMocker.refunds[0].amount.value).toBe('20.00');
223
+
224
+ // Mollie executes the refund
225
+ await mollieMocker.settleRefund();
226
+
227
+ const updatedSource = await Payment.getByID(payment.id);
228
+ expect(updatedSource!.refundedAmount).toBe(-20_0000);
229
+
230
+ // The refunded item only has this (negative) refund payment linked
231
+ const updatedItem = await BalanceItem.getByID(otherBalanceItem.id);
232
+ expect(updatedItem!.pricePaid).toBe(-20_0000);
233
+ });
234
+
235
+ test('Cannot refund more than the price of the source payment', async () => {
236
+ const { organization, adminToken, balanceItem, payment } = await init();
237
+
238
+ // Allow a negative balance item price of -60 by increasing the due price
239
+ balanceItem.unitPrice = 60_0000;
240
+ await balanceItem.save();
241
+
242
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -60_0000]]) });
243
+
244
+ await expect(post(body, organization, adminToken)).rejects.toThrow(
245
+ STExpect.simpleError({ code: 'refund_amount_too_high' }),
246
+ );
247
+ expect(mollieMocker.refunds).toHaveLength(0);
248
+ });
249
+
250
+ test('Earlier refunds reduce the remaining refundable amount', async () => {
251
+ const { organization, adminToken, balanceItem, payment } = await init();
252
+
253
+ const first = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -40_0000]]) });
254
+ await post(first, organization, adminToken);
255
+
256
+ const second = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -20_0000]]) });
257
+ await expect(post(second, organization, adminToken)).rejects.toThrow(
258
+ STExpect.simpleError({ code: 'refund_amount_too_high' }),
259
+ );
260
+
261
+ // Only the first refund reached Mollie
262
+ expect(mollieMocker.refunds).toHaveLength(1);
263
+
264
+ // A refund of the remaining 10 euro is still possible
265
+ const third = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -10_0000]]) });
266
+ const response = await post(third, organization, adminToken);
267
+ expect(response.body.price).toBe(-10_0000);
268
+
269
+ // Both refunds are still pending at Mollie
270
+ const updatedSource = await Payment.getByID(payment.id);
271
+ expect(updatedSource!.refundedAmount).toBe(0);
272
+ expect(updatedSource!.pendingRefundAmount).toBe(-50_0000);
273
+ });
274
+
275
+ test('A refund refused by Mollie is not registered', async () => {
276
+ const { organization, adminToken, balanceItem, payment, mockPayment } = await init();
277
+
278
+ // Mollie only knows about 30 euro (e.g. a refund was already created in the Mollie dashboard),
279
+ // so our local check passes but Mollie refuses
280
+ mockPayment.amount.value = '30.00';
281
+
282
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
283
+
284
+ await expect(post(body, organization, adminToken)).rejects.toThrow(
285
+ STExpect.simpleError({ code: 'refund_failed' }),
286
+ );
287
+
288
+ // The created refund payment has been cleaned up again
289
+ const refunds = await Payment.select().where('reversingPaymentId', payment.id).fetch();
290
+ expect(refunds).toHaveLength(0);
291
+
292
+ const updatedSource = await Payment.getByID(payment.id);
293
+ expect(updatedSource!.refundedAmount).toBe(0);
294
+ expect(updatedSource!.pendingRefundAmount).toBe(0);
295
+
296
+ const updatedItem = await BalanceItem.getByID(balanceItem.id);
297
+ expect(updatedItem!.pricePaid).toBe(50_0000);
298
+ });
299
+
300
+ test('Cannot refund a payment that was not paid online via Mollie', async () => {
301
+ const { organization, adminToken, balanceItem } = await init();
302
+
303
+ const transferPayment = new Payment();
304
+ transferPayment.organizationId = organization.id;
305
+ transferPayment.method = PaymentMethod.Transfer;
306
+ transferPayment.status = PaymentStatus.Succeeded;
307
+ transferPayment.type = PaymentType.Payment;
308
+ transferPayment.price = 50_0000;
309
+ transferPayment.paidAt = new Date();
310
+ await transferPayment.save();
311
+
312
+ const body = buildRefund({ sourcePayment: transferPayment, items: new Map([[balanceItem, -50_0000]]) });
313
+
314
+ await expect(post(body, organization, adminToken)).rejects.toThrow(
315
+ STExpect.simpleError({ code: 'refund_not_supported' }),
316
+ );
317
+ });
318
+
319
+ test('Can refund a payment with a discount: items can be positive if the total is negative', async () => {
320
+ // A payment of 20 euro: 100 euro minus a discount of 80 euro (2 balance items)
321
+ const organization = await new OrganizationFactory({}).create();
322
+ await mollieMocker.setupToken(organization);
323
+ const { adminToken } = await initAdmin({ organization });
324
+
325
+ const item = await new BalanceItemFactory({
326
+ organizationId: organization.id,
327
+ amount: 1,
328
+ unitPrice: 100_0000,
329
+ pricePaid: 100_0000,
330
+ }).create();
331
+
332
+ const discount = await new BalanceItemFactory({
333
+ organizationId: organization.id,
334
+ amount: 1,
335
+ unitPrice: -80_0000,
336
+ pricePaid: -80_0000,
337
+ }).create();
338
+
339
+ const { payment } = await createMolliePayment({
340
+ organization,
341
+ balanceItems: new Map([[item, 100_0000], [discount, -80_0000]]),
342
+ });
343
+
344
+ // Reverse both items: the discount item gets a positive price in the refund
345
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[item, -100_0000], [discount, 80_0000]]) });
346
+ const response = await post(body, organization, adminToken);
347
+
348
+ expect(response.body.price).toBe(-20_0000);
349
+ expect(mollieMocker.refunds).toHaveLength(1);
350
+ expect(mollieMocker.refunds[0].amount.value).toBe('20.00');
351
+
352
+ // Mollie executes the refund
353
+ await mollieMocker.settleRefund();
354
+
355
+ const updatedSource = await Payment.getByID(payment.id);
356
+ expect(updatedSource!.refundedAmount).toBe(-20_0000);
357
+
358
+ const updatedItem = await BalanceItem.getByID(item.id);
359
+ expect(updatedItem!.pricePaid).toBe(0);
360
+
361
+ const updatedDiscount = await BalanceItem.getByID(discount.id);
362
+ expect(updatedDiscount!.pricePaid).toBe(0);
363
+ });
364
+
365
+ test('The total of a refund cannot be positive or zero', async () => {
366
+ const { organization, adminToken, balanceItem, payment } = await init();
367
+
368
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, 10_0000]]) });
369
+
370
+ await expect(post(body, organization, adminToken)).rejects.toThrow(
371
+ STExpect.simpleError({ code: 'invalid_field', field: 'price' }),
372
+ );
373
+ });
374
+
375
+ test('The refund reuses the customer of the source payment, so the VAT number cannot change', async () => {
376
+ const { organization, adminToken, balanceItem, payment } = await init();
377
+
378
+ payment.customer = PaymentCustomer.create({
379
+ firstName: 'Original',
380
+ lastName: 'Customer',
381
+ email: 'original@example.com',
382
+ company: Company.create({
383
+ name: 'Original Company',
384
+ VATNumber: 'BE0428759497',
385
+ }),
386
+ });
387
+ await payment.save();
388
+
389
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
390
+
391
+ // A (changed) customer in the body is ignored: the customer of the source payment wins
392
+ body.customer = PaymentCustomer.create({
393
+ firstName: 'Changed',
394
+ lastName: 'Customer',
395
+ email: 'changed@example.com',
396
+ company: Company.create({
397
+ name: 'Changed Company',
398
+ VATNumber: 'BE0417497106',
399
+ }),
400
+ });
401
+
402
+ const response = await post(body, organization, adminToken);
403
+ expect(response.body.customer?.company?.VATNumber).toBe('BE0428759497');
404
+ expect(response.body.customer?.company?.name).toBe('Original Company');
405
+
406
+ const refund = await Payment.getByID(response.body.id);
407
+ expect(refund!.customer?.company?.VATNumber).toBe('BE0428759497');
408
+ });
409
+
410
+ test('The customer of the request is used when the source payment has no customer', async () => {
411
+ const { organization, adminToken, balanceItem, payment } = await init();
412
+
413
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
414
+ body.customer = PaymentCustomer.create({
415
+ firstName: 'Fallback',
416
+ lastName: 'Customer',
417
+ email: 'fallback@example.com',
418
+ company: Company.create({
419
+ name: 'Fallback Company',
420
+ VATNumber: 'BE0417497106',
421
+ }),
422
+ });
423
+
424
+ const response = await post(body, organization, adminToken);
425
+ expect(response.body.customer?.company?.VATNumber).toBe('BE0417497106');
426
+ });
427
+
428
+ test('A put without a source payment is a manual payment and cannot use an online method', async () => {
429
+ const { organization, adminToken, balanceItem, payment } = await init();
430
+
431
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
432
+ body.reversingPaymentId = null;
433
+
434
+ await expect(post(body, organization, adminToken)).rejects.toThrow(
435
+ STExpect.simpleError({ code: 'invalid_field', field: 'method' }),
436
+ );
437
+ expect(mollieMocker.refunds).toHaveLength(0);
438
+ });
439
+
440
+ test('Only refunds can reverse another payment', async () => {
441
+ const { organization, adminToken, balanceItem, payment } = await init();
442
+
443
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
444
+ body.type = PaymentType.Payment;
445
+
446
+ await expect(post(body, organization, adminToken)).rejects.toThrow(
447
+ STExpect.simpleError({ code: 'invalid_field', field: 'type' }),
448
+ );
449
+ expect(mollieMocker.refunds).toHaveLength(0);
450
+ });
451
+
452
+ test('A refund for the balance item of an order bumps the updatedAt of the order', async () => {
453
+ // Clients keep a local copy of all orders (offline support) and only pull in orders with
454
+ // a newer updatedAt: every payment change of an order has to bump it
455
+ const { organization, adminToken, balanceItem, payment } = await init();
456
+
457
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
458
+ const order = new Order();
459
+ order.organizationId = organization.id;
460
+ order.webshopId = webshop.id;
461
+ order.data = OrderData.create({});
462
+ order.number = 1;
463
+ order.validAt = new Date();
464
+ await order.save();
465
+
466
+ balanceItem.orderId = order.id;
467
+ await balanceItem.save();
468
+
469
+ const updatedAtBefore = (await Order.getByID(order.id))!.updatedAt;
470
+
471
+ // updatedAt has second precision: make sure the bump is measurable
472
+ await new Promise(resolve => setTimeout(resolve, 1100));
473
+
474
+ const body = buildRefund({ sourcePayment: payment, items: new Map([[balanceItem, -50_0000]]) });
475
+ await post(body, organization, adminToken);
476
+
477
+ // The (pending) refund immediately bumps the order
478
+ let updatedOrder = await Order.getByID(order.id);
479
+ expect(updatedOrder!.updatedAt.getTime()).toBeGreaterThan(updatedAtBefore.getTime());
480
+
481
+ // The refund fails at Mollie: the order is bumped again (and stays valid)
482
+ const updatedAtPending = updatedOrder!.updatedAt;
483
+ await new Promise(resolve => setTimeout(resolve, 1100));
484
+ await mollieMocker.failRefund();
485
+
486
+ updatedOrder = await Order.getByID(order.id);
487
+ expect(updatedOrder!.updatedAt.getTime()).toBeGreaterThan(updatedAtPending.getTime());
488
+ expect(updatedOrder!.status).not.toBe(OrderStatus.Deleted);
489
+ });
490
+ });