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