@stamhoofd/backend 2.143.0 → 2.144.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 +17 -17
- package/src/boot.ts +1 -0
- package/src/crons/fake-settlements.test.ts +13 -11
- package/src/crons/fake-settlements.ts +22 -38
- package/src/endpoints/global/files/UploadFile.ts +53 -25
- package/src/endpoints/global/files/UploadImage.ts +32 -6
- package/src/helpers/ApplicationFeeInvoicer.ts +1 -1
- package/src/helpers/Context.ts +1 -15
- package/src/helpers/MollieSettlementSync.test.ts +94 -41
- package/src/helpers/MollieSettlementSync.ts +174 -78
- package/src/helpers/StripeSettlementSync.test.ts +0 -85
- package/src/helpers/StripeSettlementSync.ts +37 -63
- package/src/helpers/ViesHelper.ts +18 -11
- package/src/helpers/streamForBreakdown.ts +1 -1
- package/src/services/SettlementService.test.ts +13 -85
- package/src/services/SettlementService.ts +5 -113
- package/src/sql-filters/balance-items.ts +10 -0
- package/tests/helpers/MollieMocker.ts +89 -16
|
@@ -191,25 +191,16 @@ describe('Helper.MollieSettlementSync', () => {
|
|
|
191
191
|
test('legacy JSON and new rows agree, and the settlement reconciles to zero', async () => {
|
|
192
192
|
const { organization, token, payment, refundPayment, mockPayment, mockRefund } = await init();
|
|
193
193
|
|
|
194
|
-
// 50.00 - 20.00 in entries, minus 0.30
|
|
194
|
+
// 50.00 - 20.00 in entries, minus a 0.30 payment fee and a 0.06 refund fee
|
|
195
195
|
const settlement = mollieMocker.createSettlement({
|
|
196
196
|
payments: [mockPayment],
|
|
197
197
|
refunds: [mockRefund],
|
|
198
198
|
value: '29.64',
|
|
199
|
-
invoiceId: 'inv_123',
|
|
200
|
-
periods: {
|
|
201
|
-
2026: {
|
|
202
|
-
'01': {
|
|
203
|
-
costs: [{
|
|
204
|
-
description: 'Bancontact betalingen',
|
|
205
|
-
method: 'bancontact',
|
|
206
|
-
amountNet: { currency: 'EUR', value: '0.30' },
|
|
207
|
-
amountVat: { currency: 'EUR', value: '0.06' },
|
|
208
|
-
}],
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
199
|
});
|
|
200
|
+
mollieMocker.createBalanceTransaction({ type: 'payment', entryId: mockPayment.id, fee: '0.30', createdAt: new Date(2026, 0, 5) });
|
|
201
|
+
|
|
202
|
+
// A transaction without the deductionDetails breakdown: its whole deduction is the fee
|
|
203
|
+
mollieMocker.createBalanceTransaction({ type: 'refund', entryId: mockRefund.id, deductions: '0.06', createdAt: new Date(2026, 0, 6) });
|
|
213
204
|
|
|
214
205
|
await runCron(token);
|
|
215
206
|
|
|
@@ -230,61 +221,93 @@ describe('Helper.MollieSettlementSync', () => {
|
|
|
230
221
|
].sort());
|
|
231
222
|
|
|
232
223
|
const charges = await SettlementCharge.select().where('settlementId', row.id).fetch();
|
|
233
|
-
expect(charges.map(c => ({ type: c.type, amount: c.amount,
|
|
234
|
-
{ type: SettlementChargeType.ProviderTransactionFee, amount: -30_00,
|
|
235
|
-
{ type: SettlementChargeType.
|
|
224
|
+
expect(charges.map(c => ({ type: c.type, amount: c.amount, paymentId: c.paymentId, occurredAt: c.occurredAt })).sort((a, b) => a.amount - b.amount)).toEqual([
|
|
225
|
+
{ type: SettlementChargeType.ProviderTransactionFee, amount: -30_00, paymentId: payment.id, occurredAt: new Date(2026, 0, 5) },
|
|
226
|
+
{ type: SettlementChargeType.ProviderTransactionFee, amount: -6_00, paymentId: refundPayment.id, occurredAt: new Date(2026, 0, 6) },
|
|
236
227
|
]);
|
|
237
228
|
|
|
229
|
+
// Each payment holds the fee that was deducted for it
|
|
230
|
+
expect((await Payment.getByID(payment.id))!.transferFee).toBe(30_00);
|
|
231
|
+
expect((await Payment.getByID(refundPayment.id))!.transferFee).toBe(6_00);
|
|
232
|
+
|
|
238
233
|
// The legacy blob agrees with the new settlement row
|
|
239
234
|
const updatedPayment = await Payment.getByID(payment.id);
|
|
240
235
|
expect(updatedPayment!.settlement!.id).toBe(row.externalId);
|
|
241
236
|
expect(updatedPayment!.settlement!.amount).toBe(row.amount);
|
|
242
237
|
});
|
|
243
238
|
|
|
244
|
-
test('the
|
|
245
|
-
const { token, mockPayment } = await init();
|
|
239
|
+
test('the fee of a chargeback is stored on its settlement and payment', async () => {
|
|
240
|
+
const { organization, token, payment, mockPayment } = await init();
|
|
241
|
+
const { chargebackPayment, mockChargeback } = await addChargeback(organization.id, payment, mockPayment);
|
|
242
|
+
|
|
243
|
+
// 50.00 - 50.00 in entries, minus a 0.25 chargeback fee
|
|
246
244
|
const settlement = mollieMocker.createSettlement({
|
|
247
245
|
payments: [mockPayment],
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
2026: {
|
|
251
|
-
'01': {
|
|
252
|
-
costs: [{
|
|
253
|
-
description: 'Bancontact betalingen',
|
|
254
|
-
method: 'bancontact',
|
|
255
|
-
amountNet: { currency: 'EUR', value: '0.30' },
|
|
256
|
-
amountVat: { currency: 'EUR', value: '0.00' },
|
|
257
|
-
}],
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
},
|
|
246
|
+
chargebacks: [mockChargeback],
|
|
247
|
+
value: '-0.25',
|
|
261
248
|
});
|
|
249
|
+
mollieMocker.createBalanceTransaction({ type: 'chargeback', entryId: mockChargeback.id, fee: '0.25' });
|
|
262
250
|
|
|
263
251
|
await runCron(token);
|
|
252
|
+
|
|
264
253
|
const row = await getSettlementRow(settlement.id);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
254
|
+
expect(row.unexplainedAmount).toBe(0);
|
|
255
|
+
|
|
256
|
+
const charges = await SettlementCharge.select().where('paymentId', chargebackPayment.id).fetch();
|
|
257
|
+
expect(charges.map(c => ({ type: c.type, amount: c.amount, settlementId: c.settlementId }))).toEqual([
|
|
258
|
+
{ type: SettlementChargeType.ProviderTransactionFee, amount: -25_00, settlementId: row.id },
|
|
259
|
+
]);
|
|
260
|
+
expect((await Payment.getByID(chargebackPayment.id))!.transferFee).toBe(25_00);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('only the fee part of a deduction becomes a charge', async () => {
|
|
264
|
+
const { token, payment, mockPayment } = await init();
|
|
265
|
+
|
|
266
|
+
// Mollie withheld 5.30: a 0.30 fee plus a 5.00 reserve, which stays unexplained
|
|
267
|
+
const settlement = mollieMocker.createSettlement({ payments: [mockPayment], value: '44.70' });
|
|
268
|
+
mollieMocker.createBalanceTransaction({ type: 'payment', entryId: mockPayment.id, fee: '0.30', deductions: '5.30' });
|
|
269
|
+
|
|
270
|
+
await runCron(token);
|
|
271
|
+
|
|
272
|
+
const charges = await SettlementCharge.select().where('paymentId', payment.id).fetch();
|
|
273
|
+
expect(charges.map(c => c.amount)).toEqual([-30_00]);
|
|
274
|
+
expect((await Payment.getByID(payment.id))!.transferFee).toBe(30_00);
|
|
275
|
+
expect((await getSettlementRow(settlement.id)).unexplainedAmount).toBe(-5_0000);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('the fee of a settlement that is not stored yet is stored on a later walk', async () => {
|
|
279
|
+
const { token, payment, mockPayment } = await init();
|
|
280
|
+
mollieMocker.createBalanceTransaction({ type: 'payment', entryId: mockPayment.id, fee: '0.30' });
|
|
281
|
+
|
|
282
|
+
// The transaction exists before its settlement is paid out: there is nothing to
|
|
283
|
+
// attach the fee to yet
|
|
284
|
+
await runCron(token);
|
|
285
|
+
expect(await SettlementCharge.select().where('paymentId', payment.id).count()).toBe(0);
|
|
268
286
|
|
|
269
|
-
|
|
270
|
-
settlement.invoiceId = 'inv_later';
|
|
287
|
+
const settlement = mollieMocker.createSettlement({ payments: [mockPayment], value: '49.70' });
|
|
271
288
|
await runCron(token);
|
|
272
289
|
|
|
273
|
-
const
|
|
274
|
-
expect(
|
|
290
|
+
const row = await getSettlementRow(settlement.id);
|
|
291
|
+
expect(row.unexplainedAmount).toBe(0);
|
|
292
|
+
expect(await SettlementCharge.select().where('paymentId', payment.id).count()).toBe(1);
|
|
275
293
|
});
|
|
276
294
|
|
|
277
295
|
test('re-running stores identical rows', async () => {
|
|
278
296
|
const { token, mockPayment, mockRefund } = await init();
|
|
279
|
-
const settlement = mollieMocker.createSettlement({ payments: [mockPayment], refunds: [mockRefund], value: '
|
|
297
|
+
const settlement = mollieMocker.createSettlement({ payments: [mockPayment], refunds: [mockRefund], value: '29.70' });
|
|
298
|
+
mollieMocker.createBalanceTransaction({ type: 'payment', entryId: mockPayment.id, fee: '0.30' });
|
|
280
299
|
|
|
281
300
|
await runCron(token);
|
|
282
301
|
const row = await getSettlementRow(settlement.id);
|
|
283
302
|
const before = (await PaymentSettlement.select().where('settlementId', row.id).fetch()).map(l => l.id).sort();
|
|
303
|
+
const chargesBefore = (await SettlementCharge.select().where('settlementId', row.id).fetch()).map(c => c.id).sort();
|
|
284
304
|
|
|
285
305
|
await runCron(token);
|
|
286
306
|
const after = (await PaymentSettlement.select().where('settlementId', row.id).fetch()).map(l => l.id).sort();
|
|
307
|
+
const chargesAfter = (await SettlementCharge.select().where('settlementId', row.id).fetch()).map(c => c.id).sort();
|
|
287
308
|
expect(after).toEqual(before);
|
|
309
|
+
expect(chargesAfter).toEqual(chargesBefore);
|
|
310
|
+
expect(chargesBefore).toHaveLength(1);
|
|
288
311
|
expect(await Settlement.select().where('externalId', settlement.id).count()).toBe(1);
|
|
289
312
|
});
|
|
290
313
|
});
|
|
@@ -333,6 +356,34 @@ describe('Helper.MollieSettlementSync', () => {
|
|
|
333
356
|
expect(await Settlement.select().where('externalId', beforeWindow.id).count()).toBe(0);
|
|
334
357
|
});
|
|
335
358
|
|
|
359
|
+
test('The fee walk follows pagination and stops before the window', async () => {
|
|
360
|
+
const { organization, token, payment, mockPayment, mockRefund } = await init();
|
|
361
|
+
const { mockChargeback } = await addChargeback(organization.id, payment, mockPayment);
|
|
362
|
+
mollieMocker.balanceTransactionsPageSize = 2;
|
|
363
|
+
|
|
364
|
+
const settlement = mollieMocker.createSettlement({
|
|
365
|
+
payments: [mockPayment],
|
|
366
|
+
refunds: [mockRefund],
|
|
367
|
+
chargebacks: [mockChargeback],
|
|
368
|
+
value: '100.00',
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Three fee transactions across two pages, newest first
|
|
372
|
+
mollieMocker.createBalanceTransaction({ type: 'payment', entryId: mockPayment.id, fee: '0.30', createdAt: new Date(2026, 2, 3) });
|
|
373
|
+
mollieMocker.createBalanceTransaction({ type: 'refund', entryId: mockRefund.id, fee: '0.06', createdAt: new Date(2026, 2, 2) });
|
|
374
|
+
mollieMocker.createBalanceTransaction({ type: 'chargeback', entryId: mockChargeback.id, fee: '0.25', createdAt: new Date(2026, 2, 1) });
|
|
375
|
+
|
|
376
|
+
// Before the window (minus the lookback): the walk may not reach this one
|
|
377
|
+
const beforeWindow = mollieMocker.createBalanceTransaction({ type: 'payment', entryId: mockPayment.id, fee: '9.99', createdAt: new Date(2019, 0, 1) });
|
|
378
|
+
|
|
379
|
+
await runCron(token);
|
|
380
|
+
|
|
381
|
+
const row = await Settlement.select().where('externalId', settlement.id).first(true);
|
|
382
|
+
const charges = await SettlementCharge.select().where('settlementId', row.id).fetch();
|
|
383
|
+
expect(charges.map(c => c.amount).sort((a, b) => a - b)).toEqual([-30_00, -25_00, -6_00]);
|
|
384
|
+
expect(charges.map(c => c.externalId)).not.toContain(beforeWindow.id);
|
|
385
|
+
});
|
|
386
|
+
|
|
336
387
|
test('The summary counts synced settlements', async () => {
|
|
337
388
|
const { token, mockPayment } = await init();
|
|
338
389
|
mollieMocker.createSettlement({ payments: [mockPayment], value: '50.00' });
|
|
@@ -394,11 +445,13 @@ describe('Helper.MollieSettlementSync', () => {
|
|
|
394
445
|
refunds: [unlinkedRefund, mockRefund],
|
|
395
446
|
value: '100.00',
|
|
396
447
|
});
|
|
448
|
+
const unlinkedFee = mollieMocker.createBalanceTransaction({ type: 'refund', entryId: unlinkedRefund.id, fee: '0.06' });
|
|
397
449
|
|
|
398
450
|
await runCron(token);
|
|
399
451
|
|
|
400
|
-
// The known refund still gets its settlement, the unlinked one
|
|
452
|
+
// The known refund still gets its settlement, the unlinked one and its fee are silently ignored
|
|
401
453
|
const updatedRefund = await Payment.getByID(refundPayment.id);
|
|
402
454
|
expect(updatedRefund!.settlement).toMatchObject({ id: settlement.id });
|
|
455
|
+
expect(await SettlementCharge.select().where('externalId', unlinkedFee.id).count()).toBe(0);
|
|
403
456
|
});
|
|
404
457
|
});
|
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
import type { MollieToken } from '@stamhoofd/models';
|
|
2
2
|
import { MolliePayment, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
3
4
|
import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
4
5
|
import type { AbortSignal } from '@stamhoofd/queues';
|
|
5
6
|
import { PaymentProvider } from '@stamhoofd/structures';
|
|
6
7
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
7
8
|
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
8
9
|
import axios from 'axios';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
12
12
|
import type { SettlementSyncSummary } from './ProviderSettlementSyncRunner.js';
|
|
13
13
|
|
|
14
|
-
type MollieSettlementCost = {
|
|
15
|
-
description: string;
|
|
16
|
-
method: string | null;
|
|
17
|
-
amountNet: {
|
|
18
|
-
currency: string;
|
|
19
|
-
value: string;
|
|
20
|
-
};
|
|
21
|
-
amountVat: {
|
|
22
|
-
currency: string;
|
|
23
|
-
value: string;
|
|
24
|
-
} | null;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
14
|
type MollieSettlement = {
|
|
28
15
|
id: string;
|
|
29
16
|
reference: string;
|
|
@@ -34,20 +21,47 @@ type MollieSettlement = {
|
|
|
34
21
|
currency: string;
|
|
35
22
|
value: string;
|
|
36
23
|
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type MollieAmount = {
|
|
27
|
+
currency: string;
|
|
28
|
+
value: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type MollieBalanceTransaction = {
|
|
32
|
+
id: string;
|
|
33
|
+
type: string;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
|
|
37
36
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Everything Mollie withheld from the movement (negative): fees, but also reserves, partner
|
|
38
|
+
* commissions and loan repayments. `deductionDetails` separates those.
|
|
40
39
|
*/
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
deductions?: MollieAmount | null;
|
|
41
|
+
deductionDetails?: {
|
|
42
|
+
fees?: MollieAmount | null;
|
|
43
|
+
} | null;
|
|
44
|
+
context?: {
|
|
45
|
+
paymentId?: string;
|
|
46
|
+
refundId?: string;
|
|
47
|
+
chargebackId?: string;
|
|
48
|
+
} | null;
|
|
43
49
|
};
|
|
44
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A transaction occurs before the settlement that pays it out: up to a month (Mollie settles
|
|
53
|
+
* daily, weekly or monthly) plus a few days of payout delay. The fee walk looks this much further
|
|
54
|
+
* back than the settlement window.
|
|
55
|
+
*/
|
|
56
|
+
const TRANSACTION_FEE_LOOKBACK_MS = 45 * 24 * 60 * 60 * 1000;
|
|
57
|
+
|
|
45
58
|
/**
|
|
46
59
|
* Everything one settlement walk needs to share between the resource pages.
|
|
47
60
|
*/
|
|
48
61
|
type SettlementSyncState = {
|
|
49
62
|
settlementRow: Settlement;
|
|
50
|
-
|
|
63
|
+
|
|
64
|
+
transactionCount: 0;
|
|
51
65
|
|
|
52
66
|
/**
|
|
53
67
|
* Stops the walk at the next entry (a restart).
|
|
@@ -80,10 +94,10 @@ type MollieSettlementEntryJSON = {
|
|
|
80
94
|
};
|
|
81
95
|
|
|
82
96
|
/**
|
|
83
|
-
* Walks the settlements of one Mollie account
|
|
84
|
-
* and
|
|
85
|
-
*
|
|
86
|
-
* the organization that owns the token's account.
|
|
97
|
+
* Walks the settlements of one Mollie account: payments, refunds and chargebacks become
|
|
98
|
+
* payment_settlements rows, and the legacy blob is written in the same pass. A second walk over
|
|
99
|
+
* the balance transactions stores the fee Mollie deducted per transaction as a settlement_charges
|
|
100
|
+
* row. Every row written belongs to the organization that owns the token's account.
|
|
87
101
|
*/
|
|
88
102
|
export class MollieSettlementSync {
|
|
89
103
|
private token: MollieToken;
|
|
@@ -92,12 +106,23 @@ export class MollieSettlementSync {
|
|
|
92
106
|
this.token = token;
|
|
93
107
|
}
|
|
94
108
|
|
|
109
|
+
async syncSettlements({ start, end = new Date(), summary, abort }: {
|
|
110
|
+
start: Date;
|
|
111
|
+
end?: Date;
|
|
112
|
+
summary?: SettlementSyncSummary;
|
|
113
|
+
abort?: AbortSignal;
|
|
114
|
+
}): Promise<void> {
|
|
115
|
+
// Fees attach to the settlement lines the first walk stores
|
|
116
|
+
await this.#walkSettlements({ start, end, summary, abort });
|
|
117
|
+
await this.#syncTransactionFees({ start, end, abort });
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
/**
|
|
96
121
|
* Walk the settlements newest first, until they settle before `start`.
|
|
97
122
|
*/
|
|
98
|
-
async
|
|
123
|
+
async #walkSettlements({ start, end, summary, abort }: {
|
|
99
124
|
start: Date;
|
|
100
|
-
end
|
|
125
|
+
end: Date;
|
|
101
126
|
summary?: SettlementSyncSummary;
|
|
102
127
|
abort?: AbortSignal;
|
|
103
128
|
}): Promise<void> {
|
|
@@ -191,7 +216,7 @@ export class MollieSettlementSync {
|
|
|
191
216
|
|
|
192
217
|
const state: SettlementSyncState = {
|
|
193
218
|
settlementRow,
|
|
194
|
-
|
|
219
|
+
transactionCount: 0,
|
|
195
220
|
abort,
|
|
196
221
|
};
|
|
197
222
|
|
|
@@ -207,12 +232,8 @@ export class MollieSettlementSync {
|
|
|
207
232
|
// chargeback payment (created by the mollie-chargebacks cron).
|
|
208
233
|
await this.#syncResource(settlement, 'chargebacks', state);
|
|
209
234
|
|
|
210
|
-
// Mollie's own costs, so the settlement reconciles to 0 like a Stripe one
|
|
211
|
-
await this.#storeMollieCosts(settlement, state);
|
|
212
|
-
|
|
213
|
-
await SettlementService.sweepSettlement(settlementRow, state.reported);
|
|
214
235
|
await SettlementService.finishSync(settlementRow, {
|
|
215
|
-
transactionCount: state.
|
|
236
|
+
transactionCount: state.transactionCount,
|
|
216
237
|
});
|
|
217
238
|
} catch (e) {
|
|
218
239
|
// A walk that was interrupted stored only part of the settlement: it has to be walked
|
|
@@ -227,48 +248,127 @@ export class MollieSettlementSync {
|
|
|
227
248
|
}
|
|
228
249
|
|
|
229
250
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* invoice document.
|
|
251
|
+
* Stores the fee of every settled payment, refund and chargeback, then recounts the
|
|
252
|
+
* settlements that gained charges: their finishSync ran before the fees existed.
|
|
233
253
|
*/
|
|
234
|
-
async #
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
254
|
+
async #syncTransactionFees({ start, end, abort }: { start: Date; end: Date; abort?: AbortSignal }): Promise<void> {
|
|
255
|
+
const touchedSettlementIds = new Set<string>();
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await this.#walkTransactionFees({ start, end, abort }, touchedSettlementIds);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
// Recount what was stored before the walk broke, without hiding what broke it
|
|
261
|
+
await SettlementService.refreshTotalsForIds([...touchedSettlementIds]).catch(console.error);
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await SettlementService.refreshTotalsForIds([...touchedSettlementIds]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Walk the balance transactions newest first, until they occur before the window minus the
|
|
270
|
+
* lookback.
|
|
271
|
+
*/
|
|
272
|
+
async #walkTransactionFees({ start, end, abort }: { start: Date; end: Date; abort?: AbortSignal }, touchedSettlementIds: Set<string>): Promise<void> {
|
|
273
|
+
const oldest = new Date(start.getTime() - TRANSACTION_FEE_LOOKBACK_MS);
|
|
274
|
+
let url: string | null = 'https://api.mollie.com/v2/balances/primary/transactions?limit=250';
|
|
275
|
+
|
|
276
|
+
while (url) {
|
|
277
|
+
abort?.throwIfAborted();
|
|
278
|
+
|
|
279
|
+
const request = await this.#get(url);
|
|
280
|
+
|
|
281
|
+
if (request.status !== 200) {
|
|
282
|
+
console.error('Failed to fetch balance transactions for organization', this.token.organizationId);
|
|
283
|
+
console.error(request.data);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const transactions = request.data._embedded?.balance_transactions as MollieBalanceTransaction[] | undefined;
|
|
288
|
+
if (!transactions) {
|
|
289
|
+
console.error('Unreadable balance transactions');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const transaction of transactions) {
|
|
294
|
+
abort?.throwIfAborted();
|
|
295
|
+
|
|
296
|
+
const createdAt = new Date(transaction.createdAt);
|
|
297
|
+
|
|
298
|
+
if (isNaN(createdAt.getTime())) {
|
|
299
|
+
console.error('Received an invalid balance transaction createdAt from Mollie', transaction, 'for organization', this.token.organizationId);
|
|
300
|
+
continue;
|
|
270
301
|
}
|
|
302
|
+
|
|
303
|
+
if (createdAt.getTime() > end.getTime()) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (createdAt.getTime() < oldest.getTime()) {
|
|
308
|
+
// The list is newest-first: everything from here on occurred before the window
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await this.#storeTransactionFee(transaction, createdAt, touchedSettlementIds);
|
|
271
313
|
}
|
|
314
|
+
|
|
315
|
+
const next = request.data._links?.next?.href as string | undefined;
|
|
316
|
+
url = (transactions.length > 0 && next) ? next : null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The settled entry a balance transaction belongs to. Every other transaction type (transfers,
|
|
322
|
+
* reserves, corrections, ...) is ignored for now.
|
|
323
|
+
*/
|
|
324
|
+
#getTransactionEntryId(transaction: MollieBalanceTransaction): string | null {
|
|
325
|
+
switch (transaction.type) {
|
|
326
|
+
case 'payment': return transaction.context?.paymentId ?? null;
|
|
327
|
+
case 'refund': return transaction.context?.refundId ?? null;
|
|
328
|
+
case 'chargeback': return transaction.context?.chargebackId ?? null;
|
|
329
|
+
default: return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Store the fee Mollie deducted for one settled entry: a charge on the settlement the entry
|
|
335
|
+
* was paid out in, linked to the local payment.
|
|
336
|
+
*/
|
|
337
|
+
async #storeTransactionFee(transaction: MollieBalanceTransaction, createdAt: Date, touchedSettlementIds: Set<string>) {
|
|
338
|
+
const entryId = this.#getTransactionEntryId(transaction);
|
|
339
|
+
if (!entryId) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Reserves, commissions and repayments are not costs of this entry; a transaction without
|
|
344
|
+
// the breakdown carries its whole deduction as fees
|
|
345
|
+
const amount = mollieAmountToUnits(transaction.deductionDetails?.fees?.value ?? transaction.deductions?.value ?? '0');
|
|
346
|
+
if (amount === 0) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// No line: the entry belongs to a different system on the same account, or its settlement
|
|
351
|
+
// isn't stored yet and a later walk revisits this transaction
|
|
352
|
+
const line = await PaymentSettlement.select().where('externalId', entryId).first(false);
|
|
353
|
+
if (!line) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
await SettlementService.upsertCharge({
|
|
358
|
+
type: SettlementChargeType.ProviderTransactionFee,
|
|
359
|
+
externalId: transaction.id,
|
|
360
|
+
amount,
|
|
361
|
+
settlementId: line.settlementId,
|
|
362
|
+
paymentId: line.paymentId,
|
|
363
|
+
organizationId: line.organizationId,
|
|
364
|
+
occurredAt: createdAt,
|
|
365
|
+
});
|
|
366
|
+
touchedSettlementIds.add(line.settlementId);
|
|
367
|
+
|
|
368
|
+
const payment = await Payment.getByID(line.paymentId);
|
|
369
|
+
if (payment) {
|
|
370
|
+
payment.transferFee = -amount;
|
|
371
|
+
await payment.save();
|
|
272
372
|
}
|
|
273
373
|
}
|
|
274
374
|
|
|
@@ -309,6 +409,7 @@ export class MollieSettlementSync {
|
|
|
309
409
|
*/
|
|
310
410
|
async #applySettlementToPayment(settlement: MollieSettlement, mollieId: string, state: SettlementSyncState) {
|
|
311
411
|
// Search payment
|
|
412
|
+
state.transactionCount += 1;
|
|
312
413
|
const mps = await MolliePayment.where({ mollieId });
|
|
313
414
|
if (mps.length === 1) {
|
|
314
415
|
const mp = mps[0];
|
|
@@ -322,21 +423,16 @@ export class MollieSettlementSync {
|
|
|
322
423
|
return;
|
|
323
424
|
}
|
|
324
425
|
|
|
325
|
-
|
|
426
|
+
await SettlementService.upsertPaymentLine(state.settlementRow, {
|
|
326
427
|
paymentId: payment.id,
|
|
327
428
|
amount: payment.price,
|
|
328
429
|
externalId: mollieId,
|
|
329
430
|
occurredAt: new Date(settlement.settledAt),
|
|
330
|
-
})
|
|
431
|
+
});
|
|
331
432
|
|
|
332
433
|
// The blob is written from the stored rows, so it stays deterministic across
|
|
333
434
|
// re-syncs and keeps its scoping rules in one place
|
|
334
435
|
await SettlementService.updateLegacySettlementReference(payment);
|
|
335
|
-
|
|
336
|
-
if (STAMHOOFD.environment === 'development') {
|
|
337
|
-
console.log('Updated settlement of payment ' + payment.id);
|
|
338
|
-
console.log(payment.settlement);
|
|
339
|
-
}
|
|
340
436
|
} else {
|
|
341
437
|
console.log('Missing payment ' + mp.paymentId);
|
|
342
438
|
}
|
|
@@ -662,91 +662,6 @@ describe('StripeSettlementSync', () => {
|
|
|
662
662
|
expect(after).toEqual(rowIds);
|
|
663
663
|
});
|
|
664
664
|
|
|
665
|
-
test('a payment that moved to another payout is swept', async () => {
|
|
666
|
-
const payment = await createPayment();
|
|
667
|
-
const otherPayment = await createPayment({ price: 50_00_00 });
|
|
668
|
-
const payout = stripeMocker.createPayout({ amount: 15000, arrivalDate, stripeAccount: stripeAccount.accountId });
|
|
669
|
-
|
|
670
|
-
stripeMocker.createBalanceTransaction({
|
|
671
|
-
type: 'payment',
|
|
672
|
-
amount: 10000,
|
|
673
|
-
created,
|
|
674
|
-
payout: payout.id,
|
|
675
|
-
stripeAccount: stripeAccount.accountId,
|
|
676
|
-
source: stripeMocker.createChargeObject({ metadata: { payment: payment.id } }),
|
|
677
|
-
});
|
|
678
|
-
const moved = stripeMocker.createBalanceTransaction({
|
|
679
|
-
type: 'payment',
|
|
680
|
-
amount: 5000,
|
|
681
|
-
created,
|
|
682
|
-
payout: payout.id,
|
|
683
|
-
stripeAccount: stripeAccount.accountId,
|
|
684
|
-
source: stripeMocker.createChargeObject({ metadata: { payment: otherPayment.id } }),
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
const sync = createConnectedSync();
|
|
688
|
-
expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
689
|
-
|
|
690
|
-
const settlement = await getSettlement(payout);
|
|
691
|
-
expect(await PaymentSettlement.select().where('settlementId', settlement.id).count()).toBe(2);
|
|
692
|
-
|
|
693
|
-
// Stripe moved the second payment to a later payout
|
|
694
|
-
moved.payout = stripeMocker.createId('po');
|
|
695
|
-
payout.amount = 10000;
|
|
696
|
-
|
|
697
|
-
expect(await sync.syncPayouts({ start, force: true })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
698
|
-
|
|
699
|
-
const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
|
|
700
|
-
expect(lines).toHaveLength(1);
|
|
701
|
-
expect(lines[0].paymentId).toBe(payment.id);
|
|
702
|
-
expect((await getSettlement(payout)).unexplainedAmount).toBe(0);
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
test('an application fee that moved to another payout is only unlinked', async () => {
|
|
706
|
-
const payment = await createPayment();
|
|
707
|
-
const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
|
|
708
|
-
const movedFee = stripeMocker.createBalanceTransaction({
|
|
709
|
-
type: 'application_fee',
|
|
710
|
-
amount: 250,
|
|
711
|
-
created,
|
|
712
|
-
payout: payout.id,
|
|
713
|
-
source: stripeMocker.createApplicationFee({
|
|
714
|
-
amount: 250,
|
|
715
|
-
account: stripeAccount.accountId,
|
|
716
|
-
originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payment.id, serviceFee: '30' } }),
|
|
717
|
-
}),
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
const sync = createSync();
|
|
721
|
-
expect(await sync.syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
722
|
-
|
|
723
|
-
const settlement = await getSettlement(payout);
|
|
724
|
-
const applicationFeeId = ((movedFee.source as StripeObject)).id;
|
|
725
|
-
expect(await ApplicationFee.select().where('settlementId', settlement.id).count()).toBe(2);
|
|
726
|
-
|
|
727
|
-
movedFee.payout = stripeMocker.createId('po');
|
|
728
|
-
payout.amount = 0;
|
|
729
|
-
|
|
730
|
-
expect(await sync.syncPayouts({ start, force: true })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
731
|
-
|
|
732
|
-
// The fee rows outlive the payout: they are only unlinked, and so are the deduction
|
|
733
|
-
// charges they point at (a foreign key forbids deleting those)
|
|
734
|
-
const fees = await ApplicationFee.select().where('externalId', applicationFeeId).fetch();
|
|
735
|
-
expect(fees).toHaveLength(2);
|
|
736
|
-
for (const fee of fees) {
|
|
737
|
-
expect(fee.settlementId).toBeNull();
|
|
738
|
-
}
|
|
739
|
-
const deductions = await SettlementCharge.select().where('applicationFeeId', applicationFeeId).fetch();
|
|
740
|
-
expect(deductions).toHaveLength(2);
|
|
741
|
-
for (const row of deductions) {
|
|
742
|
-
expect(row.settlementId).toBeNull();
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
const after = await getSettlement(payout);
|
|
746
|
-
expect(after.unexplainedAmount).toBe(0);
|
|
747
|
-
expect(after.pendingFees).toBe(0);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
665
|
describe('Payers we can no longer reach', () => {
|
|
751
666
|
/**
|
|
752
667
|
* A platform payout that only receives one application fee, plus the payout transaction.
|