@stamhoofd/backend 2.140.0 → 2.141.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 (61) 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/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  11. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  12. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  15. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  16. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  17. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  20. package/src/excel-loaders/payments.ts +18 -1
  21. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  22. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  23. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  24. package/src/helpers/AuthenticatedStructures.ts +14 -1
  25. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  26. package/src/helpers/MollieSettlementSync.ts +323 -0
  27. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  28. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  29. package/src/helpers/SettlementExporter.test.ts +366 -0
  30. package/src/helpers/SettlementExporter.ts +583 -0
  31. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  32. package/src/helpers/SettlementSyncRunner.ts +64 -0
  33. package/src/helpers/StripeHelper.ts +2 -1
  34. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  35. package/src/helpers/StripeSettlementSync.ts +947 -0
  36. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  37. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  38. package/src/helpers/WebmasterReport.test.ts +109 -0
  39. package/src/helpers/WebmasterReport.ts +115 -0
  40. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  41. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  42. package/src/services/ApplicationFeeService.test.ts +256 -0
  43. package/src/services/ApplicationFeeService.ts +283 -0
  44. package/src/services/InvoiceService.ts +18 -1
  45. package/src/services/SettlementService.test.ts +559 -0
  46. package/src/services/SettlementService.ts +607 -0
  47. package/src/sql-filters/payment-settlement.test.ts +2 -2
  48. package/src/sql-filters/payments.ts +73 -0
  49. package/tests/helpers/MollieMocker.ts +64 -6
  50. package/tests/helpers/StripeMocker.ts +209 -17
  51. package/src/crons/stripe-payout-reports.ts +0 -69
  52. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  53. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  54. package/src/helpers/CheckSettlements.test.ts +0 -190
  55. package/src/helpers/CheckSettlements.ts +0 -237
  56. package/src/helpers/StripeInvoicer.ts +0 -419
  57. package/src/helpers/StripePayoutChecker.ts +0 -193
  58. package/src/helpers/StripePayoutExportData.ts +0 -195
  59. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  60. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  61. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,607 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import type { Payment } from '@stamhoofd/models';
3
+ import { BalanceItemPayment, Order, Platform } from '@stamhoofd/models';
4
+ import { Payment as PaymentModel } from '@stamhoofd/models';
5
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
6
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
7
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
8
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
9
+ import { QueueHandler } from '@stamhoofd/queues';
10
+ import { SQL } from '@stamhoofd/sql';
11
+ import type { PaymentProvider } from '@stamhoofd/structures';
12
+ import { PaymentMethod, SettlementReference } from '@stamhoofd/structures';
13
+ import type { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
14
+ import type { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
15
+ import { Formatter } from '@stamhoofd/utility';
16
+
17
+ export type SettlementData = {
18
+ provider: PaymentProvider;
19
+ externalId: string;
20
+ stripeAccountId?: string | null;
21
+ organizationId: string;
22
+ reference?: string;
23
+ amount: number;
24
+ currency?: string;
25
+ status?: SettlementStatus;
26
+ settledAt: Date;
27
+ };
28
+
29
+ export type PaymentLineData = {
30
+ paymentId: string;
31
+ amount: number;
32
+
33
+ /**
34
+ * NULL for a line derived from application fees: it has no provider transaction behind it.
35
+ */
36
+ externalId: string | null;
37
+ occurredAt: Date;
38
+ };
39
+
40
+ export type ChargeData = {
41
+ type: SettlementChargeType;
42
+ externalId: string;
43
+ amount: number;
44
+
45
+ /**
46
+ * The charged organization: always the one of the payout it is deducted from, and of the
47
+ * payment it relates to.
48
+ */
49
+ organizationId: string;
50
+
51
+ settlementId?: string | null;
52
+ applicationFeeId?: string | null;
53
+ paymentId?: string | null;
54
+ stripeAccountId?: string | null;
55
+ providerInvoiceId?: string | null;
56
+ description?: string;
57
+ occurredAt: Date;
58
+ };
59
+
60
+ /**
61
+ * Rows updated per statement when a whole month of charges is stamped at once.
62
+ */
63
+ const CHARGE_UPDATE_BATCH_SIZE = 500;
64
+
65
+ /**
66
+ * Collects which rows the provider still reports in a settlement while a sync walks it. A stored
67
+ * row of the settlement that is not in here after the walk has moved or disappeared at the
68
+ * provider, and gets swept.
69
+ */
70
+ export class ReportedRows {
71
+ readonly paymentLineExternalIds = new Set<string>();
72
+ readonly chargeExternalIds = new Set<string>();
73
+ readonly applicationFeeIds = new Set<string>();
74
+
75
+ paymentLine(line: PaymentSettlement) {
76
+ if (line.externalId === null) {
77
+ return;
78
+ }
79
+ this.paymentLineExternalIds.add(line.externalId);
80
+ }
81
+
82
+ charge(charge: SettlementCharge) {
83
+ this.chargeExternalIds.add(charge.externalId);
84
+ }
85
+
86
+ charges(charges: SettlementCharge[]) {
87
+ for (const charge of charges) {
88
+ this.charge(charge);
89
+ }
90
+ }
91
+
92
+ applicationFee(fee: ApplicationFee) {
93
+ this.applicationFeeIds.add(fee.id);
94
+ }
95
+
96
+ applicationFees(fees: ApplicationFee[]) {
97
+ for (const fee of fees) {
98
+ this.applicationFee(fee);
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * All writes to the settlements tables go through this service. Every write is an upsert on the
105
+ * deterministic unique key of its table, so re-running a sync can never duplicate rows.
106
+ */
107
+ export class SettlementService {
108
+ /**
109
+ * Serializes syncs of the same settlement, so cron and manual backfill can't race. In-process
110
+ * only: across multiple API instances the tables' unique keys are the real guard, so a
111
+ * concurrent sync surfaces as a duplicate-key error and is retryable.
112
+ */
113
+ static lock<T>(provider: PaymentProvider, externalId: string, handler: () => Promise<T>): Promise<T> {
114
+ return QueueHandler.schedule('settlement-sync-' + provider + '-' + externalId, handler);
115
+ }
116
+
117
+ /**
118
+ * Month bucket of a charge, in server-local time: the same boundaries as
119
+ * getMonthUnixStartEnd, which drive the monthly invoice grouping.
120
+ */
121
+ static getPeriodStart(date: Date): Date {
122
+ return new Date(date.getFullYear(), date.getMonth(), 1);
123
+ }
124
+
125
+ /**
126
+ * The same month bucket as getPeriodStart, as a 'YYYY-MM' key.
127
+ */
128
+ static getPeriodKey(date: Date): string {
129
+ return date.getFullYear() + '-' + (date.getMonth() + 1).toString().padStart(2, '0');
130
+ }
131
+
132
+ /**
133
+ * The same month bucket as getPeriodStart, as unix second bounds (inclusive end, one second
134
+ * before the next month).
135
+ */
136
+ static getMonthUnixStartEnd(date: Date) {
137
+ const start = Math.floor((new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0).getTime()) / 1000);
138
+ const end = Math.ceil((new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime() - 1000) / 1000);
139
+ return { start, end };
140
+ }
141
+
142
+ /**
143
+ * Every settlement belongs to an organization. The platform's own provider accounts (the
144
+ * platform Stripe account, the platform Mollie token) belong to the platform membership
145
+ * organization: without one configured, platform payouts cannot be stored.
146
+ */
147
+ static async getPlatformOrganizationId(): Promise<string> {
148
+ const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
149
+ if (!membershipOrganizationId) {
150
+ throw new SimpleError({
151
+ code: 'missing_membership_organization',
152
+ message: 'Platform has no membership organization configured, so platform payouts cannot be attributed to an organization',
153
+ });
154
+ }
155
+ return membershipOrganizationId;
156
+ }
157
+
158
+ /**
159
+ * Upsert on (provider, externalId). Never touches syncedAt/syncFailureCount: those belong to
160
+ * finishSync/markSyncFailed.
161
+ */
162
+ static async upsertSettlement(data: SettlementData): Promise<Settlement> {
163
+ const settlement = await Settlement.select()
164
+ .where('provider', data.provider)
165
+ .where('externalId', data.externalId)
166
+ .first(false) ?? new Settlement();
167
+
168
+ settlement.provider = data.provider;
169
+ settlement.externalId = data.externalId;
170
+ settlement.organizationId = data.organizationId;
171
+ settlement.amount = data.amount;
172
+ settlement.settledAt = data.settledAt;
173
+
174
+ if (data.stripeAccountId !== undefined) {
175
+ settlement.stripeAccountId = data.stripeAccountId;
176
+ }
177
+ if (data.reference !== undefined) {
178
+ settlement.reference = data.reference;
179
+ }
180
+ if (data.currency !== undefined) {
181
+ settlement.currency = data.currency;
182
+ }
183
+ if (data.status !== undefined) {
184
+ settlement.status = data.status;
185
+ }
186
+
187
+ await settlement.save();
188
+ return settlement;
189
+ }
190
+
191
+ /**
192
+ * Upsert on (settlementId, externalId): one payment can be part of multiple settlements, but a
193
+ * provider transaction appears in a settlement only once. A derived fee line has no provider
194
+ * transaction, so it upserts on (settlementId, paymentId): one per fee payment and payout.
195
+ */
196
+ static async upsertPaymentLine(settlement: Settlement, data: PaymentLineData): Promise<PaymentSettlement> {
197
+ const query = PaymentSettlement.select()
198
+ .where('settlementId', settlement.id)
199
+ .where('externalId', data.externalId);
200
+
201
+ if (data.externalId === null) {
202
+ query.where('paymentId', data.paymentId);
203
+ }
204
+
205
+ const line = await query.first(false) ?? new PaymentSettlement();
206
+
207
+ line.settlementId = settlement.id;
208
+ line.organizationId = settlement.organizationId;
209
+ line.externalId = data.externalId;
210
+ line.paymentId = data.paymentId;
211
+ line.amount = data.amount;
212
+ line.occurredAt = data.occurredAt;
213
+
214
+ await line.save();
215
+
216
+ // A provider transaction sits in exactly one payout: when it moved, the row it left behind
217
+ // would count the same money twice until that payout happens to be walked again. Derived
218
+ // fee lines are the exception, they legitimately exist per payout
219
+ if (data.externalId !== null) {
220
+ const moved = await PaymentSettlement.select()
221
+ .where('externalId', data.externalId)
222
+ .where('settlementId', '!=', settlement.id)
223
+ .fetch();
224
+
225
+ for (const stale of moved) {
226
+ await stale.delete();
227
+ }
228
+ await this.refreshTotalsForIds(Formatter.uniqueArray(moved.map(l => l.settlementId)));
229
+ }
230
+
231
+ return line;
232
+ }
233
+
234
+ /**
235
+ * Upsert on the globally unique externalId. Fields that are undefined keep their stored value,
236
+ * so the fee sync (which doesn't know the settlement yet) can't unlink a charge the payout sync
237
+ * attached earlier. balanceItemId is deliberately not settable here: only markInvoiced writes
238
+ * it, when the fee is invoiced.
239
+ */
240
+ static async upsertCharge(data: ChargeData): Promise<SettlementCharge> {
241
+ const charge = await SettlementCharge.select()
242
+ .where('externalId', data.externalId)
243
+ .first(false) ?? new SettlementCharge();
244
+
245
+ // A charge that moves to another payout leaves the one it came from with cached totals
246
+ // that still count it
247
+ const previousSettlementId = charge.settlementId;
248
+
249
+ charge.type = data.type;
250
+ charge.externalId = data.externalId;
251
+ charge.amount = data.amount;
252
+ charge.organizationId = data.organizationId;
253
+ charge.occurredAt = data.occurredAt;
254
+
255
+ if (data.settlementId !== undefined) {
256
+ charge.settlementId = data.settlementId;
257
+ }
258
+ if (data.applicationFeeId !== undefined) {
259
+ charge.applicationFeeId = data.applicationFeeId;
260
+ }
261
+ if (data.paymentId !== undefined) {
262
+ charge.paymentId = data.paymentId;
263
+ }
264
+ if (data.stripeAccountId !== undefined) {
265
+ charge.stripeAccountId = data.stripeAccountId;
266
+ }
267
+ if (data.providerInvoiceId !== undefined) {
268
+ charge.providerInvoiceId = data.providerInvoiceId;
269
+ }
270
+ if (data.description !== undefined) {
271
+ charge.description = data.description;
272
+ }
273
+ await charge.save();
274
+
275
+ if (previousSettlementId && previousSettlementId !== charge.settlementId) {
276
+ await this.refreshTotalsForIds([previousSettlementId]);
277
+ }
278
+
279
+ return charge;
280
+ }
281
+
282
+ /**
283
+ * Stamps which invoice bills a charge to the charged party. Kept here so all
284
+ * settlement_charges writes stay in this service; ApplicationFeeService decides when.
285
+ */
286
+ static async setChargeProviderInvoiceId(settlementChargeId: string, providerInvoiceId: string | null) {
287
+ await this.setChargeProviderInvoiceIds([settlementChargeId], providerInvoiceId);
288
+ }
289
+
290
+ /**
291
+ * One invoice can bill a whole month of fees, so the charges are stamped in one statement per
292
+ * batch instead of loading and saving every row.
293
+ */
294
+ static async setChargeProviderInvoiceIds(settlementChargeIds: string[], providerInvoiceId: string | null) {
295
+ for (let offset = 0; offset < settlementChargeIds.length; offset += CHARGE_UPDATE_BATCH_SIZE) {
296
+ const batch = settlementChargeIds.slice(offset, offset + CHARGE_UPDATE_BATCH_SIZE);
297
+ await SQL.update(SettlementCharge.table)
298
+ .set('providerInvoiceId', providerInvoiceId)
299
+ .where('id', batch)
300
+ .update();
301
+ }
302
+ }
303
+
304
+ /**
305
+ * After a complete walk of a settlement: remove rows the provider no longer reports in this
306
+ * settlement (a transaction can move to another payout). Rows that only exist because of the
307
+ * payout are deleted; rows that outlive the payout link are only unlinked: derived fee lines
308
+ * (owned by updatePaymentSettlementsForAccountDeductionPayment), deduction charges referenced
309
+ * by an application fee (RESTRICT FK), and the application fee rows themselves.
310
+ *
311
+ * Returns the fees unlinked from this settlement, so the caller can refresh the derived lines
312
+ * of their fee payments.
313
+ */
314
+ static async sweepSettlement(settlement: Settlement, reported: ReportedRows): Promise<{ unlinkedFees: ApplicationFee[] }> {
315
+ const lines = await PaymentSettlement.select()
316
+ .where('settlementId', settlement.id)
317
+ .fetch();
318
+
319
+ const sweptPaymentIds = new Set<string>();
320
+ for (const line of lines) {
321
+ if (line.externalId === null) {
322
+ continue;
323
+ }
324
+ if (!reported.paymentLineExternalIds.has(line.externalId)) {
325
+ await line.delete();
326
+ sweptPaymentIds.add(line.paymentId);
327
+ }
328
+ }
329
+
330
+ // The legacy blob points at one of the lines, so a payment that lost one has to be
331
+ // repointed at what is left
332
+ if (sweptPaymentIds.size > 0) {
333
+ const payments = await PaymentModel.getByIDs(...sweptPaymentIds);
334
+ for (const payment of payments) {
335
+ await this.updateLegacySettlementReference(payment);
336
+ }
337
+ }
338
+
339
+ const charges = await SettlementCharge.select()
340
+ .where('settlementId', settlement.id)
341
+ .fetch();
342
+
343
+ const unreportedCharges = charges.filter(charge => !reported.chargeExternalIds.has(charge.externalId));
344
+ const referencedChargeIds = unreportedCharges.length > 0
345
+ ? new Set((await ApplicationFee.select()
346
+ .where('settlementChargeId', unreportedCharges.map(c => c.id))
347
+ .fetch()).map(fee => fee.settlementChargeId))
348
+ : new Set<string>();
349
+
350
+ for (const charge of unreportedCharges) {
351
+ if (referencedChargeIds.has(charge.id)) {
352
+ charge.settlementId = null;
353
+ await charge.save();
354
+ } else {
355
+ await charge.delete();
356
+ }
357
+ }
358
+
359
+ const fees = await ApplicationFee.select()
360
+ .where('settlementId', settlement.id)
361
+ .fetch();
362
+
363
+ const unlinkedFees: ApplicationFee[] = [];
364
+ for (const fee of fees) {
365
+ if (!reported.applicationFeeIds.has(fee.id)) {
366
+ fee.settlementId = null;
367
+ await fee.save();
368
+ unlinkedFees.push(fee);
369
+ }
370
+ }
371
+
372
+ return { unlinkedFees };
373
+ }
374
+
375
+ /**
376
+ * Recomputes the cached reconciliation columns from the stored rows: `unexplainedAmount` should
377
+ * be 0 — a non-zero value is a real question to answer — and `pendingFees` holds what is
378
+ * received but not invoiced yet, which takes up to a month and only becomes a problem when it
379
+ * stays non-zero too long.
380
+ *
381
+ * Every write that changes what a payout holds ends here, or the export and the problem report
382
+ * keep reading numbers from the last sync.
383
+ */
384
+ static async refreshTotals(settlement: Settlement): Promise<Settlement> {
385
+ await this.applyTotals(settlement);
386
+ await settlement.save();
387
+ return settlement;
388
+ }
389
+
390
+ /**
391
+ * The recomputation itself, without saving: callers that write more of the settlement in the
392
+ * same breath save once.
393
+ */
394
+ private static async applyTotals(settlement: Settlement): Promise<void> {
395
+ const paymentSum = await PaymentSettlement.select()
396
+ .where('settlementId', settlement.id)
397
+ .sum(SQL.column('amount')) ?? 0;
398
+
399
+ const chargeSum = await SettlementCharge.select()
400
+ .where('settlementId', settlement.id)
401
+ .sum(SQL.column('amount')) ?? 0;
402
+
403
+ // Once a fee is invoiced it drops out of this sum and its payment's derived line takes
404
+ // over, so the two never count the same fee twice
405
+ const pendingFees = await ApplicationFee.select()
406
+ .where('settlementId', settlement.id)
407
+ .where('balanceItemId', null)
408
+ .sum(SQL.column('amount')) ?? 0;
409
+
410
+ settlement.pendingFees = pendingFees;
411
+ settlement.unexplainedAmount = settlement.amount - paymentSum - chargeSum - pendingFees;
412
+ }
413
+
414
+ /**
415
+ * Same for settlements the caller only knows by id.
416
+ */
417
+ static async refreshTotalsForIds(settlementIds: string[]): Promise<void> {
418
+ if (settlementIds.length === 0) {
419
+ return;
420
+ }
421
+ const settlements = await Settlement.select().where('id', settlementIds).fetch();
422
+ for (const settlement of settlements) {
423
+ await this.refreshTotals(settlement);
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Marks a complete, error-free sync: caches the reconciliation delta and sets syncedAt.
429
+ */
430
+ static async finishSync(settlement: Settlement, { transactionCount }: { transactionCount: number }): Promise<Settlement> {
431
+ await this.applyTotals(settlement);
432
+
433
+ settlement.transactionCount = transactionCount;
434
+ settlement.syncFailureCount = 0;
435
+
436
+ const syncedAt = new Date();
437
+ syncedAt.setMilliseconds(0);
438
+ settlement.syncedAt = syncedAt;
439
+
440
+ await settlement.save();
441
+ return settlement;
442
+ }
443
+
444
+ /**
445
+ * Rebuilds the derived payment lines of an AccountDeductions fee payment: one line per platform
446
+ * payout that contains fees billed by this payment, amount = the sum of those fees. When the
447
+ * total of the lines matches the payment's price, the payment is completely paid out.
448
+ */
449
+ static async updatePaymentSettlementsForAccountDeductionPayment(payment: Payment): Promise<void> {
450
+ if (payment.method !== PaymentMethod.AccountDeductions) {
451
+ return;
452
+ }
453
+
454
+ const balanceItemPayments = await BalanceItemPayment.select()
455
+ .where('paymentId', payment.id)
456
+ .fetch();
457
+
458
+ const fees = balanceItemPayments.length > 0
459
+ ? await ApplicationFee.select()
460
+ .where('balanceItemId', balanceItemPayments.map(b => b.balanceItemId))
461
+ .fetch()
462
+ : [];
463
+
464
+ const perSettlement = new Map<string, { amount: number; occurredAt: Date }>();
465
+ for (const fee of fees) {
466
+ if (!fee.settlementId) {
467
+ continue;
468
+ }
469
+ const group = perSettlement.get(fee.settlementId);
470
+ if (group) {
471
+ group.amount += fee.amount;
472
+ if (fee.occurredAt > group.occurredAt) {
473
+ group.occurredAt = fee.occurredAt;
474
+ }
475
+ } else {
476
+ perSettlement.set(fee.settlementId, { amount: fee.amount, occurredAt: fee.occurredAt });
477
+ }
478
+ }
479
+
480
+ // Every payout whose stored rows change has to be recomputed: a fee that was pending is now
481
+ // explained by the line instead, and a payout that lost its line has it pending again
482
+ const touchedSettlementIds = new Set<string>(perSettlement.keys());
483
+
484
+ const existingLines = await PaymentSettlement.select()
485
+ .where('paymentId', payment.id)
486
+ .fetch();
487
+ for (const line of existingLines) {
488
+ if (line.externalId === null && !perSettlement.has(line.settlementId)) {
489
+ touchedSettlementIds.add(line.settlementId);
490
+ await line.delete();
491
+ }
492
+ }
493
+
494
+ if (perSettlement.size > 0) {
495
+ const settlements = await Settlement.select()
496
+ .where('id', [...perSettlement.keys()])
497
+ .fetch();
498
+ for (const settlement of settlements) {
499
+ const group = perSettlement.get(settlement.id)!;
500
+ await this.upsertPaymentLine(settlement, {
501
+ paymentId: payment.id,
502
+ amount: group.amount,
503
+ externalId: null,
504
+ occurredAt: group.occurredAt,
505
+ });
506
+ }
507
+ }
508
+
509
+ await this.refreshTotalsForIds([...touchedSettlementIds]);
510
+ }
511
+
512
+ /**
513
+ * Refreshes the derived lines of every fee payment that billed one of these balance items:
514
+ * called after a walk linked or unlinked invoiced fees, so the lines follow the fees.
515
+ */
516
+ static async updatePaymentSettlementsForAccountDeductionBalanceItems(balanceItemIds: string[]): Promise<void> {
517
+ if (balanceItemIds.length === 0) {
518
+ return;
519
+ }
520
+
521
+ const balanceItemPayments = await BalanceItemPayment.select()
522
+ .where('balanceItemId', balanceItemIds)
523
+ .fetch();
524
+ const paymentIds = Formatter.uniqueArray(balanceItemPayments.map(b => b.paymentId));
525
+ if (paymentIds.length === 0) {
526
+ return;
527
+ }
528
+
529
+ const payments = await PaymentModel.getByIDs(...paymentIds);
530
+ for (const payment of payments) {
531
+ await this.updatePaymentSettlementsForAccountDeductionPayment(payment);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * A failed sync leaves syncedAt NULL: that is the whole error queue.
537
+ */
538
+ static async markSyncFailed(settlement: Settlement): Promise<Settlement> {
539
+ settlement.syncedAt = null;
540
+ settlement.syncFailureCount += 1;
541
+ await settlement.save();
542
+ return settlement;
543
+ }
544
+
545
+ /**
546
+ * Dual-write of the legacy payments.settlement JSON blob from the new rows. The blob holds one
547
+ * settlement, so the primary is picked deterministically: largest |amount| line, earliest
548
+ * settledAt as tiebreaker — re-syncs never flip-flop the column.
549
+ */
550
+ static async updateLegacySettlementReference(payment: Payment): Promise<void> {
551
+ const lines = await PaymentSettlement.select()
552
+ .where('paymentId', payment.id)
553
+ .fetch();
554
+
555
+ if (lines.length === 0) {
556
+ return;
557
+ }
558
+
559
+ const settlements = await Settlement.select()
560
+ .where('id', lines.map(l => l.settlementId))
561
+ .where('organizationId', payment.organizationId)
562
+ .fetch();
563
+ const settlementsById = new Map(settlements.map(s => [s.id, s]));
564
+
565
+ // Only payouts of the payment's own organization settle it. None stored yet (e.g. that
566
+ // payout isn't synced) means the blob stays untouched.
567
+ const candidates = lines.filter(line => settlementsById.has(line.settlementId));
568
+ if (candidates.length === 0) {
569
+ return;
570
+ }
571
+
572
+ const primary = candidates.sort((a, b) => {
573
+ if (Math.abs(a.amount) !== Math.abs(b.amount)) {
574
+ return Math.abs(b.amount) - Math.abs(a.amount);
575
+ }
576
+ const settledA = settlementsById.get(a.settlementId)!.settledAt.getTime();
577
+ const settledB = settlementsById.get(b.settlementId)!.settledAt.getTime();
578
+ if (settledA !== settledB) {
579
+ return settledA - settledB;
580
+ }
581
+ return (a.externalId ?? '').localeCompare(b.externalId ?? '');
582
+ })[0];
583
+
584
+ const settlement = settlementsById.get(primary.settlementId)!;
585
+
586
+ payment.settlement = SettlementReference.create({
587
+ id: settlement.externalId,
588
+ reference: settlement.reference,
589
+ settledAt: settlement.settledAt,
590
+ amount: settlement.amount,
591
+ // Nothing writes the deprecated fee field anymore; preserve a historically stored
592
+ // value while the primary settlement doesn't change
593
+ fee: payment.settlement?.id === settlement.externalId ? payment.settlement.fee : 0,
594
+ });
595
+ const saved = await payment.save();
596
+
597
+ if (saved) {
598
+ // Mark order as 'updated', or the frontend won't pull in the updates
599
+ const order = await Order.getForPayment(null, payment.id);
600
+ if (order) {
601
+ order.updatedAt = new Date();
602
+ order.forceSaveProperty('updatedAt');
603
+ await order.save();
604
+ }
605
+ }
606
+ }
607
+ }
@@ -1,5 +1,5 @@
1
1
  import { compileToSQLFilter } from '@stamhoofd/sql';
2
- import { PaymentMethod, PaymentProvider, PaymentStatus, Settlement } from '@stamhoofd/structures';
2
+ import { PaymentMethod, PaymentProvider, PaymentStatus, SettlementReference } from '@stamhoofd/structures';
3
3
  import { toBalanceItemFilter } from '@stamhoofd/structures/breakdown/breakdownFilters.js';
4
4
  import type { SettleablePayment } from '@stamhoofd/structures/PaymentSettlement.js';
5
5
  import { ACCOUNT_DEDUCTIONS_ID, FAILED_PAYMENT_ID, getPaymentSettlement, PENDING_PAYMENT_ID } from '@stamhoofd/structures/PaymentSettlement.js';
@@ -7,7 +7,7 @@ import { balanceItemFilterCompilers } from './balance-items.js';
7
7
  import { paymentFilterCompilers } from './payments.js';
8
8
 
9
9
  describe('paymentSettlementFilterCompilers', () => {
10
- const settlement = Settlement.create({
10
+ const settlement = SettlementReference.create({
11
11
  id: 'settlement-1',
12
12
  reference: 'ST-2026-01',
13
13
  settledAt: new Date(2026, 0, 15),