@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
|
@@ -84,9 +84,7 @@ export class ApplicationFeeInvoicer {
|
|
|
84
84
|
const snapshot = truncateToSecond(new Date());
|
|
85
85
|
|
|
86
86
|
const currentPeriodStart = SettlementService.getPeriodStart(new Date());
|
|
87
|
-
const oldest = await
|
|
88
|
-
.where('organizationId', sellingOrganization.id)
|
|
89
|
-
.where('balanceItemId', null)
|
|
87
|
+
const oldest = await this.#selectBillableFees(sellingOrganization)
|
|
90
88
|
.where('occurredAt', '<', currentPeriodStart)
|
|
91
89
|
.where('createdAt', '<', snapshot)
|
|
92
90
|
.orderBy('occurredAt', 'ASC')
|
|
@@ -149,15 +147,16 @@ export class ApplicationFeeInvoicer {
|
|
|
149
147
|
const reference = ApplicationFeeInvoicer.reference(periodStart);
|
|
150
148
|
|
|
151
149
|
await QueueHandler.schedule(reference, async () => {
|
|
152
|
-
const totalsPerAccount = new Map<string
|
|
150
|
+
const totalsPerAccount = new Map<string, AccountTotals>();
|
|
153
151
|
|
|
152
|
+
// Both ids are non-null: #selectUninvoicedFees only returns fees that can be billed
|
|
154
153
|
for await (const fee of this.#selectUninvoicedFees(sellingOrganization, periodStart, nextPeriodStart, snapshot).all()) {
|
|
155
|
-
const totals = totalsPerAccount.get(fee.payingStripeAccountId) ?? {
|
|
156
|
-
payingOrganizationId: fee.payingOrganizationId
|
|
154
|
+
const totals = totalsPerAccount.get(fee.payingStripeAccountId!) ?? {
|
|
155
|
+
payingOrganizationId: fee.payingOrganizationId!,
|
|
157
156
|
amountPerType: new Map<ApplicationFeeType, number>(),
|
|
158
157
|
};
|
|
159
158
|
totals.amountPerType.set(fee.type, (totals.amountPerType.get(fee.type) ?? 0) + fee.amount);
|
|
160
|
-
totalsPerAccount.set(fee.payingStripeAccountId
|
|
159
|
+
totalsPerAccount.set(fee.payingStripeAccountId!, totals);
|
|
161
160
|
}
|
|
162
161
|
|
|
163
162
|
for (const [payingStripeAccountId, totals] of totalsPerAccount) {
|
|
@@ -171,19 +170,32 @@ export class ApplicationFeeInvoicer {
|
|
|
171
170
|
});
|
|
172
171
|
}
|
|
173
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A fee this invoicer cannot bill is left out everywhere, or every run would walk its month at
|
|
175
|
+
* Stripe and report it again: without a paying organization there is nobody left to bill, and
|
|
176
|
+
* without its Stripe account a month cannot be checked against what the legacy invoicer billed
|
|
177
|
+
* per account — billing it anyway risks charging it twice. Both are reported by the sync that
|
|
178
|
+
* stored them (StripeSettlementSync.reportUnattributedFee).
|
|
179
|
+
*/
|
|
174
180
|
#selectUninvoicedFees(sellingOrganization: Organization, periodStart: Date, nextPeriodStart: Date, snapshot: Date) {
|
|
175
|
-
return
|
|
176
|
-
.where('organizationId', sellingOrganization.id)
|
|
177
|
-
.where('balanceItemId', null)
|
|
181
|
+
return this.#selectBillableFees(sellingOrganization)
|
|
178
182
|
.where('occurredAt', '>=', periodStart)
|
|
179
183
|
.where('occurredAt', '<', nextPeriodStart)
|
|
180
184
|
.where('createdAt', '<', snapshot)
|
|
181
185
|
.limit(FEE_BATCH_SIZE);
|
|
182
186
|
}
|
|
183
187
|
|
|
188
|
+
#selectBillableFees(sellingOrganization: Organization) {
|
|
189
|
+
return ApplicationFee.select()
|
|
190
|
+
.where('organizationId', sellingOrganization.id)
|
|
191
|
+
.where('balanceItemId', null)
|
|
192
|
+
.where('payingOrganizationId', '!=', null)
|
|
193
|
+
.where('payingStripeAccountId', '!=', null);
|
|
194
|
+
}
|
|
195
|
+
|
|
184
196
|
async #invoiceGroup({ sellingOrganization, payingStripeAccountId, totals, periodStart, nextPeriodStart, snapshot }: {
|
|
185
197
|
sellingOrganization: Organization;
|
|
186
|
-
payingStripeAccountId: string
|
|
198
|
+
payingStripeAccountId: string;
|
|
187
199
|
totals: AccountTotals;
|
|
188
200
|
periodStart: Date;
|
|
189
201
|
nextPeriodStart: Date;
|
|
@@ -194,13 +206,6 @@ export class ApplicationFeeInvoicer {
|
|
|
194
206
|
return;
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
if (!payingStripeAccountId) {
|
|
198
|
-
throw new SimpleError({
|
|
199
|
-
code: 'missing_stripe_account',
|
|
200
|
-
message: 'Uninvoiced application fees without a Stripe account',
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
209
|
const stripeAccount = await StripeAccount.getByID(payingStripeAccountId);
|
|
205
210
|
if (!stripeAccount) {
|
|
206
211
|
throw new SimpleError({
|
|
@@ -363,4 +363,26 @@ describe('SettlementExporter', () => {
|
|
|
363
363
|
expect((await exporter.getProviderInvoiceStatus(totals)).check).toBe('');
|
|
364
364
|
});
|
|
365
365
|
});
|
|
366
|
+
|
|
367
|
+
describe('getSettlementCheck', () => {
|
|
368
|
+
const check = (settlement: { amount: number; pendingFees?: number; uncollectibleFees?: number }, rows: { linesTotal: number; chargesTotal: number }) => {
|
|
369
|
+
return SettlementExporter.getSettlementCheck({ pendingFees: 0, uncollectibleFees: 0, ...settlement } as Settlement, rows);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
test('a payout its rows fully explain is approved', () => {
|
|
373
|
+
expect(check({ amount: 49_70_00 }, { linesTotal: 50_00_00, chargesTotal: -30_00 })).toBe('✓');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('a difference the stored rows do not cover is missing data', () => {
|
|
377
|
+
expect(check({ amount: 49_70_00 }, { linesTotal: 50_00_00, chargesTotal: 0 })).toBe('Ontbrekende gegevens');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('fees still waiting for their invoice explain the rest, and say so', () => {
|
|
381
|
+
expect(check({ amount: 50_00_00, pendingFees: 30_00 }, { linesTotal: 49_70_00, chargesTotal: 0 })).toBe('Kosten nog niet gefactureerd');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('fees that will never be invoiced explain the rest without waiting for one', () => {
|
|
385
|
+
expect(check({ amount: 50_00_00, uncollectibleFees: 30_00 }, { linesTotal: 49_70_00, chargesTotal: 0 })).toBe('✓');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
366
388
|
});
|
|
@@ -115,6 +115,12 @@ export class SettlementExporter {
|
|
|
115
115
|
*/
|
|
116
116
|
private hasPendingFees = false;
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Same for fees of organizations that no longer exist: they are only ever received by the
|
|
120
|
+
* platform, and even there they are the exception.
|
|
121
|
+
*/
|
|
122
|
+
private hasUncollectibleFees = false;
|
|
123
|
+
|
|
118
124
|
constructor({ start, end, provider, organization, sellingOrganization }: { start: Date; end: Date; provider?: PaymentProvider | null; organization: Organization; sellingOrganization: Organization }) {
|
|
119
125
|
this.start = start;
|
|
120
126
|
this.end = end;
|
|
@@ -186,8 +192,9 @@ export class SettlementExporter {
|
|
|
186
192
|
settlements.sort((a, b) => a.settledAt.getTime() - b.settledAt.getTime() || a.externalId.localeCompare(b.externalId));
|
|
187
193
|
|
|
188
194
|
// Only the organization that charges application fees ever receives any, so for everyone
|
|
189
|
-
// else the
|
|
195
|
+
// else the columns would be empty
|
|
190
196
|
this.hasPendingFees = settlements.some(settlement => settlement.pendingFees !== 0);
|
|
197
|
+
this.hasUncollectibleFees = settlements.some(settlement => settlement.uncollectibleFees !== 0);
|
|
191
198
|
|
|
192
199
|
await writer.addRow(sheets.settlementsSheet, [
|
|
193
200
|
textCell('Provider', 10),
|
|
@@ -202,6 +209,7 @@ export class SettlementExporter {
|
|
|
202
209
|
textCell('Transacties', 11),
|
|
203
210
|
textCell('Onverklaard', 13),
|
|
204
211
|
...(this.hasPendingFees ? [textCell('Niet-gefactureerde kosten', 22)] : []),
|
|
212
|
+
...(this.hasUncollectibleFees ? [textCell('Niet-aanrekenbare kosten', 22)] : []),
|
|
205
213
|
textCell('Check', 20),
|
|
206
214
|
]);
|
|
207
215
|
|
|
@@ -241,10 +249,11 @@ export class SettlementExporter {
|
|
|
241
249
|
* The verdict of one payout: what it paid out has to be explained by its payments and its
|
|
242
250
|
* costs. Fees we received but haven't invoiced yet have no payment line of their own, so they
|
|
243
251
|
* explain their part of the difference until the invoicer creates one — that is missing data,
|
|
244
|
-
* not a mismatch, and it says so.
|
|
252
|
+
* not a mismatch, and it says so. Fees of organizations that no longer exist explain their part
|
|
253
|
+
* the same way, but never get a payment line: they are not waiting for anything.
|
|
245
254
|
*/
|
|
246
255
|
static getSettlementCheck(settlement: Settlement, { linesTotal, chargesTotal }: { linesTotal: number; chargesTotal: number }): string {
|
|
247
|
-
if (settlement.amount - linesTotal - chargesTotal - settlement.pendingFees !== 0) {
|
|
256
|
+
if (settlement.amount - linesTotal - chargesTotal - settlement.pendingFees - settlement.uncollectibleFees !== 0) {
|
|
248
257
|
return 'Ontbrekende gegevens';
|
|
249
258
|
}
|
|
250
259
|
if (settlement.pendingFees !== 0) {
|
|
@@ -270,6 +279,7 @@ export class SettlementExporter {
|
|
|
270
279
|
{ value: settlement.transactionCount },
|
|
271
280
|
currencyCell(settlement.unexplainedAmount),
|
|
272
281
|
...(this.hasPendingFees ? [currencyCell(settlement.pendingFees)] : []),
|
|
282
|
+
...(this.hasUncollectibleFees ? [currencyCell(settlement.uncollectibleFees)] : []),
|
|
273
283
|
textCell(SettlementExporter.getSettlementCheck(settlement, { linesTotal, chargesTotal })),
|
|
274
284
|
]);
|
|
275
285
|
}
|
|
@@ -9,6 +9,7 @@ import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@sta
|
|
|
9
9
|
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
10
10
|
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
11
11
|
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
12
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
12
13
|
|
|
13
14
|
import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
|
|
14
15
|
import type { StripeObject } from '../../tests/helpers/StripeMocker.js';
|
|
@@ -45,6 +46,7 @@ describe('StripeSettlementSync', () => {
|
|
|
45
46
|
|
|
46
47
|
beforeEach(() => {
|
|
47
48
|
stripeMocker.clear();
|
|
49
|
+
StripeSettlementSync.resetWarnings();
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
const createSync = () => new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
|
|
@@ -610,6 +612,173 @@ describe('StripeSettlementSync', () => {
|
|
|
610
612
|
expect(after.pendingFees).toBe(0);
|
|
611
613
|
});
|
|
612
614
|
|
|
615
|
+
describe('Payers we can no longer reach', () => {
|
|
616
|
+
/**
|
|
617
|
+
* A platform payout that only receives one application fee, plus the payout transaction.
|
|
618
|
+
* The metadata defaults to a payment that no longer exists.
|
|
619
|
+
*/
|
|
620
|
+
const createFeePayout = (account: string, metadata: Record<string, string> = {}) => {
|
|
621
|
+
const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
|
|
622
|
+
const fee = stripeMocker.createApplicationFee({
|
|
623
|
+
amount: 250,
|
|
624
|
+
account,
|
|
625
|
+
originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: uuidv4(), serviceFee: '30', ...metadata } }),
|
|
626
|
+
});
|
|
627
|
+
stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, payout: payout.id, source: fee });
|
|
628
|
+
stripeMocker.createBalanceTransaction({ type: 'payout', amount: -250, created, payout: payout.id, source: null });
|
|
629
|
+
return { payout, fee };
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
test('a fee of an organization that no longer exists is stored as income without a payer', async () => {
|
|
633
|
+
const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { organization: uuidv4() });
|
|
634
|
+
|
|
635
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
636
|
+
|
|
637
|
+
const settlement = await getSettlement(payout);
|
|
638
|
+
const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
|
|
639
|
+
expect(fees).toHaveLength(2);
|
|
640
|
+
for (const row of fees) {
|
|
641
|
+
expect(row.organizationId).toBe(membershipOrganization.id);
|
|
642
|
+
expect(row.settlementId).toBe(settlement.id);
|
|
643
|
+
expect(row.payingOrganizationId).toBeNull();
|
|
644
|
+
expect(row.payingStripeAccountId).toBeNull();
|
|
645
|
+
expect(row.payingPaymentId).toBeNull();
|
|
646
|
+
expect(row.settlementChargeId).toBeNull();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// There is no payout of theirs left to deduct it from, but ours still adds up
|
|
650
|
+
expect(await SettlementCharge.select().where('applicationFeeId', fee.id).count()).toBe(0);
|
|
651
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
652
|
+
expect(settlement.pendingFees).toBe(0);
|
|
653
|
+
expect(settlement.uncollectibleFees).toBe(2_50_00);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('a fee of a Stripe account we no longer have keeps the payer of its payment', async () => {
|
|
657
|
+
const payment = await createPayment();
|
|
658
|
+
const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { payment: payment.id });
|
|
659
|
+
|
|
660
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
661
|
+
|
|
662
|
+
// The cost is still attributed to the organization, so its own export shows it
|
|
663
|
+
const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
|
|
664
|
+
expect(charges).toHaveLength(2);
|
|
665
|
+
for (const charge of charges) {
|
|
666
|
+
expect(charge.organizationId).toBe(organization.id);
|
|
667
|
+
expect(charge.stripeAccountId).toBeNull();
|
|
668
|
+
expect(charge.paymentId).toBe(payment.id);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
|
|
672
|
+
for (const row of fees) {
|
|
673
|
+
expect(row.payingOrganizationId).toBe(organization.id);
|
|
674
|
+
expect(row.payingStripeAccountId).toBeNull();
|
|
675
|
+
expect(row.payingPaymentId).toBe(payment.id);
|
|
676
|
+
expect(row.settlementChargeId).not.toBeNull();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Without the account it was deducted from, the invoicer can't bill it per account
|
|
680
|
+
const settlement = await getSettlement(payout);
|
|
681
|
+
expect(settlement.pendingFees).toBe(0);
|
|
682
|
+
expect(settlement.uncollectibleFees).toBe(2_50_00);
|
|
683
|
+
expect(settlement.unexplainedAmount).toBe(0);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('a fee whose payment is gone falls back to the organization in our charge metadata', async () => {
|
|
687
|
+
const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { organization: organization.id });
|
|
688
|
+
|
|
689
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
690
|
+
|
|
691
|
+
const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
|
|
692
|
+
expect(charges).toHaveLength(2);
|
|
693
|
+
for (const charge of charges) {
|
|
694
|
+
expect(charge.organizationId).toBe(organization.id);
|
|
695
|
+
expect(charge.paymentId).toBeNull();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
|
|
699
|
+
for (const row of fees) {
|
|
700
|
+
expect(row.payingOrganizationId).toBe(organization.id);
|
|
701
|
+
expect(row.payingPaymentId).toBeNull();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
expect((await getSettlement(payout)).uncollectibleFees).toBe(2_50_00);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('every unattributable account is reported once, not once per fee', async () => {
|
|
708
|
+
const unknownAccount = stripeMocker.createId('acct');
|
|
709
|
+
createFeePayout(unknownAccount, { organization: uuidv4() });
|
|
710
|
+
createFeePayout(unknownAccount, { organization: uuidv4() });
|
|
711
|
+
|
|
712
|
+
await WebmasterReport.group('Onaanrekenbare applicatiekosten', async () => {
|
|
713
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 2, skipped: 0, failed: 0 });
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Onaanrekenbare applicatiekosten'));
|
|
717
|
+
expect(emails).toHaveLength(1);
|
|
718
|
+
expect(emails[0].html).toContain(unknownAccount);
|
|
719
|
+
expect(emails[0].subject).toContain('1 probleem');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('a deleted Stripe account keeps its organization, its account and its payment', async () => {
|
|
723
|
+
const deletedOrganization = await new OrganizationFactory({}).create();
|
|
724
|
+
const deletedAccount = await stripeMocker.createStripeAccount(deletedOrganization.id);
|
|
725
|
+
deletedAccount.status = 'deleted';
|
|
726
|
+
await deletedAccount.save();
|
|
727
|
+
|
|
728
|
+
// Deleting a Stripe account is a soft delete: the organization and its payments stay
|
|
729
|
+
const payment = new Payment();
|
|
730
|
+
payment.organizationId = deletedOrganization.id;
|
|
731
|
+
payment.stripeAccountId = deletedAccount.id;
|
|
732
|
+
payment.method = PaymentMethod.Bancontact;
|
|
733
|
+
payment.provider = PaymentProvider.Stripe;
|
|
734
|
+
payment.status = PaymentStatus.Succeeded;
|
|
735
|
+
payment.type = PaymentType.Payment;
|
|
736
|
+
payment.price = 100_00_00;
|
|
737
|
+
payment.paidAt = created;
|
|
738
|
+
await payment.save();
|
|
739
|
+
|
|
740
|
+
const { payout, fee } = createFeePayout(deletedAccount.accountId, { payment: payment.id });
|
|
741
|
+
|
|
742
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
|
|
743
|
+
|
|
744
|
+
const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
|
|
745
|
+
expect(charges).toHaveLength(2);
|
|
746
|
+
for (const charge of charges) {
|
|
747
|
+
expect(charge.organizationId).toBe(deletedOrganization.id);
|
|
748
|
+
expect(charge.stripeAccountId).toBe(deletedAccount.id);
|
|
749
|
+
expect(charge.paymentId).toBe(payment.id);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
|
|
753
|
+
for (const row of fees) {
|
|
754
|
+
expect(row.payingOrganizationId).toBe(deletedOrganization.id);
|
|
755
|
+
expect(row.payingStripeAccountId).toBe(deletedAccount.id);
|
|
756
|
+
expect(row.payingPaymentId).toBe(payment.id);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Nothing was lost, so it is billed like any other month
|
|
760
|
+
const settlement = await getSettlement(payout);
|
|
761
|
+
expect(settlement.pendingFees).toBe(2_50_00);
|
|
762
|
+
expect(settlement.uncollectibleFees).toBe(0);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('an account still in our database keeps failing the payout on an unresolvable payment', async () => {
|
|
766
|
+
const deletedAccount = await stripeMocker.createStripeAccount(organization.id);
|
|
767
|
+
deletedAccount.status = 'deleted';
|
|
768
|
+
await deletedAccount.save();
|
|
769
|
+
|
|
770
|
+
// A deleted account is no reason to stop checking: an organization may not attach
|
|
771
|
+
// another organization's payment to its fees by deleting its Stripe account first
|
|
772
|
+
for (const account of [stripeAccount, deletedAccount]) {
|
|
773
|
+
const { payout } = createFeePayout(account.accountId);
|
|
774
|
+
|
|
775
|
+
expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
|
|
776
|
+
expect((await getSettlement(payout)).syncedAt).toBeNull();
|
|
777
|
+
stripeMocker.clear();
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
613
782
|
describe('Connected accounts', () => {
|
|
614
783
|
test('an organization payout writes the mirrored deduction rows, summing to zero against the Received rows', async () => {
|
|
615
784
|
const payment = await createPayment();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
-
import { Payment, StripeAccount } from '@stamhoofd/models';
|
|
2
|
+
import { Organization, Payment, StripeAccount } from '@stamhoofd/models';
|
|
3
3
|
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
4
4
|
import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
|
|
5
5
|
import type { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
@@ -17,6 +17,27 @@ import { passthroughFetch } from './passthroughFetch.js';
|
|
|
17
17
|
import { getPaymentIdForStripeCharge } from './getPaymentIdForStripeCharge.js';
|
|
18
18
|
import { WebmasterReport } from './WebmasterReport.js';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Who paid an application fee: normally the organization of the Stripe account it was deducted
|
|
22
|
+
* from, and otherwise the organization our own charge metadata names.
|
|
23
|
+
*/
|
|
24
|
+
type ApplicationFeePayer = {
|
|
25
|
+
organizationId: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* NULL when we no longer have the stripe_accounts row the fee was deducted from.
|
|
29
|
+
*/
|
|
30
|
+
stripeAccountId: string | null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether our records of this payer are still complete. Only false when its stripe_accounts
|
|
34
|
+
* row is gone, which means its organization was deleted: what can't be resolved anymore is
|
|
35
|
+
* then the consequence of that deletion, not a problem to repair. A deleted account keeps its
|
|
36
|
+
* row, and its organization keeps its payments, so those stay strictly checked.
|
|
37
|
+
*/
|
|
38
|
+
intact: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
20
41
|
/**
|
|
21
42
|
* Walks paid payouts and stores every balance transaction in them: payments become
|
|
22
43
|
* payment_settlements rows, everything else becomes settlement_charges rows. One walker for both
|
|
@@ -37,6 +58,38 @@ export class StripeSettlementSync {
|
|
|
37
58
|
*/
|
|
38
59
|
private fetchedCharges = new Map<string, Stripe.Charge>();
|
|
39
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Stripe accounts already reported as unattributable, so a month of their fees is one problem
|
|
63
|
+
* instead of thousands.
|
|
64
|
+
*/
|
|
65
|
+
static #reportedUnattributedAccounts = new Set<string>();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* For tests only.
|
|
69
|
+
*/
|
|
70
|
+
static resetWarnings() {
|
|
71
|
+
this.#reportedUnattributedAccounts.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A fee we can't fully attribute is stored anyway (it is our income), so it would otherwise
|
|
76
|
+
* only exist as a number nobody looks at: the invoicer skips it, and no payout of the payer
|
|
77
|
+
* links it. Reported once per account, so someone decides whether to repair or write it off.
|
|
78
|
+
*/
|
|
79
|
+
static reportUnattributedFee(payingAccountId: string, payer: ApplicationFeePayer | null) {
|
|
80
|
+
if (this.#reportedUnattributedAccounts.has(payingAccountId)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.#reportedUnattributedAccounts.add(payingAccountId);
|
|
84
|
+
|
|
85
|
+
WebmasterReport.report(
|
|
86
|
+
'Applicatiekosten van Stripe account ' + payingAccountId + ' worden niet aangerekend',
|
|
87
|
+
payer
|
|
88
|
+
? 'Dat account staat niet meer in onze database. De kosten zijn wel opgeslagen op vereniging ' + payer.organizationId + ', maar worden niet automatisch gefactureerd.'
|
|
89
|
+
: 'Dat account en de vereniging erachter staan niet meer in onze database. De kosten zijn opgeslagen als niet-aanrekenbare inkomsten.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
40
93
|
constructor({ secretKey, stripeAccount }: { secretKey: string; stripeAccount?: StripeAccount | null }) {
|
|
41
94
|
this.stripeAccount = stripeAccount ?? null;
|
|
42
95
|
|
|
@@ -155,25 +208,12 @@ export class StripeSettlementSync {
|
|
|
155
208
|
}
|
|
156
209
|
|
|
157
210
|
/**
|
|
158
|
-
* Stores
|
|
159
|
-
* deduction charge (its settlement link belongs to the payer's own payout walk) and the
|
|
160
|
-
* application fee row. The platform payout walk passes `settlementId` to link the fee rows to
|
|
161
|
-
* the payout that contains them. Throws instead of writing a guessed row: missing serviceFee
|
|
162
|
-
* metadata, an unknown Stripe account or an unresolvable payment all mean someone has to look
|
|
163
|
-
* at it first.
|
|
211
|
+
* Stores the SettlementCharge for the connected account (costs) and ApplicationFee for the platform account (revenue), related to an application fee in Stripe.
|
|
164
212
|
*/
|
|
165
213
|
async #handleApplicationFee(transaction: Stripe.BalanceTransaction, options: { settlementId?: string } = {}): Promise<{ fees: ApplicationFee[]; charges: SettlementCharge[] }> {
|
|
166
214
|
const fee = transaction.source as Stripe.ApplicationFee;
|
|
167
215
|
const payingAccountId = typeof fee.account === 'string' ? fee.account : fee.account.id;
|
|
168
216
|
|
|
169
|
-
const payingStripeAccount = await StripeAccount.select().where('accountId', payingAccountId).first(false);
|
|
170
|
-
if (!payingStripeAccount) {
|
|
171
|
-
throw new SimpleError({
|
|
172
|
-
code: 'stripe_account_not_found',
|
|
173
|
-
message: 'No Stripe account found for ' + payingAccountId,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
217
|
const originatingTransaction = fee.originating_transaction;
|
|
178
218
|
if (!originatingTransaction || typeof originatingTransaction === 'string') {
|
|
179
219
|
throw new SimpleError({
|
|
@@ -181,28 +221,18 @@ export class StripeSettlementSync {
|
|
|
181
221
|
message: 'Application fee ' + fee.id + ' has no expanded originating transaction',
|
|
182
222
|
});
|
|
183
223
|
}
|
|
184
|
-
|
|
224
|
+
const originatingCharge = originatingTransaction as Stripe.Charge;
|
|
185
225
|
const details = ApplicationFeeDetails.fromStripe(transaction);
|
|
186
|
-
|
|
187
|
-
const paymentId = await getPaymentIdForStripeCharge(originatingTransaction as Stripe.Charge, {
|
|
226
|
+
const resolvedPaymentId = await getPaymentIdForStripeCharge(originatingCharge, {
|
|
188
227
|
stripePlatform: this.stripePlatform,
|
|
189
228
|
});
|
|
229
|
+
const resolvedPayment = (resolvedPaymentId ? await Payment.getByID(resolvedPaymentId) : null) ?? null;
|
|
190
230
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
code: 'payment_not_found',
|
|
194
|
-
message: 'No payment found for application fee ' + fee.id,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
231
|
+
const payer = await this.#resolveApplicationFeePayer(payingAccountId, originatingCharge, resolvedPayment);
|
|
232
|
+
const paymentId = this.#getApplicationFeePaymentId(fee, payer, { paymentId: resolvedPaymentId, payment: resolvedPayment });
|
|
197
233
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const payment = await Payment.getByID(paymentId);
|
|
201
|
-
if (!payment || payment.organizationId !== payingStripeAccount.organizationId) {
|
|
202
|
-
throw new SimpleError({
|
|
203
|
-
code: 'payment_scope_mismatch',
|
|
204
|
-
message: 'Payment ' + paymentId + ' of application fee ' + fee.id + ' does not belong to organization ' + payingStripeAccount.organizationId,
|
|
205
|
-
});
|
|
234
|
+
if (!payer?.stripeAccountId) {
|
|
235
|
+
StripeSettlementSync.reportUnattributedFee(payingAccountId, payer);
|
|
206
236
|
}
|
|
207
237
|
|
|
208
238
|
const occurredAt = new Date(transaction.created * 1000);
|
|
@@ -222,39 +252,115 @@ export class StripeSettlementSync {
|
|
|
222
252
|
continue;
|
|
223
253
|
}
|
|
224
254
|
|
|
225
|
-
const charge =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
255
|
+
const charge = payer
|
|
256
|
+
? await SettlementService.upsertCharge({
|
|
257
|
+
type: chargeType,
|
|
258
|
+
externalId: fee.id + ':' + chargeType,
|
|
259
|
+
amount: -amount,
|
|
260
|
+
applicationFeeId: fee.id,
|
|
261
|
+
|
|
262
|
+
// Unresolvable stays undefined: a re-sync may not clear earlier stored links
|
|
263
|
+
paymentId: paymentId ?? undefined,
|
|
264
|
+
organizationId: payer.organizationId ?? undefined,
|
|
265
|
+
stripeAccountId: payer.stripeAccountId ?? undefined,
|
|
266
|
+
occurredAt,
|
|
267
|
+
|
|
268
|
+
// settlementId (settlement of the paying organization where the costs are deducted): still unknown, will be filled when looping the payouts of the paying organization
|
|
269
|
+
})
|
|
270
|
+
: null;
|
|
271
|
+
|
|
272
|
+
if (charge) {
|
|
273
|
+
charges.push(charge);
|
|
274
|
+
}
|
|
240
275
|
|
|
241
276
|
fees.push(await ApplicationFeeService.upsertFee({
|
|
242
277
|
externalId: fee.id,
|
|
243
278
|
type: feeType,
|
|
244
279
|
amount,
|
|
245
280
|
organizationId: receivingOrganizationId,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
281
|
+
|
|
282
|
+
// Unresolvable stays undefined: a re-sync may not clear earlier stored links
|
|
283
|
+
payingOrganizationId: payer?.organizationId ?? undefined,
|
|
284
|
+
payingStripeAccountId: payer?.stripeAccountId ?? undefined,
|
|
285
|
+
payingPaymentId: paymentId ?? undefined,
|
|
286
|
+
settlementChargeId: charge?.id ?? undefined,
|
|
287
|
+
settlementId: options.settlementId,
|
|
250
288
|
occurredAt,
|
|
251
|
-
...(options.settlementId !== undefined ? { settlementId: options.settlementId } : {}),
|
|
252
289
|
}));
|
|
253
290
|
}
|
|
254
291
|
|
|
255
292
|
return { fees, charges };
|
|
256
293
|
}
|
|
257
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The organization an application fee was deducted from. Its Stripe account is the first
|
|
297
|
+
* source. A deleted organization takes its stripe_accounts row with it, and accounts deleted
|
|
298
|
+
* before we started keeping deleted ones are gone too; what is left of the payment then still
|
|
299
|
+
* names the organization: our own payment row first, and otherwise the metadata we wrote on the
|
|
300
|
+
* charge. Only a destination charge has an originating transaction, and that charge sits on our
|
|
301
|
+
* platform account, so the connected account could not have changed either.
|
|
302
|
+
*
|
|
303
|
+
* NULL when even that organization no longer exists: the fee is then income without a payer.
|
|
304
|
+
*/
|
|
305
|
+
async #resolveApplicationFeePayer(payingAccountId: string, originatingCharge: Stripe.Charge, payment: Payment | null): Promise<ApplicationFeePayer | null> {
|
|
306
|
+
const stripeAccount = await StripeAccount.select().where('accountId', payingAccountId).first(false);
|
|
307
|
+
if (stripeAccount) {
|
|
308
|
+
return {
|
|
309
|
+
organizationId: stripeAccount.organizationId,
|
|
310
|
+
stripeAccountId: stripeAccount.id,
|
|
311
|
+
intact: true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (payment?.organizationId) {
|
|
316
|
+
return { organizationId: payment.organizationId, stripeAccountId: null, intact: false };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const organizationId = originatingCharge.metadata?.organization;
|
|
320
|
+
if (!organizationId || !await Organization.getByID(organizationId)) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { organizationId, stripeAccountId: null, intact: false };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The payer's payment an application fee was charged on. Charge metadata is writable by the
|
|
329
|
+
* connected account's owner: the fee is deducted from this account, so it can only be about a
|
|
330
|
+
* payment of its own organization.
|
|
331
|
+
*
|
|
332
|
+
* A payer whose records are no longer intact keeps whatever still resolves: its payments may
|
|
333
|
+
* have been deleted with its account, so a missing one is expected instead of something to
|
|
334
|
+
* repair.
|
|
335
|
+
*/
|
|
336
|
+
#getApplicationFeePaymentId(fee: Stripe.ApplicationFee, payer: ApplicationFeePayer | null, { paymentId, payment }: { paymentId: string | null; payment: Payment | null }): string | null {
|
|
337
|
+
if (!payer) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!paymentId) {
|
|
342
|
+
if (!payer.intact) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
throw new SimpleError({
|
|
346
|
+
code: 'payment_not_found',
|
|
347
|
+
message: 'No payment found for application fee ' + fee.id,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!payment || payment.organizationId !== payer.organizationId) {
|
|
352
|
+
if (!payer.intact) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
throw new SimpleError({
|
|
356
|
+
code: 'payment_scope_mismatch',
|
|
357
|
+
message: 'Payment ' + paymentId + ' of application fee ' + fee.id + ' does not belong to organization ' + payer.organizationId,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return paymentId;
|
|
362
|
+
}
|
|
363
|
+
|
|
258
364
|
/**
|
|
259
365
|
* The organization that owns the walked account: the connected account's organization, or the
|
|
260
366
|
* platform membership organization for our own platform account (resolved once per instance).
|
|
@@ -103,6 +103,14 @@ export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner
|
|
|
103
103
|
totals.skipped += result.skipped;
|
|
104
104
|
totals.failed += result.failed;
|
|
105
105
|
} catch (e) {
|
|
106
|
+
if (e !== null && typeof e === 'object' && 'type' in e && e.type === 'StripePermissionError' && e.message.includes(account.accountId) && e.message.includes('does not have access to account')) {
|
|
107
|
+
// Stripe account no longer in active use
|
|
108
|
+
console.error(e, 'marking stripe account', account.id, account.accountId, 'as inaccessible because we do not seem to have access to it any longer');
|
|
109
|
+
account.status = 'inaccessible';
|
|
110
|
+
await account.save();
|
|
111
|
+
totals.skipped += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
106
114
|
console.error('Failed to sync payouts of Stripe account ' + account.accountId, e);
|
|
107
115
|
totals.failed += 1;
|
|
108
116
|
|