@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.
@@ -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 ApplicationFee.select()
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 | null, AccountTotals>();
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, totals);
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 ApplicationFee.select()
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 | null;
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 column would be empty
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 one application_fee balance transaction: per non-zero part the payer's negative
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
- if (!paymentId) {
192
- throw new SimpleError({
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
- // Charge metadata is writable by the connected account's owner: the fee is deducted from
199
- // this account, so it can only be about a payment of its own organization
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 = await SettlementService.upsertCharge({
226
- type: chargeType,
227
- externalId: fee.id + ':' + chargeType,
228
- amount: -amount,
229
- applicationFeeId: fee.id,
230
- paymentId,
231
-
232
- // The charge sits in the paying organization's payout
233
- organizationId: payingStripeAccount.organizationId,
234
- stripeAccountId: payingStripeAccount.id,
235
- occurredAt,
236
-
237
- // settlementId (settlement of the paying organization where the costs are deducted): still unknown, will be filled when looping the payouts of the paying organization
238
- });
239
- charges.push(charge);
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
- payingOrganizationId: payingStripeAccount.organizationId,
247
- payingStripeAccountId: payingStripeAccount.id,
248
- payingPaymentId: paymentId,
249
- settlementChargeId: charge.id,
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