@stamhoofd/backend 2.139.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 (69) 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/auth/MFA.security.test.ts +76 -1
  11. package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
  13. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  14. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +14 -14
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  17. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  22. package/src/excel-loaders/payments.ts +18 -1
  23. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  24. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  25. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  26. package/src/helpers/AuthenticatedStructures.ts +14 -1
  27. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  28. package/src/helpers/MollieSettlementSync.ts +323 -0
  29. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  30. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  31. package/src/helpers/SettlementExporter.test.ts +366 -0
  32. package/src/helpers/SettlementExporter.ts +583 -0
  33. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  34. package/src/helpers/SettlementSyncRunner.ts +64 -0
  35. package/src/helpers/StripeHelper.ts +2 -1
  36. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  37. package/src/helpers/StripeSettlementSync.ts +947 -0
  38. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  39. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  40. package/src/helpers/TwoFactorHelper.ts +1 -1
  41. package/src/helpers/WebmasterReport.test.ts +109 -0
  42. package/src/helpers/WebmasterReport.ts +115 -0
  43. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  45. package/src/helpers/streamForBreakdown.ts +2 -2
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +283 -0
  48. package/src/services/DocumentRenderService.test.ts +72 -1
  49. package/src/services/InvoiceService.ts +18 -1
  50. package/src/services/PaymentService.ts +8 -0
  51. package/src/services/SettlementService.test.ts +559 -0
  52. package/src/services/SettlementService.ts +607 -0
  53. package/src/sql-filters/orders.ts +6 -2
  54. package/src/sql-filters/payment-settlement.test.ts +2 -2
  55. package/src/sql-filters/payments.ts +73 -0
  56. package/tests/filters/orders.test.ts +635 -0
  57. package/tests/helpers/MollieMocker.ts +64 -6
  58. package/tests/helpers/StripeMocker.ts +209 -17
  59. package/src/crons/stripe-payout-reports.ts +0 -69
  60. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  61. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  62. package/src/helpers/CheckSettlements.test.ts +0 -190
  63. package/src/helpers/CheckSettlements.ts +0 -237
  64. package/src/helpers/StripeInvoicer.ts +0 -419
  65. package/src/helpers/StripePayoutChecker.ts +0 -193
  66. package/src/helpers/StripePayoutExportData.ts +0 -195
  67. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  68. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  69. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -517,6 +517,14 @@ export class PaymentService {
517
517
  }
518
518
  }
519
519
 
520
+ // DirectDebit expires after 3 weeks
521
+ if ((status === PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method === PaymentMethod.DirectDebit) {
522
+ // If payment is not succeeded after one day, mark as failed
523
+ if (payment.createdAt < new Date(new Date().getTime() - 60 * 1000 * 60 * 24 * 7 * 3)) {
524
+ return true;
525
+ }
526
+ }
527
+
520
528
  if (STAMHOOFD.environment === 'development') {
521
529
  // In development, we expire all direct debits and other paymetns after 1 hour, because they need manual changes
522
530
  // otherwise they will remain stuck in the dev environment, poluting the UI
@@ -0,0 +1,559 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { BalanceItem, BalanceItemPayment, OrganizationFactory, Payment, StripeAccount } from '@stamhoofd/models';
3
+ import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
4
+ import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
5
+ import type { Settlement as SettlementModel } from '@stamhoofd/models/models/Settlement.js';
6
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
7
+ import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
8
+ import { BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus, SettlementReference } from '@stamhoofd/structures';
9
+ import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
10
+ import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
11
+ import { v4 as uuidv4 } from 'uuid';
12
+ import { ReportedRows, SettlementService } from './SettlementService.js';
13
+
14
+ describe('SettlementService', () => {
15
+ let organization: Organization;
16
+
17
+ beforeAll(async () => {
18
+ organization = await new OrganizationFactory({}).create();
19
+ });
20
+
21
+ async function createPayment(price = 50_00_00, method = PaymentMethod.Bancontact, organizationId = organization.id) {
22
+ const payment = new Payment();
23
+ payment.organizationId = organizationId;
24
+ payment.method = method;
25
+ payment.provider = PaymentProvider.Stripe;
26
+ payment.status = PaymentStatus.Succeeded;
27
+ payment.price = price;
28
+ payment.paidAt = new Date();
29
+ await payment.save();
30
+ return payment;
31
+ }
32
+
33
+ async function createStripeAccount() {
34
+ const stripeAccount = new StripeAccount();
35
+ stripeAccount.organizationId = organization.id;
36
+ stripeAccount.accountId = 'acct_' + uuidv4();
37
+ await stripeAccount.save();
38
+ return stripeAccount;
39
+ }
40
+
41
+ /**
42
+ * A fee with its deduction charge, as the sync stores them.
43
+ */
44
+ async function createApplicationFee({ amount = 1_00_00, type = ApplicationFeeType.Service, settlement = null as SettlementModel | null, balanceItemId = null as string | null, occurredAt = new Date(2026, 0, 14) } = {}) {
45
+ const externalId = 'fee_' + uuidv4();
46
+ const charge = await SettlementService.upsertCharge({
47
+ type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
48
+ externalId: externalId + ':' + type,
49
+ amount: -amount,
50
+ applicationFeeId: externalId,
51
+ organizationId: organization.id,
52
+ occurredAt,
53
+ });
54
+
55
+ const fee = new ApplicationFee();
56
+ fee.externalId = externalId;
57
+ fee.type = type;
58
+ fee.amount = amount;
59
+ fee.organizationId = organization.id;
60
+ fee.payingOrganizationId = organization.id;
61
+ fee.settlementChargeId = charge.id;
62
+ fee.settlementId = settlement?.id ?? null;
63
+ fee.balanceItemId = balanceItemId;
64
+ fee.occurredAt = occurredAt;
65
+ await fee.save();
66
+ return { fee, charge };
67
+ }
68
+
69
+ /**
70
+ * A fee payment with one balance item, like the invoicer creates.
71
+ */
72
+ async function createFeePayment(price = 2_00_00) {
73
+ const payment = await createPayment(price, PaymentMethod.AccountDeductions);
74
+
75
+ const balanceItem = new BalanceItem();
76
+ balanceItem.type = BalanceItemType.ServiceFee;
77
+ balanceItem.organizationId = organization.id;
78
+ balanceItem.payingOrganizationId = organization.id;
79
+ balanceItem.unitPrice = price;
80
+ balanceItem.quantity = 1;
81
+ balanceItem.status = BalanceItemStatus.Hidden;
82
+ await balanceItem.save();
83
+
84
+ const balanceItemPayment = new BalanceItemPayment();
85
+ balanceItemPayment.balanceItemId = balanceItem.id;
86
+ balanceItemPayment.paymentId = payment.id;
87
+ balanceItemPayment.organizationId = organization.id;
88
+ balanceItemPayment.price = price;
89
+ await balanceItemPayment.save();
90
+
91
+ return { payment, balanceItem };
92
+ }
93
+
94
+ function settlementData(overrides: Partial<Parameters<typeof SettlementService.upsertSettlement>[0]> = {}) {
95
+ return {
96
+ provider: PaymentProvider.Stripe,
97
+ externalId: 'po_' + uuidv4(),
98
+ organizationId: organization.id,
99
+ amount: 370_00_00,
100
+ reference: 'STRIPE PAYOUT',
101
+ settledAt: new Date(2026, 0, 15),
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ describe('upsertSettlement', () => {
107
+ test('re-running updates the same row instead of duplicating', async () => {
108
+ const data = settlementData();
109
+ const created = await SettlementService.upsertSettlement(data);
110
+
111
+ const updated = await SettlementService.upsertSettlement({ ...data, amount: 400_00_00 });
112
+
113
+ expect(updated.id).toBe(created.id);
114
+ expect(updated.amount).toBe(400_00_00);
115
+ expect(await Settlement.select().where('externalId', data.externalId).count()).toBe(1);
116
+ });
117
+
118
+ test('the same externalId of a different provider is a different settlement', async () => {
119
+ const externalId = 'shared_' + uuidv4();
120
+ const stripe = await SettlementService.upsertSettlement(settlementData({ externalId }));
121
+ const mollie = await SettlementService.upsertSettlement(settlementData({ externalId, provider: PaymentProvider.Mollie }));
122
+
123
+ expect(mollie.id).not.toBe(stripe.id);
124
+ });
125
+
126
+ test('undefined optional fields keep their stored value', async () => {
127
+ const data = settlementData({ stripeAccountId: null });
128
+ const created = await SettlementService.upsertSettlement(data);
129
+ expect(created.organizationId).toBe(organization.id);
130
+
131
+ const updated = await SettlementService.upsertSettlement({
132
+ provider: data.provider,
133
+ externalId: data.externalId,
134
+ organizationId: organization.id,
135
+ amount: data.amount,
136
+ settledAt: data.settledAt,
137
+ });
138
+ expect(updated.stripeAccountId).toBe(null);
139
+ expect(updated.reference).toBe('STRIPE PAYOUT');
140
+ });
141
+ });
142
+
143
+ describe('upsertPaymentLine', () => {
144
+ test('idempotent per (settlement, externalId)', async () => {
145
+ const payment = await createPayment();
146
+ const settlement = await SettlementService.upsertSettlement(settlementData());
147
+
148
+ const line = await SettlementService.upsertPaymentLine(settlement, {
149
+ paymentId: payment.id,
150
+ amount: 50_00_00,
151
+ externalId: 'txn_1',
152
+ occurredAt: new Date(2026, 0, 14),
153
+ });
154
+ const again = await SettlementService.upsertPaymentLine(settlement, {
155
+ paymentId: payment.id,
156
+ amount: 51_00_00,
157
+ externalId: 'txn_1',
158
+ occurredAt: new Date(2026, 0, 14),
159
+ });
160
+
161
+ expect(again.id).toBe(line.id);
162
+ expect(again.amount).toBe(51_00_00);
163
+ expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(1);
164
+ });
165
+
166
+ test('a transaction that moved to another payout leaves nothing behind', async () => {
167
+ const payment = await createPayment();
168
+ const from = await SettlementService.upsertSettlement(settlementData({ amount: 50_00_00 }));
169
+ const to = await SettlementService.upsertSettlement(settlementData({ amount: 50_00_00 }));
170
+
171
+ await SettlementService.upsertPaymentLine(from, {
172
+ paymentId: payment.id,
173
+ amount: 50_00_00,
174
+ externalId: 'txn_moved',
175
+ occurredAt: new Date(2026, 0, 14),
176
+ });
177
+ await SettlementService.finishSync(from, { transactionCount: 1 });
178
+ expect(from.unexplainedAmount).toBe(0);
179
+
180
+ // Stripe reports the same transaction in a later payout: counting it in both would
181
+ // settle the payment twice
182
+ await SettlementService.upsertPaymentLine(to, {
183
+ paymentId: payment.id,
184
+ amount: 50_00_00,
185
+ externalId: 'txn_moved',
186
+ occurredAt: new Date(2026, 0, 20),
187
+ });
188
+
189
+ const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
190
+ expect(lines).toHaveLength(1);
191
+ expect(lines[0].settlementId).toBe(to.id);
192
+
193
+ // The payout it left is recomputed right away, instead of waiting for its next walk
194
+ expect((await Settlement.getByID(from.id))!.unexplainedAmount).toBe(50_00_00);
195
+ });
196
+
197
+ test('the line is stored on the organization of the payout', async () => {
198
+ const otherOrganization = await new OrganizationFactory({}).create();
199
+ const payment = await createPayment(50_00_00, PaymentMethod.Bancontact, otherOrganization.id);
200
+ const settlement = await SettlementService.upsertSettlement(settlementData({ organizationId: otherOrganization.id }));
201
+
202
+ const line = await SettlementService.upsertPaymentLine(settlement, {
203
+ paymentId: payment.id,
204
+ amount: 50_00_00,
205
+ externalId: 'txn_organization',
206
+ occurredAt: new Date(2026, 0, 14),
207
+ });
208
+
209
+ expect(line.organizationId).toBe(otherOrganization.id);
210
+ expect((await PaymentSettlement.getByID(line.id))!.organizationId).toBe(otherOrganization.id);
211
+ });
212
+ });
213
+
214
+ describe('upsertCharge', () => {
215
+ test('idempotent by externalId, and undefined fields keep their stored value', async () => {
216
+ const settlement = await SettlementService.upsertSettlement(settlementData());
217
+ const externalId = 'fee_' + uuidv4() + ':ApplicationFeeService';
218
+
219
+ const created = await SettlementService.upsertCharge({
220
+ type: SettlementChargeType.ApplicationFeeService,
221
+ externalId,
222
+ amount: -1_00_00,
223
+ settlementId: settlement.id,
224
+ applicationFeeId: 'fee_123',
225
+ organizationId: organization.id,
226
+ occurredAt: new Date(2026, 0, 14),
227
+ });
228
+
229
+ // The fee walk doesn't know the settlement: it may not unlink the row
230
+ const updated = await SettlementService.upsertCharge({
231
+ type: SettlementChargeType.ApplicationFeeService,
232
+ externalId,
233
+ amount: -1_10_00,
234
+ organizationId: organization.id,
235
+ occurredAt: new Date(2026, 0, 14),
236
+ });
237
+
238
+ expect(updated.id).toBe(created.id);
239
+ expect(updated.amount).toBe(-1_10_00);
240
+ expect(updated.settlementId).toBe(settlement.id);
241
+ expect(updated.applicationFeeId).toBe('fee_123');
242
+
243
+ // An explicit null does clear the link
244
+ const cleared = await SettlementService.upsertCharge({
245
+ type: SettlementChargeType.ApplicationFeeService,
246
+ externalId,
247
+ amount: -1_10_00,
248
+ settlementId: null,
249
+ organizationId: organization.id,
250
+ occurredAt: new Date(2026, 0, 14),
251
+ });
252
+ expect(cleared.settlementId).toBe(null);
253
+ });
254
+ });
255
+
256
+ describe('sweepSettlement', () => {
257
+ test('deletes payout-only rows, but unlinks what outlives the payout', async () => {
258
+ const payment = await createPayment();
259
+ const settlement = await SettlementService.upsertSettlement(settlementData());
260
+
261
+ const keptLine = await SettlementService.upsertPaymentLine(settlement, {
262
+ paymentId: payment.id, amount: 50_00_00, externalId: 'txn_kept', occurredAt: new Date(2026, 0, 14),
263
+ });
264
+ const movedLine = await SettlementService.upsertPaymentLine(settlement, {
265
+ paymentId: payment.id, amount: 10_00_00, externalId: 'txn_moved', occurredAt: new Date(2026, 0, 14),
266
+ });
267
+
268
+ // A derived fee line is never reported by a walk: it belongs to the fee payment
269
+ const derivedLine = await SettlementService.upsertPaymentLine(settlement, {
270
+ paymentId: payment.id, amount: 2_00_00, externalId: null, occurredAt: new Date(2026, 0, 14),
271
+ });
272
+
273
+ const { fee, charge: referencedCharge } = await createApplicationFee({ settlement });
274
+ const providerFee = await SettlementService.upsertCharge({
275
+ type: SettlementChargeType.ProviderTransactionFee,
276
+ externalId: 'txn_moved:fee:0',
277
+ amount: -30_00,
278
+ settlementId: settlement.id,
279
+ organizationId: organization.id,
280
+ occurredAt: new Date(2026, 0, 14),
281
+ });
282
+ const keptFee = await SettlementService.upsertCharge({
283
+ type: SettlementChargeType.ProviderTransactionFee,
284
+ externalId: 'txn_kept:fee:0',
285
+ amount: -25_00,
286
+ settlementId: settlement.id,
287
+ organizationId: organization.id,
288
+ occurredAt: new Date(2026, 0, 14),
289
+ });
290
+
291
+ // The deduction charge is linked to this settlement too, but no longer reported
292
+ referencedCharge.settlementId = settlement.id;
293
+ await referencedCharge.save();
294
+
295
+ const reported = new ReportedRows();
296
+ reported.paymentLine(keptLine);
297
+ reported.charge(keptFee);
298
+ const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
299
+
300
+ expect(await PaymentSettlement.getByID(keptLine.id)).toBeDefined();
301
+ expect(await PaymentSettlement.getByID(movedLine.id)).toBeUndefined();
302
+ expect(await PaymentSettlement.getByID(derivedLine.id)).toBeDefined();
303
+ expect(await SettlementCharge.getByID(providerFee.id)).toBeUndefined();
304
+ expect((await SettlementCharge.getByID(keptFee.id))?.settlementId).toBe(settlement.id);
305
+
306
+ // A charge an application fee points at may never be deleted (foreign key), only unlinked
307
+ const unlinkedCharge = await SettlementCharge.getByID(referencedCharge.id);
308
+ expect(unlinkedCharge).toBeDefined();
309
+ expect(unlinkedCharge!.settlementId).toBe(null);
310
+
311
+ expect(unlinkedFees.map(f => f.id)).toEqual([fee.id]);
312
+ expect((await ApplicationFee.getByID(fee.id))!.settlementId).toBe(null);
313
+ });
314
+
315
+ test('keeps the application fees the walk reported', async () => {
316
+ const settlement = await SettlementService.upsertSettlement(settlementData());
317
+ const { fee } = await createApplicationFee({ settlement });
318
+
319
+ const reported = new ReportedRows();
320
+ reported.applicationFee(fee);
321
+ const { unlinkedFees } = await SettlementService.sweepSettlement(settlement, reported);
322
+
323
+ expect(unlinkedFees).toHaveLength(0);
324
+ expect((await ApplicationFee.getByID(fee.id))!.settlementId).toBe(settlement.id);
325
+ });
326
+ });
327
+
328
+ describe('updatePaymentSettlementsForAccountDeductionPayment', () => {
329
+ test('one line per payout, summing the fees it contained', async () => {
330
+ const { payment, balanceItem } = await createFeePayment(3_25_00);
331
+ const first = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 10) }));
332
+ const second = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 20) }));
333
+
334
+ await createApplicationFee({ settlement: first, balanceItemId: balanceItem.id, amount: 1_00_00, occurredAt: new Date(2026, 0, 5) });
335
+ await createApplicationFee({ settlement: first, balanceItemId: balanceItem.id, amount: 50_00, occurredAt: new Date(2026, 0, 8) });
336
+ await createApplicationFee({ settlement: second, balanceItemId: balanceItem.id, amount: 1_50_00, occurredAt: new Date(2026, 0, 15) });
337
+
338
+ // A fee that isn't paid out yet doesn't produce a line
339
+ const { fee: pending } = await createApplicationFee({ balanceItemId: balanceItem.id, amount: 25_00 });
340
+
341
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
342
+
343
+ const lines = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
344
+ expect(lines).toHaveLength(2);
345
+ expect(lines.every(line => line.externalId === null)).toBe(true);
346
+
347
+ const bySettlement = new Map(lines.map(line => [line.settlementId, line]));
348
+ expect(bySettlement.get(first.id)!.amount).toBe(1_50_00);
349
+ expect(bySettlement.get(first.id)!.occurredAt).toEqual(new Date(2026, 0, 8));
350
+ expect(bySettlement.get(second.id)!.amount).toBe(1_50_00);
351
+
352
+ // Not settled completely yet: the lines don't add up to the payment price
353
+ expect(lines.reduce((total, line) => total + line.amount, 0)).not.toBe(payment.price);
354
+
355
+ // Paying out the last fee completes the payment
356
+ pending.settlementId = second.id;
357
+ await pending.save();
358
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
359
+
360
+ const completed = await PaymentSettlement.select().where('paymentId', payment.id).fetch();
361
+ expect(completed.reduce((total, line) => total + line.amount, 0)).toBe(payment.price);
362
+ });
363
+
364
+ test('every fee payment keeps its own line in the same payout', async () => {
365
+ const settlement = await SettlementService.upsertSettlement(settlementData());
366
+ const first = await createFeePayment(1_00_00);
367
+ const second = await createFeePayment(2_00_00);
368
+ await createApplicationFee({ settlement, balanceItemId: first.balanceItem.id, amount: 1_00_00 });
369
+ await createApplicationFee({ settlement, balanceItemId: second.balanceItem.id, amount: 2_00_00 });
370
+
371
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(first.payment);
372
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(second.payment);
373
+ // Re-running may not duplicate: the lines have no externalId to upsert on
374
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(first.payment);
375
+
376
+ const lines = await PaymentSettlement.select().where('settlementId', settlement.id).fetch();
377
+ expect(lines).toHaveLength(2);
378
+ expect(lines.find(line => line.paymentId === first.payment.id)!.amount).toBe(1_00_00);
379
+ expect(lines.find(line => line.paymentId === second.payment.id)!.amount).toBe(2_00_00);
380
+ });
381
+
382
+ test('a line disappears again when its fees are unlinked from the payout', async () => {
383
+ const { payment, balanceItem } = await createFeePayment(1_00_00);
384
+ const settlement = await SettlementService.upsertSettlement(settlementData());
385
+ const { fee } = await createApplicationFee({ settlement, balanceItemId: balanceItem.id, amount: 1_00_00 });
386
+
387
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
388
+ expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(1);
389
+
390
+ fee.settlementId = null;
391
+ await fee.save();
392
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
393
+
394
+ expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(0);
395
+ });
396
+
397
+ test('other payment methods are left alone', async () => {
398
+ const payment = await createPayment();
399
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
400
+ expect(await PaymentSettlement.select().where('paymentId', payment.id).count()).toBe(0);
401
+ });
402
+ });
403
+
404
+ describe('finishSync and markSyncFailed', () => {
405
+ test('finishSync caches the reconciliation delta and resets the failure count', async () => {
406
+ const payment = await createPayment();
407
+ const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 48_70_00 }));
408
+ await SettlementService.markSyncFailed(settlement);
409
+ expect(settlement.syncFailureCount).toBe(1);
410
+ expect(settlement.syncedAt).toBe(null);
411
+
412
+ await SettlementService.upsertPaymentLine(settlement, {
413
+ paymentId: payment.id, amount: 50_00_00, externalId: 'txn_1', occurredAt: new Date(2026, 0, 14),
414
+ });
415
+ await SettlementService.upsertCharge({
416
+ type: SettlementChargeType.ApplicationFeeService,
417
+ externalId: 'fee_' + uuidv4() + ':ApplicationFeeService',
418
+ amount: -1_00_00,
419
+ settlementId: settlement.id,
420
+ organizationId: organization.id,
421
+ occurredAt: new Date(2026, 0, 14),
422
+ });
423
+
424
+ await SettlementService.finishSync(settlement, { transactionCount: 2 });
425
+
426
+ // 48.70 - (50.00 - 1.00) = -0.30 unexplained
427
+ expect(settlement.unexplainedAmount).toBe(-30_00);
428
+ expect(settlement.pendingFees).toBe(0);
429
+ expect(settlement.transactionCount).toBe(2);
430
+ expect(settlement.syncedAt).not.toBe(null);
431
+ expect(settlement.syncFailureCount).toBe(0);
432
+ });
433
+
434
+ test('fees waiting to be invoiced explain the payout until their payment does', async () => {
435
+ const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 1_00_00 }));
436
+ const { fee } = await createApplicationFee({ settlement, amount: 1_00_00 });
437
+
438
+ await SettlementService.finishSync(settlement, { transactionCount: 1 });
439
+ expect(settlement.pendingFees).toBe(1_00_00);
440
+ expect(settlement.unexplainedAmount).toBe(0);
441
+
442
+ // Once invoiced, the fee payment's derived line explains it instead: counting both
443
+ // would explain the same money twice. Nothing re-syncs a healthy payout, so the cached
444
+ // columns have to follow the line right away
445
+ const { payment, balanceItem } = await createFeePayment(1_00_00);
446
+ fee.balanceItemId = balanceItem.id;
447
+ await fee.save();
448
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
449
+
450
+ const invoiced = await Settlement.getByID(settlement.id);
451
+ expect(invoiced!.pendingFees).toBe(0);
452
+ expect(invoiced!.unexplainedAmount).toBe(0);
453
+ });
454
+
455
+ test('a payout that loses its derived line has the fee pending again', async () => {
456
+ const settlement = await SettlementService.upsertSettlement(settlementData({ amount: 1_00_00 }));
457
+ const { payment, balanceItem } = await createFeePayment(1_00_00);
458
+ const { fee } = await createApplicationFee({ settlement, balanceItemId: balanceItem.id, amount: 1_00_00 });
459
+
460
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
461
+ await SettlementService.finishSync(settlement, { transactionCount: 1 });
462
+ expect(settlement.pendingFees).toBe(0);
463
+ expect(settlement.unexplainedAmount).toBe(0);
464
+
465
+ // The payout no longer contains the fee: its line goes, and so does what it explained
466
+ fee.settlementId = null;
467
+ await fee.save();
468
+ await SettlementService.updatePaymentSettlementsForAccountDeductionPayment(payment);
469
+
470
+ const unlinked = await Settlement.getByID(settlement.id);
471
+ expect(unlinked!.pendingFees).toBe(0);
472
+ expect(unlinked!.unexplainedAmount).toBe(1_00_00);
473
+ });
474
+ });
475
+
476
+ describe('updateLegacySettlementReference', () => {
477
+ test('the largest line wins and re-syncs never flip-flop the column', async () => {
478
+ const payment = await createPayment();
479
+ const original = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 15) }));
480
+ const refundPayout = await SettlementService.upsertSettlement(settlementData({ settledAt: new Date(2026, 0, 22) }));
481
+
482
+ await SettlementService.upsertPaymentLine(original, {
483
+ paymentId: payment.id, amount: 50_00_00, externalId: 'txn_pay', occurredAt: new Date(2026, 0, 14),
484
+ });
485
+ await SettlementService.upsertPaymentLine(refundPayout, {
486
+ paymentId: payment.id, amount: -20_00_00, externalId: 'txn_refund', occurredAt: new Date(2026, 0, 21),
487
+ });
488
+
489
+ await SettlementService.updateLegacySettlementReference(payment);
490
+ expect(payment.settlement?.id).toBe(original.externalId);
491
+ expect(payment.settlement?.amount).toBe(original.amount);
492
+
493
+ await SettlementService.updateLegacySettlementReference(payment);
494
+ expect(payment.settlement?.id).toBe(original.externalId);
495
+ });
496
+
497
+ test('keeps the deprecated fee field when the primary settlement is unchanged', async () => {
498
+ const payment = await createPayment();
499
+ const settlement = await SettlementService.upsertSettlement(settlementData());
500
+ await SettlementService.upsertPaymentLine(settlement, {
501
+ paymentId: payment.id, amount: 50_00_00, externalId: 'txn_pay', occurredAt: new Date(2026, 0, 14),
502
+ });
503
+
504
+ // A historically stored blob that still carries a fee
505
+ payment.settlement = SettlementReference.create({
506
+ id: settlement.externalId,
507
+ reference: 'OLD',
508
+ settledAt: new Date(2026, 0, 15),
509
+ amount: 370_00_00,
510
+ fee: 1_23_00,
511
+ });
512
+ await payment.save();
513
+
514
+ await SettlementService.updateLegacySettlementReference(payment);
515
+ expect(payment.settlement?.fee).toBe(1_23_00);
516
+
517
+ // A different primary settlement can't invent a fee
518
+ const bigger = await SettlementService.upsertSettlement(settlementData());
519
+ await SettlementService.upsertPaymentLine(bigger, {
520
+ paymentId: payment.id, amount: 60_00_00, externalId: 'txn_bigger', occurredAt: new Date(2026, 0, 20),
521
+ });
522
+ await SettlementService.updateLegacySettlementReference(payment);
523
+ expect(payment.settlement?.id).toBe(bigger.externalId);
524
+ expect(payment.settlement?.fee).toBe(0);
525
+ });
526
+
527
+ test('a payout of another organization is never the payment\'s payout', async () => {
528
+ const stripeAccount = await createStripeAccount();
529
+ const payment = await createPayment();
530
+ payment.stripeAccountId = stripeAccount.id;
531
+ await payment.save();
532
+
533
+ const otherOrganization = await new OrganizationFactory({}).create();
534
+ const foreignPayout = await SettlementService.upsertSettlement(settlementData({ organizationId: otherOrganization.id, settledAt: new Date(2026, 0, 10) }));
535
+ const organizationPayout = await SettlementService.upsertSettlement(settlementData({ stripeAccountId: stripeAccount.id, settledAt: new Date(2026, 0, 15) }));
536
+
537
+ await SettlementService.upsertPaymentLine(foreignPayout, {
538
+ paymentId: payment.id, amount: 50_00_00, externalId: 'txn_foreign', occurredAt: new Date(2026, 0, 9),
539
+ });
540
+ await SettlementService.upsertPaymentLine(organizationPayout, {
541
+ paymentId: payment.id, amount: 50_00_00, externalId: 'txn_organization', occurredAt: new Date(2026, 0, 9),
542
+ });
543
+
544
+ await SettlementService.updateLegacySettlementReference(payment);
545
+ expect(payment.settlement?.id).toBe(organizationPayout.externalId);
546
+ });
547
+
548
+ test('a payment without settlement rows is left untouched', async () => {
549
+ const payment = await createPayment();
550
+ payment.settlement = SettlementReference.create({
551
+ id: 'legacy', reference: 'LEGACY', settledAt: new Date(2026, 0, 15), amount: 370_00_00,
552
+ });
553
+ await payment.save();
554
+
555
+ await SettlementService.updateLegacySettlementReference(payment);
556
+ expect(payment.settlement?.id).toBe('legacy');
557
+ });
558
+ });
559
+ });