@stamhoofd/backend 2.143.1 → 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.
@@ -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 costs + 0.06 VAT
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, providerInvoiceId: c.providerInvoiceId, occurredAt: c.occurredAt })).sort((a, b) => a.amount - b.amount)).toEqual([
234
- { type: SettlementChargeType.ProviderTransactionFee, amount: -30_00, providerInvoiceId: 'inv_123', occurredAt: new Date(2026, 0, 1) },
235
- { type: SettlementChargeType.Tax, amount: -6_00, providerInvoiceId: 'inv_123', occurredAt: new Date(2026, 0, 1) },
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 invoiceId is filled in on a later re-walk', async () => {
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
- value: '49.70',
249
- periods: {
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
- const costs = await SettlementCharge.select().where('settlementId', row.id).fetch();
266
- expect(costs).toHaveLength(1);
267
- expect(costs[0].providerInvoiceId).toBeNull();
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
- // Mollie created the invoice since the last walk
270
- settlement.invoiceId = 'inv_later';
287
+ const settlement = mollieMocker.createSettlement({ payments: [mockPayment], value: '49.70' });
271
288
  await runCron(token);
272
289
 
273
- const updated = await SettlementCharge.getByID(costs[0].id);
274
- expect(updated!.providerInvoiceId).toBe('inv_later');
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: '30.00' });
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 is silently ignored
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 { ReportedRows, SettlementService } from '../services/SettlementService.js';
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
- * "The ID of the oldest invoice created for all the periods": null until Mollie created it,
39
- * filled in by the regular re-walk of recent settlements.
37
+ * Everything Mollie withheld from the movement (negative): fees, but also reserves, partner
38
+ * commissions and loan repayments. `deductionDetails` separates those.
40
39
  */
41
- invoiceId?: string | null;
42
- periods?: Record<string, Record<string, { costs?: MollieSettlementCost[]; invoiceId?: string | null }>>;
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
- reported: ReportedRows;
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 and stores every entry in them: payments, refunds
84
- * and chargebacks become payment_settlements rows, Mollie's own costs become settlement_charges
85
- * rows, and the legacy blob is written in the same pass. Every settlement row written belongs to
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 syncSettlements({ start, end = new Date(), summary, abort }: {
123
+ async #walkSettlements({ start, end, summary, abort }: {
99
124
  start: Date;
100
- end?: Date;
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
- reported: new ReportedRows(),
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.reported.paymentLineExternalIds.size + state.reported.chargeExternalIds.size,
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
- * Mollie invoices its costs per period: every cost line becomes a ProviderTransactionFee row plus
231
- * a Tax row for its VAT, carrying the settlement's invoiceId so they can be matched against the
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 #storeMollieCosts(settlement: MollieSettlement, state: SettlementSyncState) {
235
- for (const [year, months] of Object.entries(settlement.periods ?? {})) {
236
- for (const [month, period] of Object.entries(months)) {
237
- // Mollie states the period explicitly: that month is the cost's date, so the monthly
238
- // grouping stays derivable from occurredAt alone
239
- const occurredAt = new Date(parseInt(year), parseInt(month) - 1, 1);
240
-
241
- // A settlement can straddle a month boundary: each period can be billed on its own
242
- // invoice, the settlement-level id is only "the oldest invoice of all the periods"
243
- const invoiceId = period.invoiceId ?? settlement.invoiceId;
244
-
245
- for (const cost of period.costs ?? []) {
246
- // Mollie aggregates cost lines per description + method, so that pair identifies
247
- // the line within the period (hashed to keep the externalId short)
248
- const hash = createHash('sha256').update(cost.description + ':' + (cost.method ?? '')).digest('hex').slice(0, 16);
249
- const externalId = settlement.id + ':' + year + '-' + month + ':cost:' + hash;
250
- const description = cost.description + (cost.method ? ' (' + cost.method + ')' : '');
251
-
252
- const rows = [
253
- { type: SettlementChargeType.ProviderTransactionFee, externalId, amount: -mollieAmountToUnits(cost.amountNet.value) },
254
- ...(cost.amountVat && mollieAmountToUnits(cost.amountVat.value) !== 0
255
- ? [{ type: SettlementChargeType.Tax, externalId: externalId + ':tax', amount: -mollieAmountToUnits(cost.amountVat.value) }]
256
- : []),
257
- ];
258
-
259
- for (const row of rows) {
260
- const charge = await SettlementService.upsertCharge({
261
- ...row,
262
- settlementId: state.settlementRow.id,
263
- organizationId: state.settlementRow.organizationId,
264
- ...(invoiceId ? { providerInvoiceId: invoiceId } : {}),
265
- description,
266
- occurredAt,
267
- });
268
- state.reported.charge(charge);
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
- state.reported.paymentLine(await SettlementService.upsertPaymentLine(state.settlementRow, {
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.