@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.
- package/package.json +17 -17
- package/src/crons/settlement-sync.ts +1 -1
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
- package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
- package/src/helpers/SettlementExporter.test.ts +22 -0
- package/src/helpers/SettlementExporter.ts +13 -3
- package/src/helpers/StripeSettlementSync.test.ts +169 -0
- package/src/helpers/StripeSettlementSync.ts +158 -52
- package/src/helpers/StripeSettlementSyncRunner.ts +8 -0
- package/src/services/ApplicationFeeService.ts +27 -9
- package/src/services/SettlementService.test.ts +82 -9
- package/src/services/SettlementService.ts +46 -3
|
@@ -31,10 +31,11 @@ export type ApplicationFeeData = {
|
|
|
31
31
|
*/
|
|
32
32
|
organizationId: string;
|
|
33
33
|
|
|
34
|
-
payingOrganizationId
|
|
35
|
-
payingStripeAccountId
|
|
34
|
+
payingOrganizationId?: string | null;
|
|
35
|
+
payingStripeAccountId?: string | null;
|
|
36
36
|
payingPaymentId?: string | null;
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await
|
|
38
|
-
return
|
|
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:
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
/**
|