@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.126.2",
3
+ "version": "2.127.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.126.2",
61
- "@stamhoofd/backend-i18n": "2.126.2",
62
- "@stamhoofd/backend-middleware": "2.126.2",
63
- "@stamhoofd/crons": "2.126.2",
64
- "@stamhoofd/email": "2.126.2",
65
- "@stamhoofd/excel-writer": "2.126.2",
66
- "@stamhoofd/logging": "2.126.2",
67
- "@stamhoofd/models": "2.126.2",
68
- "@stamhoofd/object-differ": "2.126.2",
69
- "@stamhoofd/queues": "2.126.2",
70
- "@stamhoofd/sql": "2.126.2",
71
- "@stamhoofd/structures": "2.126.2",
72
- "@stamhoofd/types": "2.126.2",
73
- "@stamhoofd/utility": "2.126.2",
60
+ "@stamhoofd/backend-env": "2.127.0",
61
+ "@stamhoofd/backend-i18n": "2.127.0",
62
+ "@stamhoofd/backend-middleware": "2.127.0",
63
+ "@stamhoofd/crons": "2.127.0",
64
+ "@stamhoofd/email": "2.127.0",
65
+ "@stamhoofd/excel-writer": "2.127.0",
66
+ "@stamhoofd/logging": "2.127.0",
67
+ "@stamhoofd/models": "2.127.0",
68
+ "@stamhoofd/object-differ": "2.127.0",
69
+ "@stamhoofd/queues": "2.127.0",
70
+ "@stamhoofd/sql": "2.127.0",
71
+ "@stamhoofd/structures": "2.127.0",
72
+ "@stamhoofd/types": "2.127.0",
73
+ "@stamhoofd/utility": "2.127.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.126.2",
94
+ "@stamhoofd/test-utils": "2.127.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": "ccfe4c07df84adf3b7720912aac11cba65811358"
110
+ "gitHead": "c3c61a7728293f5fe7b4b639f95b3cc79166265f"
111
111
  }
@@ -6,6 +6,7 @@ import './balance-emails.js';
6
6
  import './delete-old-email-drafts.js';
7
7
  import './delete-archived-data.js';
8
8
  import './mollie-chargebacks.js';
9
+ import './mollie-refunds.js';
9
10
  import './invoices.js';
10
11
  import './service-fees.js';
11
12
  import './members-fees.js';
@@ -23,7 +23,7 @@ async function invoices() {
23
23
  return;
24
24
  }
25
25
 
26
- if (isOutside('03:00', '10:00')) {
26
+ if (isOutside('03:00', '10:00') && STAMHOOFD.environment !== 'development') {
27
27
  return;
28
28
  }
29
29
 
@@ -52,10 +52,10 @@ async function createInvoicesFor(organization: Organization) {
52
52
  const today = Formatter.luxon();
53
53
  const startDate = today.day <= 15 ? today.minus({ month: 2 }).startOf('month') : today.minus({ month: 1 }).startOf('month');
54
54
 
55
- // Don't invoice below 1 euro - unless we reached the timeout date for invoices (end of month + 15 days - 3 days margin)
56
- const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 1_0000;
55
+ // Don't invoice below 10 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin)
56
+ const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 10_0000;
57
57
  function getPaymentTimeoutDate(p: Payment) {
58
- return Formatter.luxon(p.paidAt ?? p.createdAt).plus({ month: 1 }).set({ day: 15 - 3 }).startOf('day').toJSDate();
58
+ return Formatter.luxon(p.paidAt ?? p.createdAt).plus({ month: 1 }).set({ day: 10 }).startOf('day').toJSDate();
59
59
  }
60
60
 
61
61
  console.log('Fetching all payments between ' + Formatter.dateTime(startDate.toJSDate()) + ' and now for ' + organization.name);
@@ -80,8 +80,7 @@ async function createInvoicesFor(organization: Organization) {
80
80
  const blob = {
81
81
  // Grouping by payingOrganizationId avoid privacy issues and data leaks
82
82
  payingOrganizationId: payment.payingOrganizationId ?? null,
83
- vatNumber: payment.customer?.company?.VATNumber,
84
- companyNumber: payment.customer?.company?.companyNumber,
83
+ vatNumber: Formatter.slugVATNumber(payment.customer?.company?.VATNumber ?? payment.customer?.company?.companyNumber ?? ''),
85
84
  // Name and adress is ignored, because subject to changes
86
85
  };
87
86
  const id = JSON.stringify(blob);
@@ -0,0 +1,310 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
3
+ import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
4
+ import type { MollieMockPayment } from '../../tests/helpers/MollieMocker.js';
5
+ import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
6
+ import { MollieService } from '../services/MollieService.js';
7
+ import { checkMollieRefundsFor, MOLLIE_REFUNDS_MINIMUM_DATE } from './mollie-refunds.js';
8
+
9
+ describe('Cron.mollie-refunds', () => {
10
+ let mollieMocker: MollieMocker;
11
+
12
+ beforeAll(() => {
13
+ mollieMocker = new MollieMocker();
14
+ mollieMocker.start();
15
+ });
16
+
17
+ afterAll(() => {
18
+ mollieMocker.stop();
19
+ });
20
+
21
+ beforeEach(() => {
22
+ mollieMocker.reset();
23
+ });
24
+
25
+ /**
26
+ * Create an organization with a Mollie token and a succeeded Mollie payment
27
+ * for the given balance item prices.
28
+ */
29
+ const init = async ({ prices = [50_0000] }: { prices?: number[] } = {}) => {
30
+ const organization = await new OrganizationFactory({}).create();
31
+ await mollieMocker.setupToken(organization);
32
+
33
+ const balanceItems: BalanceItem[] = [];
34
+ let price = 0;
35
+ for (const itemPrice of prices) {
36
+ balanceItems.push(await new BalanceItemFactory({
37
+ organizationId: organization.id,
38
+ amount: 1,
39
+ unitPrice: itemPrice,
40
+ pricePaid: itemPrice,
41
+ }).create());
42
+ price += itemPrice;
43
+ }
44
+
45
+ const payment = new Payment();
46
+ payment.organizationId = organization.id;
47
+ payment.method = PaymentMethod.Bancontact;
48
+ payment.provider = PaymentProvider.Mollie;
49
+ payment.status = PaymentStatus.Succeeded;
50
+ payment.type = PaymentType.Payment;
51
+ payment.price = price;
52
+ payment.paidAt = new Date();
53
+ await payment.save();
54
+
55
+ for (const [index, balanceItem] of balanceItems.entries()) {
56
+ const balanceItemPayment = new BalanceItemPayment();
57
+ balanceItemPayment.balanceItemId = balanceItem.id;
58
+ balanceItemPayment.paymentId = payment.id;
59
+ balanceItemPayment.organizationId = organization.id;
60
+ balanceItemPayment.price = prices[index];
61
+ await balanceItemPayment.save();
62
+ }
63
+
64
+ const mockPayment: MollieMockPayment = {
65
+ id: mollieMocker.createId('tr'),
66
+ status: 'paid',
67
+ amount: { currency: 'EUR', value: (Math.round(price / 100) / 100).toFixed(2) },
68
+ internalPaymentId: payment.id,
69
+ redirectUrl: null,
70
+ sequenceType: 'oneoff',
71
+ customerId: null,
72
+ mandateId: null,
73
+ isCancelable: false,
74
+ details: null,
75
+ };
76
+ mollieMocker.payments.push(mockPayment);
77
+
78
+ const molliePayment = new MolliePayment();
79
+ molliePayment.paymentId = payment.id;
80
+ molliePayment.mollieId = mockPayment.id;
81
+ await molliePayment.save();
82
+
83
+ return { organization, balanceItems, payment, mockPayment };
84
+ };
85
+
86
+ const runCron = async (organization: Organization) => {
87
+ const service = await MollieService.create({ sellingOrganization: organization });
88
+ expect(service).not.toBeNull();
89
+ await checkMollieRefundsFor(service!, false);
90
+ };
91
+
92
+ const getRefundPayments = async (payment: Payment) => {
93
+ return await Payment.select().where('reversingPaymentId', payment.id).fetch();
94
+ };
95
+
96
+ test('A refund created in the Mollie dashboard is registered pending and reconciled once executed', async () => {
97
+ const { organization, balanceItems, payment, mockPayment } = await init();
98
+
99
+ const mockRefund = mollieMocker.createRefund(mockPayment);
100
+ await runCron(organization);
101
+
102
+ // Mollie still has to execute the refund, so it is registered as pending
103
+ const refunds = await getRefundPayments(payment);
104
+ expect(refunds).toHaveLength(1);
105
+ expect(refunds[0]).toMatchObject({
106
+ type: PaymentType.Refund,
107
+ price: -50_0000,
108
+ status: PaymentStatus.Pending,
109
+ method: PaymentMethod.Bancontact,
110
+ provider: PaymentProvider.Mollie,
111
+ });
112
+
113
+ // Linked so it won't get registered twice
114
+ const link = await MolliePayment.select().where('mollieId', mockRefund.id).first(false);
115
+ expect(link).not.toBeNull();
116
+ expect(link!.paymentId).toBe(refunds[0].id);
117
+
118
+ // Running the cron again does not create a duplicate
119
+ await runCron(organization);
120
+ expect(await getRefundPayments(payment)).toHaveLength(1);
121
+
122
+ // Nothing is refunded yet
123
+ let updatedSource = await Payment.getByID(payment.id);
124
+ expect(updatedSource!.refundedAmount).toBe(0);
125
+ expect(updatedSource!.pendingRefundAmount).toBe(-50_0000);
126
+
127
+ // Mollie executes the refund: the next cron run marks it as succeeded
128
+ mockRefund.status = 'refunded';
129
+ await runCron(organization);
130
+
131
+ const updatedRefund = await Payment.getByID(refunds[0].id);
132
+ expect(updatedRefund!.status).toBe(PaymentStatus.Succeeded);
133
+
134
+ // The source payment tracks the refunded amount and the balance item is no longer paid
135
+ updatedSource = await Payment.getByID(payment.id);
136
+ expect(updatedSource!.refundedAmount).toBe(-50_0000);
137
+ expect(updatedSource!.pendingRefundAmount).toBe(0);
138
+
139
+ const updatedItem = await BalanceItem.getByID(balanceItems[0].id);
140
+ expect(updatedItem!.pricePaid).toBe(0);
141
+ });
142
+
143
+ test('A pending refund that fails at Mollie is marked as failed by the cron', async () => {
144
+ const { organization, balanceItems, payment, mockPayment } = await init();
145
+
146
+ const mockRefund = mollieMocker.createRefund(mockPayment);
147
+ await runCron(organization);
148
+
149
+ const refunds = await getRefundPayments(payment);
150
+ expect(refunds).toHaveLength(1);
151
+ expect(refunds[0].status).toBe(PaymentStatus.Pending);
152
+
153
+ // The refund failed at Mollie (e.g. the bank refused the transfer)
154
+ mockRefund.status = 'failed';
155
+ await runCron(organization);
156
+
157
+ const updatedRefund = await Payment.getByID(refunds[0].id);
158
+ expect(updatedRefund!.status).toBe(PaymentStatus.Failed);
159
+
160
+ // Nothing was refunded: the balance item stays paid and the amount is refundable again
161
+ const updatedSource = await Payment.getByID(payment.id);
162
+ expect(updatedSource!.refundedAmount).toBe(0);
163
+ expect(updatedSource!.pendingRefundAmount).toBe(0);
164
+
165
+ const updatedItem = await BalanceItem.getByID(balanceItems[0].id);
166
+ expect(updatedItem!.pricePaid).toBe(50_0000);
167
+ });
168
+
169
+ test('A refund with a missing link is relinked via its metadata instead of registered twice', async () => {
170
+ const { organization, payment, mockPayment } = await init();
171
+
172
+ // A local refund payment that was created via the refund endpoint, but saving the
173
+ // Mollie link failed afterwards
174
+ const localRefund = new Payment();
175
+ localRefund.organizationId = organization.id;
176
+ localRefund.method = PaymentMethod.Bancontact;
177
+ localRefund.provider = PaymentProvider.Mollie;
178
+ localRefund.status = PaymentStatus.Pending;
179
+ localRefund.type = PaymentType.Refund;
180
+ localRefund.price = -10_0000;
181
+ localRefund.reversingPaymentId = payment.id;
182
+ await localRefund.save();
183
+
184
+ const mockRefund = mollieMocker.createRefund(mockPayment, {
185
+ value: '10.00',
186
+ status: 'refunded',
187
+ metadata: { refundPaymentId: localRefund.id },
188
+ });
189
+
190
+ await runCron(organization);
191
+
192
+ // No duplicate refund payment was registered
193
+ const refunds = await getRefundPayments(payment);
194
+ expect(refunds).toHaveLength(1);
195
+ expect(refunds[0].id).toBe(localRefund.id);
196
+
197
+ // The link was restored and the status reconciled
198
+ const link = await MolliePayment.select().where('mollieId', mockRefund.id).first(false);
199
+ expect(link).not.toBeNull();
200
+ expect(link!.paymentId).toBe(localRefund.id);
201
+
202
+ const updatedRefund = await Payment.getByID(localRefund.id);
203
+ expect(updatedRefund!.status).toBe(PaymentStatus.Succeeded);
204
+ });
205
+
206
+ test('A partial refund is allocated across the balance items of the payment', async () => {
207
+ const { organization, balanceItems, payment, mockPayment } = await init({ prices: [30_0000, 20_0000] });
208
+
209
+ mollieMocker.createRefund(mockPayment, { value: '40.00', status: 'refunded' });
210
+ await runCron(organization);
211
+
212
+ const refunds = await getRefundPayments(payment);
213
+ expect(refunds).toHaveLength(1);
214
+ expect(refunds[0].price).toBe(-40_0000);
215
+ expect(refunds[0].status).toBe(PaymentStatus.Succeeded);
216
+
217
+ const refundItems = await BalanceItemPayment.select().where('paymentId', refunds[0].id).fetch();
218
+ expect(refundItems).toHaveLength(2);
219
+
220
+ // The allocation order over the balance items is not deterministic: only check that
221
+ // every item stays within what was paid for it and that the total matches
222
+ const first = refundItems.find(i => i.balanceItemId === balanceItems[0].id);
223
+ const second = refundItems.find(i => i.balanceItemId === balanceItems[1].id);
224
+ expect(first!.price + second!.price).toBe(-40_0000);
225
+ expect(first!.price).toBeGreaterThanOrEqual(-30_0000);
226
+ expect(first!.price).toBeLessThan(0);
227
+ expect(second!.price).toBeGreaterThanOrEqual(-20_0000);
228
+ expect(second!.price).toBeLessThan(0);
229
+ });
230
+
231
+ test('Multiple refunds cannot exceed the payment amount', async () => {
232
+ const { organization, payment, mockPayment } = await init();
233
+
234
+ mollieMocker.createRefund(mockPayment, { value: '40.00', status: 'refunded' });
235
+ await runCron(organization);
236
+ expect(await getRefundPayments(payment)).toHaveLength(1);
237
+
238
+ // A second refund that exceeds the remainder is skipped (the error is logged)
239
+ mollieMocker.createRefund(mockPayment, { value: '20.00', status: 'refunded' });
240
+ await runCron(organization);
241
+ expect(await getRefundPayments(payment)).toHaveLength(1);
242
+
243
+ // A second refund within the remainder is registered
244
+ mollieMocker.createRefund(mockPayment, { value: '10.00', status: 'refunded' });
245
+ await runCron(organization);
246
+
247
+ const refunds = await getRefundPayments(payment);
248
+ expect(refunds).toHaveLength(2);
249
+
250
+ const updatedSource = await Payment.getByID(payment.id);
251
+ expect(updatedSource!.refundedAmount).toBe(-50_0000);
252
+ });
253
+
254
+ test('Refunds created before the minimum date are ignored', async () => {
255
+ const { organization, payment, mockPayment } = await init();
256
+
257
+ mollieMocker.createRefund(mockPayment, {
258
+ createdAt: new Date(MOLLIE_REFUNDS_MINIMUM_DATE.getTime() - 1000 * 60 * 60 * 24),
259
+ });
260
+ await runCron(organization);
261
+
262
+ expect(await getRefundPayments(payment)).toHaveLength(0);
263
+ });
264
+
265
+ test('Canceled and failed refunds are ignored', async () => {
266
+ const { organization, payment, mockPayment } = await init();
267
+
268
+ mollieMocker.createRefund(mockPayment, { value: '10.00', status: 'canceled' });
269
+ mollieMocker.createRefund(mockPayment, { value: '10.00', status: 'failed' });
270
+ await runCron(organization);
271
+
272
+ expect(await getRefundPayments(payment)).toHaveLength(0);
273
+ });
274
+
275
+ test('An already linked refund does not block older unhandled refunds', async () => {
276
+ const { organization, payment, mockPayment } = await init();
277
+
278
+ // An older refund created in the Mollie dashboard (unhandled)
279
+ mollieMocker.createRefund(mockPayment, {
280
+ value: '10.00',
281
+ createdAt: new Date(Date.now() - 1000 * 60 * 60),
282
+ });
283
+
284
+ // A newer refund that was already registered (e.g. created via the refund endpoint):
285
+ // its Mollie refund id is linked to a refund payment
286
+ const handled = mollieMocker.createRefund(mockPayment, { value: '5.00' });
287
+
288
+ const handledRefundPayment = new Payment();
289
+ handledRefundPayment.organizationId = organization.id;
290
+ handledRefundPayment.method = PaymentMethod.Bancontact;
291
+ handledRefundPayment.provider = PaymentProvider.Mollie;
292
+ handledRefundPayment.status = PaymentStatus.Succeeded;
293
+ handledRefundPayment.type = PaymentType.Refund;
294
+ handledRefundPayment.price = -5_0000;
295
+ handledRefundPayment.paidAt = new Date();
296
+ await handledRefundPayment.save();
297
+
298
+ const existingLink = new MolliePayment();
299
+ existingLink.paymentId = handledRefundPayment.id;
300
+ existingLink.mollieId = handled.id;
301
+ await existingLink.save();
302
+
303
+ await runCron(organization);
304
+
305
+ // Only the unhandled (older) refund was registered
306
+ const refunds = await getRefundPayments(payment);
307
+ expect(refunds).toHaveLength(1);
308
+ expect(refunds[0].price).toBe(-10_0000);
309
+ });
310
+ });
@@ -0,0 +1,157 @@
1
+ import { RefundStatus } from '@mollie/api-client';
2
+ import { registerCron } from '@stamhoofd/crons';
3
+ import { MolliePayment, MollieToken, Organization, Payment } from '@stamhoofd/models';
4
+ import { PaymentStatus, PaymentType } from '@stamhoofd/structures';
5
+ import { MollieService } from '../services/MollieService.js';
6
+ import { PaymentService } from '../services/PaymentService.js';
7
+
8
+ registerCron('mollie-refunds', checkMollieRefunds);
9
+
10
+ /**
11
+ * Refunds created before this date are ignored: those were created before
12
+ * refund detection existed and have been handled manually.
13
+ */
14
+ export const MOLLIE_REFUNDS_MINIMUM_DATE = new Date('2026-06-22T00:00:00.000Z');
15
+
16
+ let lastRun: Date | null = null;
17
+ export async function checkMollieRefunds() {
18
+ if (STAMHOOFD.environment !== 'development') {
19
+ if (lastRun && new Date().getTime() - lastRun.getTime() < 1000 * 60 * 60 * 24) {
20
+ return;
21
+ }
22
+ lastRun = new Date();
23
+ }
24
+ await doCheckMollieRefunds(false);
25
+ }
26
+
27
+ export async function doCheckMollieRefunds(checkAll = false) {
28
+ if (STAMHOOFD.environment !== 'development') {
29
+ console.log('Checking Mollie refunds');
30
+ }
31
+
32
+ // Loop all mollie tokens
33
+ for await (const token of MollieToken.select().limit(1).all({}, 'organizationId')) {
34
+ const sellingOrganization = await Organization.getByID(token.id);
35
+ if (sellingOrganization) {
36
+ const service = await MollieService.create({ sellingOrganization });
37
+ if (service) {
38
+ await checkMollieRefundsFor(service, checkAll);
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ export async function checkMollieRefundsFor(service: MollieService, checkAll = false) {
45
+ if (STAMHOOFD.environment !== 'development') {
46
+ console.log('Checking Mollie refunds for ' + service.sellingOrganization.name);
47
+ }
48
+
49
+ // Check last 7 days
50
+ const offset = new Date(Date.now() - 1000 * 60 * 60 * 24 * 7);
51
+
52
+ // due to a bug in mollie client code, testmode paramter is missing in the typescript definitions
53
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
54
+ for await (const refund of service.client.refunds.iterate({ sort: 'desc', testmode: service.testMode } as any)) {
55
+ const createdAt = new Date(refund.createdAt);
56
+
57
+ if (createdAt < MOLLIE_REFUNDS_MINIMUM_DATE) {
58
+ break;
59
+ }
60
+
61
+ if (!checkAll && createdAt < offset) {
62
+ break;
63
+ }
64
+
65
+ // Check if this refund is already registered (refunds created via the API are linked on
66
+ // creation, so we cannot break here: an older refund created in the Mollie UI could still
67
+ // be unhandled). Registered refunds that are still pending locally are reconciled: Mollie
68
+ // refunds can still fail or be canceled until they are actually executed.
69
+ const existingRefund = await MolliePayment.select().where('mollieId', refund.id).first(false);
70
+ if (existingRefund) {
71
+ await reconcileRefundStatus(existingRefund.paymentId, refund.status, createdAt);
72
+ continue;
73
+ }
74
+
75
+ // Refunds that were canceled or failed before we ever registered them can be ignored
76
+ if (refund.status === RefundStatus.canceled || refund.status === RefundStatus.failed) {
77
+ continue;
78
+ }
79
+
80
+ // Safety net: when creating a refund via the API succeeded, but saving the link afterwards
81
+ // failed, the local refund payment already exists. Restore the link instead of registering
82
+ // the refund twice (the metadata is set in PaymentService.createRefund).
83
+ const metadata = refund.metadata as { refundPaymentId?: string } | null;
84
+ if (metadata?.refundPaymentId) {
85
+ const localRefund = await Payment.getByID(metadata.refundPaymentId);
86
+ if (localRefund && localRefund.type === PaymentType.Refund) {
87
+ console.error('Restoring missing link for Mollie refund ' + refund.id + ' to payment ' + localRefund.id);
88
+ const link = new MolliePayment();
89
+ link.paymentId = localRefund.id;
90
+ link.mollieId = refund.id;
91
+ await link.save();
92
+
93
+ await reconcileRefundStatus(localRefund.id, refund.status, createdAt);
94
+ continue;
95
+ }
96
+ }
97
+
98
+ if (refund.paymentId) {
99
+ const molliePayment = await MolliePayment.select().where('mollieId', refund.paymentId).first(false);
100
+ if (molliePayment) {
101
+ const payment = await Payment.getByID(molliePayment.paymentId);
102
+ if (payment) {
103
+ try {
104
+ const amount = Math.round(parseFloat(refund.amount.value) * 100) * 100;
105
+ const status = MollieService.refundStatusToPaymentStatus(refund.status) === PaymentStatus.Succeeded ? PaymentStatus.Succeeded : PaymentStatus.Pending;
106
+ const createdPayment = await PaymentService.registerRefund(payment, amount, createdAt, status);
107
+
108
+ // Link Mollie refund ID (so we don't register it twice and can set the settlement later)
109
+ const molliePayment = new MolliePayment();
110
+ molliePayment.paymentId = createdPayment.id;
111
+ molliePayment.mollieId = refund.id;
112
+ await molliePayment.save();
113
+ } catch (e) {
114
+ console.error('Failed to register refund ' + refund.id, e);
115
+ }
116
+ }
117
+ } else {
118
+ console.error('Invalid refund payment id ' + refund.paymentId + ', not found');
119
+ }
120
+ }
121
+ }
122
+
123
+ if (STAMHOOFD.environment !== 'development') {
124
+ console.log('Done checking Mollie refunds for ' + service.sellingOrganization.name);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Update a locally registered refund that is still pending when Mollie reports a final status
130
+ * (refunded, failed or canceled).
131
+ */
132
+ async function reconcileRefundStatus(paymentId: string, mollieStatus: RefundStatus, createdAt: Date) {
133
+ const payment = await Payment.getByID(paymentId);
134
+ if (!payment || !payment.organizationId) {
135
+ return;
136
+ }
137
+
138
+ if (payment.status !== PaymentStatus.Pending && payment.status !== PaymentStatus.Created) {
139
+ return;
140
+ }
141
+
142
+ const status = MollieService.refundStatusToPaymentStatus(mollieStatus);
143
+ if (status === payment.status) {
144
+ return;
145
+ }
146
+
147
+ const organization = await Organization.getByID(payment.organizationId);
148
+ if (!organization) {
149
+ return;
150
+ }
151
+
152
+ try {
153
+ await PaymentService.handlePaymentStatusUpdate(payment, organization, status, createdAt);
154
+ } catch (e) {
155
+ console.error('Failed to reconcile refund status for payment ' + payment.id, e);
156
+ }
157
+ }
@@ -8,8 +8,8 @@ registerCron('stripe-invoices', createStripeInvoices);
8
8
  let lastStripeInvoice: Date | null = null;
9
9
 
10
10
  async function createStripeInvoices() {
11
- if (STAMHOOFD.environment !== 'production') {
12
- // return;
11
+ if (STAMHOOFD.environment !== 'production' && STAMHOOFD.environment !== 'development') {
12
+ return;
13
13
  }
14
14
 
15
15
  if (STAMHOOFD.userMode === 'platform') {
@@ -26,6 +26,7 @@ async function createStripeInvoices() {
26
26
  console.log('Stripe check done for this day');
27
27
  return;
28
28
  }
29
+
29
30
  console.log('Creating Stripe Invoices...');
30
31
 
31
32
  if (!STAMHOOFD.STRIPE_SECRET_KEY) {
@@ -245,6 +245,9 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
245
245
  }
246
246
  });
247
247
 
248
+ // Make sure clients refetch the orders these balance items belong to
249
+ await BalanceItemService.markOrdersUpdated(returnedModels);
250
+
248
251
  // Update balances before we return the up to date versions
249
252
  await BalanceItemService.flushCaches(organization.id);
250
253
 
@@ -3,7 +3,7 @@ import { PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encodin
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { DocumentTemplate } from '@stamhoofd/models';
6
+ import { Document, DocumentTemplate } from '@stamhoofd/models';
7
7
  import { DocumentTemplatePrivate, PermissionLevel } from '@stamhoofd/structures';
8
8
 
9
9
  import { SQL, SQLWhereSign } from '@stamhoofd/sql';
@@ -74,6 +74,10 @@ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body
74
74
 
75
75
  let shouldCheckIfAlreadyHasFiscalDocument = false;
76
76
 
77
+ if (patch.isLocked !== undefined) {
78
+ template.isLocked = patch.isLocked;
79
+ }
80
+
77
81
  if (patch.privateSettings) {
78
82
  const patchType = patch.privateSettings.templateDefinition?.type;
79
83
 
@@ -113,6 +117,13 @@ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body
113
117
 
114
118
  await template.save();
115
119
 
120
+ if (patch.isLocked !== undefined) {
121
+ await Document.update()
122
+ .where('templateId', template.id)
123
+ .set('isLocked', template.isLocked)
124
+ .update();
125
+ }
126
+
116
127
  // Update documents
117
128
  await template.buildAll();
118
129