@stamhoofd/backend 2.141.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.
@@ -31,10 +31,11 @@ export type ApplicationFeeData = {
31
31
  */
32
32
  organizationId: string;
33
33
 
34
- payingOrganizationId: string;
35
- payingStripeAccountId: string;
34
+ payingOrganizationId?: string | null;
35
+ payingStripeAccountId?: string | null;
36
36
  payingPaymentId?: string | null;
37
- settlementChargeId: string;
37
+
38
+ settlementChargeId?: string | null;
38
39
  settlementId?: string | null;
39
40
  occurredAt: Date;
40
41
  };
@@ -85,17 +86,28 @@ export class ApplicationFeeService {
85
86
  fee.type = data.type;
86
87
  fee.amount = data.amount;
87
88
  fee.organizationId = data.organizationId;
88
- fee.payingOrganizationId = data.payingOrganizationId;
89
- fee.payingStripeAccountId = data.payingStripeAccountId;
90
- fee.settlementChargeId = data.settlementChargeId;
91
89
  fee.occurredAt = data.occurredAt;
92
90
 
93
91
  if (data.settlementId !== undefined) {
94
92
  fee.settlementId = data.settlementId;
95
93
  }
94
+
96
95
  if (data.payingPaymentId !== undefined) {
97
96
  fee.payingPaymentId = data.payingPaymentId;
98
97
  }
98
+
99
+ if (data.settlementChargeId !== undefined) {
100
+ fee.settlementChargeId = data.settlementChargeId;
101
+ }
102
+
103
+ if (data.payingOrganizationId !== undefined) {
104
+ fee.payingOrganizationId = data.payingOrganizationId;
105
+ }
106
+
107
+ if (data.payingStripeAccountId !== undefined) {
108
+ fee.payingStripeAccountId = data.payingStripeAccountId;
109
+ }
110
+
99
111
  await fee.save();
100
112
 
101
113
  if (fee.balanceItemId === null) {
@@ -162,7 +174,7 @@ export class ApplicationFeeService {
162
174
  * from being billed twice.
163
175
  */
164
176
  private static async linkLegacyInvoicedFee(fee: ApplicationFee) {
165
- if (!fee.payingStripeAccountId) {
177
+ if (!fee.payingStripeAccountId || !fee.payingOrganizationId) {
166
178
  return;
167
179
  }
168
180
 
@@ -217,7 +229,9 @@ export class ApplicationFeeService {
217
229
  .where('id', balanceItemPayments.map(b => b.balanceItemId))
218
230
  .fetch();
219
231
  const balanceItemType = type === ApplicationFeeType.Service ? BalanceItemType.ServiceFee : BalanceItemType.TransferFee;
220
- return balanceItems.find(item => item.type === balanceItemType) ?? null;
232
+
233
+ // In legacy migrations, the type has been set to 'Other' - fallback to that (contains both service fees and transfer fees in one balance item)
234
+ return balanceItems.find(item => item.type === balanceItemType) ?? (balanceItems.length === 1 ? balanceItems.find(item => item.type === BalanceItemType.Other) : null) ?? null;
221
235
  }
222
236
 
223
237
  /**
@@ -226,6 +240,10 @@ export class ApplicationFeeService {
226
240
  * no stamp: stampInvoicedPayments runs when the invoice is created later.
227
241
  */
228
242
  private static async stampProviderInvoiceId(fee: ApplicationFee, { payment }: { payment?: Payment } = {}) {
243
+ if (!fee.settlementChargeId) {
244
+ return;
245
+ }
246
+
229
247
  if (!payment) {
230
248
  if (!fee.balanceItemId) {
231
249
  return;
@@ -275,7 +293,7 @@ export class ApplicationFeeService {
275
293
  .where('balanceItemId', balanceItemPayments.map(b => b.balanceItemId))
276
294
  .limit(FEE_BATCH_SIZE)
277
295
  .allBatched()) {
278
- settlementChargeIds.push(...fees.map(fee => fee.settlementChargeId));
296
+ settlementChargeIds.push(...fees.map(fee => fee.settlementChargeId).filter((id): id is string => id !== null));
279
297
  }
280
298
 
281
299
  await SettlementService.setChargeProviderInvoiceIds(settlementChargeIds, invoice?.number ?? null);
@@ -13,9 +13,11 @@ import { ReportedRows, SettlementService } from './SettlementService.js';
13
13
 
14
14
  describe('SettlementService', () => {
15
15
  let organization: Organization;
16
+ let stripeAccount: StripeAccount;
16
17
 
17
18
  beforeAll(async () => {
18
19
  organization = await new OrganizationFactory({}).create();
20
+ stripeAccount = await createStripeAccount();
19
21
  });
20
22
 
21
23
  async function createPayment(price = 50_00_00, method = PaymentMethod.Bancontact, organizationId = organization.id) {
@@ -30,25 +32,25 @@ describe('SettlementService', () => {
30
32
  return payment;
31
33
  }
32
34
 
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;
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;
39
41
  }
40
42
 
41
43
  /**
42
44
  * A fee with its deduction charge, as the sync stores them.
43
45
  */
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) } = {}) {
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 } = {}) {
45
47
  const externalId = 'fee_' + uuidv4();
46
48
  const charge = await SettlementService.upsertCharge({
47
49
  type: type === ApplicationFeeType.Service ? SettlementChargeType.ApplicationFeeService : SettlementChargeType.ApplicationFeeTransfer,
48
50
  externalId: externalId + ':' + type,
49
51
  amount: -amount,
50
52
  applicationFeeId: externalId,
51
- organizationId: organization.id,
53
+ organizationId: payingOrganizationId,
52
54
  occurredAt,
53
55
  });
54
56
 
@@ -57,7 +59,8 @@ describe('SettlementService', () => {
57
59
  fee.type = type;
58
60
  fee.amount = amount;
59
61
  fee.organizationId = organization.id;
60
- fee.payingOrganizationId = organization.id;
62
+ fee.payingOrganizationId = payingOrganizationId;
63
+ fee.payingStripeAccountId = payingStripeAccountId ?? stripeAccount.id;
61
64
  fee.settlementChargeId = charge.id;
62
65
  fee.settlementId = settlement?.id ?? null;
63
66
  fee.balanceItemId = balanceItemId;
@@ -66,6 +69,22 @@ describe('SettlementService', () => {
66
69
  return { fee, charge };
67
70
  }
68
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
+
69
88
  /**
70
89
  * A fee payment with one balance item, like the invoicer creates.
71
90
  */
@@ -471,6 +490,60 @@ describe('SettlementService', () => {
471
490
  expect(unlinked!.pendingFees).toBe(0);
472
491
  expect(unlinked!.unexplainedAmount).toBe(1_00_00);
473
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
+ });
474
547
  });
475
548
 
476
549
  describe('updateLegacySettlementReference', () => {
@@ -62,6 +62,11 @@ export type ChargeData = {
62
62
  */
63
63
  const CHARGE_UPDATE_BATCH_SIZE = 500;
64
64
 
65
+ /**
66
+ * Fees read per batch when a whole organization's fees are walked.
67
+ */
68
+ const FEE_BATCH_SIZE = 500;
69
+
65
70
  /**
66
71
  * Collects which rows the provider still reports in a settlement while a sync walks it. A stored
67
72
  * row of the settlement that is not in here after the walk has moved or disappeared at the
@@ -306,7 +311,7 @@ export class SettlementService {
306
311
  * settlement (a transaction can move to another payout). Rows that only exist because of the
307
312
  * payout are deleted; rows that outlive the payout link are only unlinked: derived fee lines
308
313
  * (owned by updatePaymentSettlementsForAccountDeductionPayment), deduction charges referenced
309
- * by an application fee (RESTRICT FK), and the application fee rows themselves.
314
+ * by an application fee, and the application fee rows themselves.
310
315
  *
311
316
  * Returns the fees unlinked from this settlement, so the caller can refresh the derived lines
312
317
  * of their fee payments.
@@ -376,7 +381,9 @@ export class SettlementService {
376
381
  * Recomputes the cached reconciliation columns from the stored rows: `unexplainedAmount` should
377
382
  * be 0 — a non-zero value is a real question to answer — and `pendingFees` holds what is
378
383
  * received but not invoiced yet, which takes up to a month and only becomes a problem when it
379
- * stays non-zero too long.
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.
380
387
  *
381
388
  * Every write that changes what a payout holds ends here, or the export and the problem report
382
389
  * keep reading numbers from the last sync.
@@ -405,10 +412,46 @@ export class SettlementService {
405
412
  const pendingFees = await ApplicationFee.select()
406
413
  .where('settlementId', settlement.id)
407
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
+ )
408
428
  .sum(SQL.column('amount')) ?? 0;
409
429
 
410
430
  settlement.pendingFees = pendingFees;
411
- settlement.unexplainedAmount = settlement.amount - paymentSum - chargeSum - 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];
412
455
  }
413
456
 
414
457
  /**