@stamhoofd/backend 2.140.0 → 2.142.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.
Files changed (65) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  12. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  13. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  14. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  15. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  16. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  17. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  18. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  19. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  22. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  23. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  24. package/src/excel-loaders/payments.ts +18 -1
  25. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  26. package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
  27. package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
  28. package/src/helpers/AuthenticatedStructures.ts +14 -1
  29. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  30. package/src/helpers/MollieSettlementSync.ts +323 -0
  31. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  32. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  33. package/src/helpers/SettlementExporter.test.ts +388 -0
  34. package/src/helpers/SettlementExporter.ts +593 -0
  35. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  36. package/src/helpers/SettlementSyncRunner.ts +64 -0
  37. package/src/helpers/StripeHelper.ts +2 -1
  38. package/src/helpers/StripeSettlementSync.test.ts +997 -0
  39. package/src/helpers/StripeSettlementSync.ts +1053 -0
  40. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  41. package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
  42. package/src/helpers/WebmasterReport.test.ts +109 -0
  43. package/src/helpers/WebmasterReport.ts +115 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  45. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +301 -0
  48. package/src/services/InvoiceService.ts +18 -1
  49. package/src/services/SettlementService.test.ts +632 -0
  50. package/src/services/SettlementService.ts +650 -0
  51. package/src/sql-filters/payment-settlement.test.ts +2 -2
  52. package/src/sql-filters/payments.ts +73 -0
  53. package/tests/helpers/MollieMocker.ts +64 -6
  54. package/tests/helpers/StripeMocker.ts +209 -17
  55. package/src/crons/stripe-payout-reports.ts +0 -69
  56. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  57. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  58. package/src/helpers/CheckSettlements.test.ts +0 -190
  59. package/src/helpers/CheckSettlements.ts +0 -237
  60. package/src/helpers/StripeInvoicer.ts +0 -419
  61. package/src/helpers/StripePayoutChecker.ts +0 -193
  62. package/src/helpers/StripePayoutExportData.ts +0 -195
  63. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  64. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  65. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,361 @@
1
+ import type { MollieToken } from '@stamhoofd/models';
2
+ import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
3
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
4
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
5
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
6
+ import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
7
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
8
+ import type { MollieMockPayment, MollieMockRefund } from '../../tests/helpers/MollieMocker.js';
9
+ import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
10
+ import { MollieSettlementSync } from './MollieSettlementSync.js';
11
+ import type { SettlementSyncSummary } from './ProviderSettlementSyncRunner.js';
12
+
13
+ describe('Helper.MollieSettlementSync', () => {
14
+ let mollieMocker: MollieMocker;
15
+
16
+ beforeAll(() => {
17
+ mollieMocker = new MollieMocker();
18
+ mollieMocker.start();
19
+ });
20
+
21
+ afterAll(() => {
22
+ mollieMocker.stop();
23
+ });
24
+
25
+ beforeEach(() => {
26
+ mollieMocker.reset();
27
+ });
28
+
29
+ /**
30
+ * Create an organization with a Mollie token, a succeeded Mollie payment and a Mollie refund
31
+ * payment reversing it. Both are linked to their Mollie ids (tr_... / re_...) like the real crons do.
32
+ */
33
+ const init = async () => {
34
+ const organization = await new OrganizationFactory({}).create();
35
+ const token = await mollieMocker.setupToken(organization);
36
+
37
+ // Source payment
38
+ const payment = new Payment();
39
+ payment.organizationId = organization.id;
40
+ payment.method = PaymentMethod.Bancontact;
41
+ payment.provider = PaymentProvider.Mollie;
42
+ payment.status = PaymentStatus.Succeeded;
43
+ payment.type = PaymentType.Payment;
44
+ payment.price = 50_0000;
45
+ payment.paidAt = new Date();
46
+ await payment.save();
47
+
48
+ const mockPayment: MollieMockPayment = {
49
+ id: mollieMocker.createId('tr'),
50
+ status: 'paid',
51
+ amount: { currency: 'EUR', value: '50.00' },
52
+ internalPaymentId: payment.id,
53
+ redirectUrl: null,
54
+ sequenceType: 'oneoff',
55
+ customerId: null,
56
+ mandateId: null,
57
+ isCancelable: false,
58
+ details: null,
59
+ };
60
+ mollieMocker.payments.push(mockPayment);
61
+
62
+ const paymentLink = new MolliePayment();
63
+ paymentLink.paymentId = payment.id;
64
+ paymentLink.mollieId = mockPayment.id;
65
+ await paymentLink.save();
66
+
67
+ // Refund payment reversing the source payment
68
+ const refundPayment = new Payment();
69
+ refundPayment.organizationId = organization.id;
70
+ refundPayment.method = PaymentMethod.Bancontact;
71
+ refundPayment.provider = PaymentProvider.Mollie;
72
+ refundPayment.status = PaymentStatus.Succeeded;
73
+ refundPayment.type = PaymentType.Refund;
74
+ refundPayment.price = -20_0000;
75
+ refundPayment.reversingPaymentId = payment.id;
76
+ refundPayment.paidAt = new Date();
77
+ await refundPayment.save();
78
+
79
+ const mockRefund = mollieMocker.createRefund(mockPayment, { value: '20.00', status: 'refunded' });
80
+
81
+ const refundLink = new MolliePayment();
82
+ refundLink.paymentId = refundPayment.id;
83
+ refundLink.mollieId = mockRefund.id;
84
+ await refundLink.save();
85
+
86
+ return { organization, token, payment, refundPayment, mockPayment, mockRefund };
87
+ };
88
+
89
+ const runCron = async (token: MollieToken, options: { start?: Date; end?: Date; summary?: SettlementSyncSummary } = {}) => {
90
+ await new MollieSettlementSync({ token }).syncSettlements({
91
+ start: options.start ?? new Date(2020, 0, 1),
92
+ end: options.end,
93
+ summary: options.summary,
94
+ });
95
+ };
96
+
97
+ /**
98
+ * Add a Mollie chargeback payment reversing the given source payment, linked to its Mollie
99
+ * chargeback id (chb_...) like the mollie-chargebacks cron does.
100
+ */
101
+ const addChargeback = async (organizationId: string, sourcePayment: Payment, mockPayment: MollieMockPayment) => {
102
+ const chargebackPayment = new Payment();
103
+ chargebackPayment.organizationId = organizationId;
104
+ chargebackPayment.method = PaymentMethod.Bancontact;
105
+ chargebackPayment.provider = PaymentProvider.Mollie;
106
+ chargebackPayment.status = PaymentStatus.Succeeded;
107
+ chargebackPayment.type = PaymentType.Chargeback;
108
+ chargebackPayment.price = -sourcePayment.price;
109
+ chargebackPayment.reversingPaymentId = sourcePayment.id;
110
+ chargebackPayment.paidAt = new Date();
111
+ await chargebackPayment.save();
112
+
113
+ const mockChargeback = mollieMocker.createChargeback(mockPayment);
114
+
115
+ const chargebackLink = new MolliePayment();
116
+ chargebackLink.paymentId = chargebackPayment.id;
117
+ chargebackLink.mollieId = mockChargeback.id;
118
+ await chargebackLink.save();
119
+
120
+ return { chargebackPayment, mockChargeback };
121
+ };
122
+
123
+ test('The settlement of a refund settled at Mollie is stored on the refund payment', async () => {
124
+ const { token, payment, refundPayment, mockPayment, mockRefund } = await init();
125
+
126
+ const settlement = mollieMocker.createSettlement({
127
+ payments: [mockPayment],
128
+ refunds: [mockRefund],
129
+ value: '100.00',
130
+ });
131
+
132
+ await runCron(token);
133
+
134
+ // The source payment gets the settlement metadata (existing behaviour)
135
+ const updatedPayment = await Payment.getByID(payment.id);
136
+ expect(updatedPayment!.settlement).toMatchObject({
137
+ id: settlement.id,
138
+ reference: settlement.reference,
139
+ });
140
+
141
+ // The refund payment gets the same settlement metadata (new behaviour)
142
+ const updatedRefund = await Payment.getByID(refundPayment.id);
143
+ expect(updatedRefund!.settlement).toMatchObject({
144
+ id: settlement.id,
145
+ reference: settlement.reference,
146
+ amount: 100_0000,
147
+ });
148
+ });
149
+
150
+ test('A refund that is not part of any settlement keeps no settlement', async () => {
151
+ const { token, refundPayment, mockPayment } = await init();
152
+
153
+ // A settlement that only contains the source payment, not the refund
154
+ mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00' });
155
+
156
+ await runCron(token);
157
+
158
+ const updatedRefund = await Payment.getByID(refundPayment.id);
159
+ expect(updatedRefund!.settlement).toBeNull();
160
+ });
161
+
162
+ test('The settlement of a chargeback settled at Mollie is stored on the chargeback payment', async () => {
163
+ const { organization, token, payment, mockPayment } = await init();
164
+ const { chargebackPayment, mockChargeback } = await addChargeback(organization.id, payment, mockPayment);
165
+
166
+ const settlement = mollieMocker.createSettlement({
167
+ payments: [mockPayment],
168
+ chargebacks: [mockChargeback],
169
+ value: '100.00',
170
+ });
171
+
172
+ await runCron(token);
173
+
174
+ const updatedChargeback = await Payment.getByID(chargebackPayment.id);
175
+ expect(updatedChargeback!.settlement).toMatchObject({
176
+ id: settlement.id,
177
+ reference: settlement.reference,
178
+ amount: 100_0000,
179
+ });
180
+ });
181
+
182
+ describe('Settlement rows', () => {
183
+ const getSettlementRow = async (externalId: string) => {
184
+ return await Settlement.select().where('externalId', externalId).first(true);
185
+ };
186
+
187
+ test('legacy JSON and new rows agree, and the settlement reconciles to zero', async () => {
188
+ const { organization, token, payment, refundPayment, mockPayment, mockRefund } = await init();
189
+
190
+ // 50.00 - 20.00 in entries, minus 0.30 costs + 0.06 VAT
191
+ const settlement = mollieMocker.createSettlement({
192
+ payments: [mockPayment],
193
+ refunds: [mockRefund],
194
+ value: '29.64',
195
+ invoiceId: 'inv_123',
196
+ periods: {
197
+ 2026: {
198
+ '01': {
199
+ costs: [{
200
+ description: 'Bancontact betalingen',
201
+ method: 'bancontact',
202
+ amountNet: { currency: 'EUR', value: '0.30' },
203
+ amountVat: { currency: 'EUR', value: '0.06' },
204
+ }],
205
+ },
206
+ },
207
+ },
208
+ });
209
+
210
+ await runCron(token);
211
+
212
+ const row = await getSettlementRow(settlement.id);
213
+ expect(row).toMatchObject({
214
+ provider: PaymentProvider.Mollie,
215
+ organizationId: organization.id,
216
+ reference: settlement.reference,
217
+ amount: 29_6400,
218
+ unexplainedAmount: 0,
219
+ });
220
+ expect(row.syncedAt).not.toBeNull();
221
+
222
+ const lines = await PaymentSettlement.select().where('settlementId', row.id).fetch();
223
+ expect(lines.map(l => [l.externalId, l.paymentId, l.amount]).sort()).toEqual([
224
+ [mockPayment.id, payment.id, 50_0000],
225
+ [mockRefund.id, refundPayment.id, -20_0000],
226
+ ].sort());
227
+
228
+ const charges = await SettlementCharge.select().where('settlementId', row.id).fetch();
229
+ expect(charges.map(c => ({ type: c.type, amount: c.amount, providerInvoiceId: c.providerInvoiceId, occurredAt: c.occurredAt })).sort((a, b) => a.amount - b.amount)).toEqual([
230
+ { type: SettlementChargeType.ProviderTransactionFee, amount: -30_00, providerInvoiceId: 'inv_123', occurredAt: new Date(2026, 0, 1) },
231
+ { type: SettlementChargeType.Tax, amount: -6_00, providerInvoiceId: 'inv_123', occurredAt: new Date(2026, 0, 1) },
232
+ ]);
233
+
234
+ // The legacy blob agrees with the new settlement row
235
+ const updatedPayment = await Payment.getByID(payment.id);
236
+ expect(updatedPayment!.settlement!.id).toBe(row.externalId);
237
+ expect(updatedPayment!.settlement!.amount).toBe(row.amount);
238
+ });
239
+
240
+ test('the invoiceId is filled in on a later re-walk', async () => {
241
+ const { token, mockPayment } = await init();
242
+ const settlement = mollieMocker.createSettlement({
243
+ payments: [mockPayment],
244
+ value: '49.70',
245
+ periods: {
246
+ 2026: {
247
+ '01': {
248
+ costs: [{
249
+ description: 'Bancontact betalingen',
250
+ method: 'bancontact',
251
+ amountNet: { currency: 'EUR', value: '0.30' },
252
+ amountVat: { currency: 'EUR', value: '0.00' },
253
+ }],
254
+ },
255
+ },
256
+ },
257
+ });
258
+
259
+ await runCron(token);
260
+ const row = await getSettlementRow(settlement.id);
261
+ const costs = await SettlementCharge.select().where('settlementId', row.id).fetch();
262
+ expect(costs).toHaveLength(1);
263
+ expect(costs[0].providerInvoiceId).toBeNull();
264
+
265
+ // Mollie created the invoice since the last walk
266
+ settlement.invoiceId = 'inv_later';
267
+ await runCron(token);
268
+
269
+ const updated = await SettlementCharge.getByID(costs[0].id);
270
+ expect(updated!.providerInvoiceId).toBe('inv_later');
271
+ });
272
+
273
+ test('re-running stores identical rows', async () => {
274
+ const { token, mockPayment, mockRefund } = await init();
275
+ const settlement = mollieMocker.createSettlement({ payments: [mockPayment], refunds: [mockRefund], value: '30.00' });
276
+
277
+ await runCron(token);
278
+ const row = await getSettlementRow(settlement.id);
279
+ const before = (await PaymentSettlement.select().where('settlementId', row.id).fetch()).map(l => l.id).sort();
280
+
281
+ await runCron(token);
282
+ const after = (await PaymentSettlement.select().where('settlementId', row.id).fetch()).map(l => l.id).sort();
283
+ expect(after).toEqual(before);
284
+ expect(await Settlement.select().where('externalId', settlement.id).count()).toBe(1);
285
+ });
286
+ });
287
+
288
+ test('A settlement settled before the window start is not walked', async () => {
289
+ const { token, mockPayment } = await init();
290
+
291
+ // The list is newest-first: the walk must sync the recent settlement, then stop at the old one
292
+ const oldSettlement = mollieMocker.createSettlement({ payments: [mockPayment], value: '10.00', settledAt: new Date(2019, 5, 1) });
293
+ const recentSettlement = mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00' });
294
+
295
+ await runCron(token);
296
+
297
+ expect(await Settlement.select().where('externalId', recentSettlement.id).count()).toBe(1);
298
+ expect(await Settlement.select().where('externalId', oldSettlement.id).count()).toBe(0);
299
+ });
300
+
301
+ test('A settlement settled after the window end is skipped, older ones are still walked', async () => {
302
+ const { token, mockPayment } = await init();
303
+
304
+ const afterEnd = mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00', settledAt: new Date(2026, 5, 1) });
305
+ const inWindow = mollieMocker.createSettlement({ payments: [mockPayment], value: '20.00', settledAt: new Date(2026, 1, 1) });
306
+
307
+ await runCron(token, { start: new Date(2026, 0, 1), end: new Date(2026, 2, 1) });
308
+
309
+ expect(await Settlement.select().where('externalId', inWindow.id).count()).toBe(1);
310
+ expect(await Settlement.select().where('externalId', afterEnd.id).count()).toBe(0);
311
+ });
312
+
313
+ test('The walk follows pagination until it reaches the window start', async () => {
314
+ const { token, mockPayment } = await init();
315
+ mollieMocker.settlementsPageSize = 2;
316
+
317
+ // Two pages: [first, second] and [third, beforeWindow] — the third settlement only syncs
318
+ // if the walk follows the next link, and the pre-window one proves it still stops
319
+ const first = mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00', settledAt: new Date(2026, 2, 3) });
320
+ const second = mollieMocker.createSettlement({ payments: [mockPayment], value: '20.00', settledAt: new Date(2026, 2, 2) });
321
+ const third = mollieMocker.createSettlement({ payments: [mockPayment], value: '10.00', settledAt: new Date(2026, 2, 1) });
322
+ const beforeWindow = mollieMocker.createSettlement({ payments: [mockPayment], value: '5.00', settledAt: new Date(2019, 0, 1) });
323
+
324
+ await runCron(token);
325
+
326
+ for (const settlement of [first, second, third]) {
327
+ expect(await Settlement.select().where('externalId', settlement.id).count()).toBe(1);
328
+ }
329
+ expect(await Settlement.select().where('externalId', beforeWindow.id).count()).toBe(0);
330
+ });
331
+
332
+ test('The summary counts synced settlements', async () => {
333
+ const { token, mockPayment } = await init();
334
+ mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00' });
335
+
336
+ const summary: SettlementSyncSummary = { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
337
+ await runCron(token, { summary });
338
+
339
+ expect(summary.synced).toBe(1);
340
+ expect(summary.failed).toBe(0);
341
+ });
342
+
343
+ test('An unlinked refund entry in a settlement is skipped without affecting the known refund', async () => {
344
+ const { token, refundPayment, mockPayment, mockRefund } = await init();
345
+
346
+ // A refund that belongs to a different system: it exists at Mollie but has no MolliePayment link
347
+ const unlinkedRefund: MollieMockRefund = mollieMocker.createRefund(mockPayment, { value: '5.00', status: 'refunded' });
348
+
349
+ const settlement = mollieMocker.createSettlement({
350
+ payments: [mockPayment],
351
+ refunds: [unlinkedRefund, mockRefund],
352
+ value: '100.00',
353
+ });
354
+
355
+ await runCron(token);
356
+
357
+ // The known refund still gets its settlement, the unlinked one is silently ignored
358
+ const updatedRefund = await Payment.getByID(refundPayment.id);
359
+ expect(updatedRefund!.settlement).toMatchObject({ id: settlement.id });
360
+ });
361
+ });
@@ -0,0 +1,323 @@
1
+ import type { MollieToken } from '@stamhoofd/models';
2
+ import { MolliePayment, Payment } from '@stamhoofd/models';
3
+ import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
4
+ import { PaymentProvider } from '@stamhoofd/structures';
5
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
6
+ import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
7
+ import axios from 'axios';
8
+ import { createHash } from 'crypto';
9
+
10
+ import { ReportedRows, SettlementService } from '../services/SettlementService.js';
11
+ import type { SettlementSyncSummary } from './ProviderSettlementSyncRunner.js';
12
+
13
+ type MollieSettlementCost = {
14
+ description: string;
15
+ method: string | null;
16
+ amountNet: {
17
+ currency: string;
18
+ value: string;
19
+ };
20
+ amountVat: {
21
+ currency: string;
22
+ value: string;
23
+ } | null;
24
+ };
25
+
26
+ type MollieSettlement = {
27
+ id: string;
28
+ reference: string;
29
+ createdAt: string;
30
+ settledAt: string;
31
+ status: 'open' | 'pending' | 'paidout' | 'failed';
32
+ amount: {
33
+ currency: string;
34
+ value: string;
35
+ };
36
+ /**
37
+ * "The ID of the oldest invoice created for all the periods": null until Mollie created it,
38
+ * filled in by the regular re-walk of recent settlements.
39
+ */
40
+ invoiceId?: string | null;
41
+ periods?: Record<string, Record<string, { costs?: MollieSettlementCost[]; invoiceId?: string | null }>>;
42
+ };
43
+
44
+ /**
45
+ * Everything one settlement walk needs to share between the resource pages.
46
+ */
47
+ type SettlementSyncState = {
48
+ settlementRow: Settlement;
49
+ reported: ReportedRows;
50
+ };
51
+
52
+ /**
53
+ * Same expression as the legacy blob write, so the two can never disagree (euros → 4-decimal
54
+ * platform units).
55
+ */
56
+ function mollieAmountToUnits(value: string): number {
57
+ return Math.round(parseFloat(value) * 100) * 100;
58
+ }
59
+
60
+ function getMollieSettlementStatus(status: MollieSettlement['status']): SettlementStatus {
61
+ switch (status) {
62
+ case 'paidout': return SettlementStatus.Paid;
63
+ case 'failed': return SettlementStatus.Failed;
64
+ default: return SettlementStatus.Pending;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Both payments (tr_...) and refunds (re_...) settled in a settlement are matched to a local
70
+ * payment through the mollieId of their MolliePayment link, so we only need their id here.
71
+ */
72
+ type MollieSettlementEntryJSON = {
73
+ id: string;
74
+ };
75
+
76
+ /**
77
+ * Walks the settlements of one Mollie account and stores every entry in them: payments, refunds
78
+ * and chargebacks become payment_settlements rows, Mollie's own costs become settlement_charges
79
+ * rows, and the legacy blob is written in the same pass. Every settlement row written belongs to
80
+ * the organization that owns the token's account.
81
+ */
82
+ export class MollieSettlementSync {
83
+ private token: MollieToken;
84
+
85
+ constructor({ token }: { token: MollieToken }) {
86
+ this.token = token;
87
+ }
88
+
89
+ /**
90
+ * Walk the settlements newest first, until they settle before `start`.
91
+ */
92
+ async syncSettlements({ start, end = new Date(), summary }: {
93
+ start: Date;
94
+ end?: Date;
95
+ summary?: SettlementSyncSummary;
96
+ }): Promise<void> {
97
+ let url: string | null = 'https://api.mollie.com/v2/settlements?limit=250';
98
+
99
+ while (url) {
100
+ const request = await this.#get(url);
101
+
102
+ if (request.status !== 200) {
103
+ console.error('Failed to fetch settlements');
104
+ console.error(request.data);
105
+ return;
106
+ }
107
+
108
+ const settlements = request.data._embedded?.settlements as MollieSettlement[] | undefined;
109
+ if (!settlements) {
110
+ console.error('Unreadable settlements');
111
+ return;
112
+ }
113
+
114
+ for (const settlement of settlements) {
115
+ if (settlement.settledAt === null) {
116
+ // Skip: this is the open settlement
117
+ continue;
118
+ }
119
+
120
+ const settledAt = new Date(settlement.settledAt);
121
+
122
+ if (isNaN(settledAt.getTime())) {
123
+ console.error('Received an invalid settledAt from Mollie', settlement, 'for organization', this.token.organizationId);
124
+ continue;
125
+ }
126
+
127
+ if (settledAt.getTime() > end.getTime()) {
128
+ continue;
129
+ }
130
+
131
+ if (settledAt.getTime() < start.getTime()) {
132
+ // The list is newest-first: everything from here on settled before the window
133
+ return;
134
+ }
135
+
136
+ try {
137
+ await SettlementService.lock(PaymentProvider.Mollie, settlement.id, () => this.#syncSettlement(settlement));
138
+ if (summary) {
139
+ summary.synced += 1;
140
+ }
141
+ } catch (e) {
142
+ console.error('Sync of Mollie settlement ' + settlement.id + ' failed', e);
143
+ if (summary) {
144
+ summary.failed += 1;
145
+ }
146
+ }
147
+ }
148
+
149
+ const next = request.data._links?.next?.href as string | undefined;
150
+ url = (settlements.length > 0 && next) ? next : null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The token can expire during a long walk: refresh it (if needed) before every request.
156
+ */
157
+ async #get(url: string) {
158
+ await this.token.refreshIfNeeded();
159
+ return await axios.get(url, {
160
+ headers: {
161
+ Authorization: 'Bearer ' + this.token.accessToken,
162
+ },
163
+ });
164
+ }
165
+
166
+ async #syncSettlement(settlement: MollieSettlement) {
167
+ const settlementRow = await SettlementService.upsertSettlement({
168
+ provider: PaymentProvider.Mollie,
169
+ externalId: settlement.id,
170
+ stripeAccountId: null,
171
+ organizationId: this.token.organizationId,
172
+ reference: settlement.reference,
173
+ amount: mollieAmountToUnits(settlement.amount.value),
174
+ status: getMollieSettlementStatus(settlement.status),
175
+ settledAt: new Date(settlement.settledAt),
176
+ });
177
+
178
+ const state: SettlementSyncState = {
179
+ settlementRow,
180
+ reported: new ReportedRows(),
181
+ };
182
+
183
+ try {
184
+ // Regular payments settled in this settlement
185
+ await this.#syncResource(settlement, 'payments', state);
186
+
187
+ // Refunds settled in this settlement. These are negative entries linked to a refund payment
188
+ // (created by the mollie-refunds cron), so we can set their settlement metadata too.
189
+ await this.#syncResource(settlement, 'refunds', state);
190
+
191
+ // Chargebacks settled in this settlement. Like refunds, these are negative entries linked to a
192
+ // chargeback payment (created by the mollie-chargebacks cron).
193
+ await this.#syncResource(settlement, 'chargebacks', state);
194
+
195
+ // Mollie's own costs, so the settlement reconciles to 0 like a Stripe one
196
+ await this.#storeMollieCosts(settlement, state);
197
+
198
+ await SettlementService.sweepSettlement(settlementRow, state.reported);
199
+ await SettlementService.finishSync(settlementRow, {
200
+ transactionCount: state.reported.paymentLineExternalIds.size + state.reported.chargeExternalIds.size,
201
+ });
202
+ } catch (e) {
203
+ await SettlementService.markSyncFailed(settlementRow);
204
+ throw e;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Mollie invoices its costs per period: every cost line becomes a ProviderTransactionFee row plus
210
+ * a Tax row for its VAT, carrying the settlement's invoiceId so they can be matched against the
211
+ * invoice document.
212
+ */
213
+ async #storeMollieCosts(settlement: MollieSettlement, state: SettlementSyncState) {
214
+ for (const [year, months] of Object.entries(settlement.periods ?? {})) {
215
+ for (const [month, period] of Object.entries(months)) {
216
+ // Mollie states the period explicitly: that month is the cost's date, so the monthly
217
+ // grouping stays derivable from occurredAt alone
218
+ const occurredAt = new Date(parseInt(year), parseInt(month) - 1, 1);
219
+
220
+ // A settlement can straddle a month boundary: each period can be billed on its own
221
+ // invoice, the settlement-level id is only "the oldest invoice of all the periods"
222
+ const invoiceId = period.invoiceId ?? settlement.invoiceId;
223
+
224
+ for (const cost of period.costs ?? []) {
225
+ // Mollie aggregates cost lines per description + method, so that pair identifies
226
+ // the line within the period (hashed to keep the externalId short)
227
+ const hash = createHash('sha256').update(cost.description + ':' + (cost.method ?? '')).digest('hex').slice(0, 16);
228
+ const externalId = settlement.id + ':' + year + '-' + month + ':cost:' + hash;
229
+ const description = cost.description + (cost.method ? ' (' + cost.method + ')' : '');
230
+
231
+ const rows = [
232
+ { type: SettlementChargeType.ProviderTransactionFee, externalId, amount: -mollieAmountToUnits(cost.amountNet.value) },
233
+ ...(cost.amountVat && mollieAmountToUnits(cost.amountVat.value) !== 0
234
+ ? [{ type: SettlementChargeType.Tax, externalId: externalId + ':tax', amount: -mollieAmountToUnits(cost.amountVat.value) }]
235
+ : []),
236
+ ];
237
+
238
+ for (const row of rows) {
239
+ const charge = await SettlementService.upsertCharge({
240
+ ...row,
241
+ settlementId: state.settlementRow.id,
242
+ organizationId: state.settlementRow.organizationId,
243
+ ...(invoiceId ? { providerInvoiceId: invoiceId } : {}),
244
+ description,
245
+ occurredAt,
246
+ });
247
+ state.reported.charge(charge);
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Loop over all entries (payments, refunds or chargebacks) that are part of a settlement and set the
256
+ * settlement metadata on the matching local payment. Every resource exposes its entries under
257
+ * `_embedded[resource]` and uses the same pagination, so they share this logic.
258
+ */
259
+ async #syncResource(settlement: MollieSettlement, resource: 'payments' | 'refunds' | 'chargebacks', state: SettlementSyncState, fromId?: string) {
260
+ const limit = 250;
261
+
262
+ const request = await this.#get('https://api.mollie.com/v2/settlements/' + settlement.id + '/' + resource + '?limit=' + limit + (fromId ? ('&from=' + encodeURIComponent(fromId)) : ''));
263
+
264
+ if (request.status === 200) {
265
+ const entries = request.data._embedded[resource] as MollieSettlementEntryJSON[];
266
+
267
+ for (const entry of entries) {
268
+ await this.#applySettlementToPayment(settlement, entry.id, state);
269
+ }
270
+
271
+ // Check next page
272
+ if (request.data._links.next && entries.length > 0) {
273
+ await this.#syncResource(settlement, resource, state, entries[entries.length - 1].id);
274
+ }
275
+ } else {
276
+ console.error(request.data);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Find the local payment linked to a Mollie payment or refund id and store the settlement metadata.
282
+ * Entries without a local payment belong to a different system on the same Mollie account: they are
283
+ * skipped, and the settlement's unexplainedAmount shows the gap.
284
+ */
285
+ async #applySettlementToPayment(settlement: MollieSettlement, mollieId: string, state: SettlementSyncState) {
286
+ // Search payment
287
+ const mps = await MolliePayment.where({ mollieId });
288
+ if (mps.length === 1) {
289
+ const mp = mps[0];
290
+ const payment = await Payment.getByID(mp.paymentId);
291
+ if (payment) {
292
+ // A payment is only ever settled by the payouts of its own organization. The
293
+ // platform's own Mollie account can reach payments of other systems on the same
294
+ // token, and those may not be linked here
295
+ if (payment.organizationId !== state.settlementRow.organizationId) {
296
+ console.log('Skipped payment ' + payment.id + ' of another organization in Mollie settlement ' + settlement.id);
297
+ return;
298
+ }
299
+
300
+ state.reported.paymentLine(await SettlementService.upsertPaymentLine(state.settlementRow, {
301
+ paymentId: payment.id,
302
+ amount: payment.price,
303
+ externalId: mollieId,
304
+ occurredAt: new Date(settlement.settledAt),
305
+ }));
306
+
307
+ // The blob is written from the stored rows, so it stays deterministic across
308
+ // re-syncs and keeps its scoping rules in one place
309
+ await SettlementService.updateLegacySettlementReference(payment);
310
+
311
+ if (STAMHOOFD.environment === 'development') {
312
+ console.log('Updated settlement of payment ' + payment.id);
313
+ console.log(payment.settlement);
314
+ }
315
+ } else {
316
+ console.log('Missing payment ' + mp.paymentId);
317
+ }
318
+ } else {
319
+ // Probably a payment in a different system/platform
320
+ // console.log("No mollie payment found for id "+mollieId)
321
+ }
322
+ }
323
+ }