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