@stamhoofd/backend 2.141.0 → 2.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +17 -17
  2. package/src/boot.ts +31 -16
  3. package/src/crons/settlement-sync.test.ts +59 -1
  4. package/src/crons/settlement-sync.ts +20 -9
  5. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  6. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  7. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  8. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  9. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +84 -0
  10. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +12 -11
  11. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +6 -0
  12. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  13. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -0
  14. package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
  15. package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
  16. package/src/helpers/MollieSettlementSync.test.ts +43 -0
  17. package/src/helpers/MollieSettlementSync.ts +29 -4
  18. package/src/helpers/MollieSettlementSyncRunner.ts +10 -3
  19. package/src/helpers/ProviderSettlementSyncRunner.ts +8 -0
  20. package/src/helpers/SettlementExporter.test.ts +22 -0
  21. package/src/helpers/SettlementExporter.ts +13 -3
  22. package/src/helpers/SettlementSyncRunner.test.ts +53 -0
  23. package/src/helpers/SettlementSyncRunner.ts +20 -3
  24. package/src/helpers/StripeSettlementSync.test.ts +375 -1
  25. package/src/helpers/StripeSettlementSync.ts +264 -113
  26. package/src/helpers/StripeSettlementSyncRunner.test.ts +18 -0
  27. package/src/helpers/StripeSettlementSyncRunner.ts +34 -9
  28. package/src/helpers/waitUntilDeadline.test.ts +48 -0
  29. package/src/helpers/waitUntilDeadline.ts +28 -0
  30. package/src/services/ApplicationFeeService.ts +47 -11
  31. package/src/services/BalanceItemService.ts +5 -0
  32. package/src/services/SettlementService.test.ts +82 -9
  33. package/src/services/SettlementService.ts +72 -6
  34. package/src/services/WebshopCrowdfundingService.test.ts +433 -0
  35. package/src/services/WebshopCrowdfundingService.ts +98 -0
  36. package/tests/filters/orders.test.ts +24 -1
  37. package/tests/vitest.setup.ts +5 -2
@@ -1,3 +1,5 @@
1
+ import type { AbortSignal } from '@stamhoofd/queues';
2
+
1
3
  export type SettlementSyncSummary = {
2
4
  feeMonths: number;
3
5
  failedFeeMonths: number;
@@ -26,6 +28,12 @@ export type ProviderSyncRunOptions = {
26
28
  * Fired after each unit of work (a month for Stripe, an account for Mollie).
27
29
  */
28
30
  onProgress?: () => void;
31
+
32
+ /**
33
+ * Stops the walk at the next safe point (a restart). Aborting is not a sync failure: it leaves
34
+ * everything it touched retryable instead of counting or reporting it.
35
+ */
36
+ abort: AbortSignal;
29
37
  };
30
38
 
31
39
  /**
@@ -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
  }
@@ -1,7 +1,9 @@
1
1
  import type { Organization } from '@stamhoofd/models';
2
2
  import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
3
3
  import { Settlement } from '@stamhoofd/models/models/Settlement.js';
4
+ import { AbortSignal } from '@stamhoofd/queues';
4
5
  import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
6
+ import { STExpect } from '@stamhoofd/test-utils';
5
7
  import { v4 as uuidv4 } from 'uuid';
6
8
  import { vi } from 'vitest';
7
9
 
@@ -140,6 +142,57 @@ describe('Helper.SettlementSyncRunner', () => {
140
142
  expect(mollieRow.syncedAt).not.toBeNull();
141
143
  });
142
144
 
145
+ test('An aborted run walks nothing and does not report a failure', async () => {
146
+ const organization = await new OrganizationFactory({}).create();
147
+ const stripePayment = await createStripePayment(organization);
148
+
149
+ const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
150
+ stripeMocker.createBalanceTransaction({
151
+ type: 'charge',
152
+ amount: 10000,
153
+ created: new Date(2026, 0, 15),
154
+ payout: payout.id,
155
+ source: stripeMocker.createChargeObject({ metadata: { payment: stripePayment.id } }),
156
+ });
157
+
158
+ const abort = new AbortSignal();
159
+ abort.abort();
160
+
161
+ // Throwing instead of returning a summary: a caller may not read an interrupted run as a
162
+ // completed one
163
+ await expect(new SettlementSyncRunner().run({
164
+ start: new Date(2026, 0, 1),
165
+ end: new Date(2026, 0, 31),
166
+ providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
167
+ abort,
168
+ })).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
169
+
170
+ expect(await Settlement.select().where('externalId', payout.id).first(false)).toBeNull();
171
+ });
172
+
173
+ test('An aborted run walks nothing of a provider Stripe never reached', async () => {
174
+ const mollieOrganization = await new OrganizationFactory({}).create();
175
+ await mollieMocker.setupToken(mollieOrganization);
176
+ const { mockPayment } = await createMolliePayment(mollieOrganization);
177
+ const mollieSettlement = mollieMocker.createSettlement({
178
+ payments: [mockPayment],
179
+ value: '50.00',
180
+ settledAt: new Date(2026, 0, 20),
181
+ });
182
+
183
+ const abort = new AbortSignal();
184
+ abort.abort();
185
+
186
+ await expect(new SettlementSyncRunner().run({
187
+ start: new Date(2026, 0, 1),
188
+ end: new Date(2026, 0, 31),
189
+ providers: [PaymentProvider.Mollie],
190
+ abort,
191
+ })).rejects.toThrow(STExpect.simpleError({ code: 'queue-aborted' }));
192
+
193
+ expect(await Settlement.select().where('externalId', mollieSettlement.id).first(false)).toBeNull();
194
+ });
195
+
143
196
  test('The stripe retryUnsynced option flows through the run', async () => {
144
197
  const organization = await new OrganizationFactory({}).create();
145
198
  const settlement = await SettlementService.upsertSettlement({
@@ -1,3 +1,4 @@
1
+ import { AbortSignal } from '@stamhoofd/queues';
1
2
  import { PaymentProvider } from '@stamhoofd/structures';
2
3
 
3
4
  import { MollieSettlementSyncRunner } from './MollieSettlementSyncRunner.js';
@@ -19,11 +20,17 @@ export class SettlementSyncRunner {
19
20
  */
20
21
  callback: ((summary: SettlementSyncSummary) => void) | null = null;
21
22
 
22
- async run({ start = new Date(2025, 0, 1), end, providers, stripe }: {
23
+ async run({ start = new Date(2025, 0, 1), end, providers, stripe, abort = new AbortSignal() }: {
23
24
  start?: Date;
24
25
  end?: Date | null;
25
26
  providers?: PaymentProvider[] | null;
26
27
  stripe?: StripeSyncOptions;
28
+
29
+ /**
30
+ * Stops the run at the next safe point, and throws what it was aborted with: a caller that
31
+ * treats a completed run as "done for today" may not mistake an interrupted one for it.
32
+ */
33
+ abort?: AbortSignal;
27
34
  } = {}): Promise<SettlementSyncSummary> {
28
35
  const summary: SettlementSyncSummary = { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
29
36
  const rangeEnd = end ?? new Date();
@@ -41,8 +48,12 @@ export class SettlementSyncRunner {
41
48
  if (includeStripe && STAMHOOFD.STRIPE_SECRET_KEY) {
42
49
  const runner = new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY, ...stripe });
43
50
  try {
44
- await runner.run({ start, end: rangeEnd, summary, onProgress });
51
+ await runner.run({ start, end: rangeEnd, summary, onProgress, abort });
45
52
  } catch (e) {
53
+ // An interrupted provider stops the whole run: it didn't fail, and the
54
+ // providers after it would only be interrupted at their first step too
55
+ abort.throwIfAborted();
56
+
46
57
  console.error('Stripe settlement sync failed', e);
47
58
  summary.failed += 1;
48
59
  }
@@ -51,13 +62,19 @@ export class SettlementSyncRunner {
51
62
  if (includeMollie) {
52
63
  const runner = new MollieSettlementSyncRunner();
53
64
  try {
54
- await runner.run({ start, end: rangeEnd, summary, onProgress });
65
+ await runner.run({ start, end: rangeEnd, summary, onProgress, abort });
55
66
  } catch (e) {
67
+ abort.throwIfAborted();
68
+
56
69
  console.error('Mollie settlement sync failed', e);
57
70
  summary.failed += 1;
58
71
  }
59
72
  }
60
73
 
74
+ // A provider that ignores the signal (or a run without providers) may not hand back a
75
+ // summary that reads as a completed run
76
+ abort.throwIfAborted();
77
+
61
78
  return summary;
62
79
  });
63
80
  }
@@ -2,16 +2,25 @@ import { EmailMocker } from '@stamhoofd/email';
2
2
  import type { Organization, StripeAccount } from '@stamhoofd/models';
3
3
  import { OrganizationFactory, Payment, Platform } from '@stamhoofd/models';
4
4
  import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
5
+ import { BalanceItem } from '@stamhoofd/models/models/BalanceItem.js';
6
+ import { BalanceItemPayment } from '@stamhoofd/models/models/BalanceItemPayment.js';
5
7
  import { PaymentSettlement } from '@stamhoofd/models/models/PaymentSettlement.js';
8
+ import type { Settlement as SettlementModel } from '@stamhoofd/models/models/Settlement.js';
6
9
  import { Settlement } from '@stamhoofd/models/models/Settlement.js';
7
10
  import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
8
- import { PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
11
+ import { AbortSignal } from '@stamhoofd/queues';
12
+ import { BalanceItemStatus, BalanceItemType, PaymentMethod, PaymentProvider, PaymentStatus, PaymentType } from '@stamhoofd/structures';
9
13
  import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
10
14
  import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
11
15
  import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
16
+ import { STExpect } from '@stamhoofd/test-utils';
17
+ import { v4 as uuidv4 } from 'uuid';
18
+ import { vi } from 'vitest';
12
19
 
13
20
  import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
14
21
  import type { StripeObject } from '../../tests/helpers/StripeMocker.js';
22
+ import { ApplicationFeeService } from '../services/ApplicationFeeService.js';
23
+ import { SettlementService } from '../services/SettlementService.js';
15
24
  import { StripeSettlementSync } from './StripeSettlementSync.js';
16
25
  import { StripeSettlementSyncRunner } from './StripeSettlementSyncRunner.js';
17
26
  import { WebmasterReport } from './WebmasterReport.js';
@@ -45,6 +54,7 @@ describe('StripeSettlementSync', () => {
45
54
 
46
55
  beforeEach(() => {
47
56
  stripeMocker.clear();
57
+ StripeSettlementSync.resetWarnings();
48
58
  });
49
59
 
50
60
  const createSync = () => new StripeSettlementSync({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
@@ -69,6 +79,54 @@ describe('StripeSettlementSync', () => {
69
79
  return await Settlement.select().where('externalId', payout.id).first(true);
70
80
  };
71
81
 
82
+ /**
83
+ * A fee we already invoiced to the organization, like the ApplicationFeeInvoicer stores it: the
84
+ * membership organization bills it, the organization pays it. Every payout that contains such a
85
+ * fee needs a derived line on its fee payment.
86
+ */
87
+ const createInvoicedFee = async (externalId: string, amount: number, { settlement = null as SettlementModel | null } = {}) => {
88
+ const payment = new Payment();
89
+ payment.organizationId = membershipOrganization.id;
90
+ payment.payingOrganizationId = organization.id;
91
+ payment.stripeAccountId = stripeAccount.id;
92
+ payment.method = PaymentMethod.AccountDeductions;
93
+ payment.provider = PaymentProvider.Stripe;
94
+ payment.status = PaymentStatus.Succeeded;
95
+ payment.price = amount;
96
+ payment.paidAt = created;
97
+ await payment.save();
98
+
99
+ const balanceItem = new BalanceItem();
100
+ balanceItem.type = BalanceItemType.ServiceFee;
101
+ balanceItem.organizationId = membershipOrganization.id;
102
+ balanceItem.payingOrganizationId = organization.id;
103
+ balanceItem.unitPrice = amount;
104
+ balanceItem.quantity = 1;
105
+ balanceItem.status = BalanceItemStatus.Hidden;
106
+ await balanceItem.save();
107
+
108
+ const balanceItemPayment = new BalanceItemPayment();
109
+ balanceItemPayment.balanceItemId = balanceItem.id;
110
+ balanceItemPayment.paymentId = payment.id;
111
+ balanceItemPayment.organizationId = payment.organizationId;
112
+ balanceItemPayment.price = amount;
113
+ await balanceItemPayment.save();
114
+
115
+ const fee = new ApplicationFee();
116
+ fee.externalId = externalId;
117
+ fee.type = ApplicationFeeType.Service;
118
+ fee.amount = amount;
119
+ fee.organizationId = membershipOrganization.id;
120
+ fee.payingOrganizationId = organization.id;
121
+ fee.payingStripeAccountId = stripeAccount.id;
122
+ fee.balanceItemId = balanceItem.id;
123
+ fee.settlementId = settlement?.id ?? null;
124
+ fee.occurredAt = created;
125
+ await fee.save();
126
+
127
+ return { payment, fee };
128
+ };
129
+
72
130
  test('a full destination-charge payout reconciles to zero', async () => {
73
131
  const payment = await createPayment({ price: 100_00_00 });
74
132
  const payout = stripeMocker.createPayout({ amount: 225, arrivalDate });
@@ -357,6 +415,85 @@ describe('StripeSettlementSync', () => {
357
415
  expect(emails[0].html).toContain(second.id);
358
416
  });
359
417
 
418
+ test('a walk that fails still updates the fee payments of what it stored', async () => {
419
+ const payerPayment = await createPayment();
420
+ const stripeFee = stripeMocker.createApplicationFee({
421
+ amount: 250,
422
+ account: stripeAccount.accountId,
423
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payerPayment.id, serviceFee: '250' } }),
424
+ });
425
+ const { payment: feePayment } = await createInvoicedFee(stripeFee.id, 2_50_00);
426
+
427
+ const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
428
+ stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, payout: payout.id, source: stripeFee });
429
+
430
+ // Fails the walk after the fee was linked to the payout
431
+ stripeMocker.createBalanceTransaction({ type: 'issuing_authorization_hold', amount: 100, created, payout: payout.id, source: null });
432
+
433
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
434
+
435
+ // The walk linked the fee to this payout before it failed, so the fee payment gets its
436
+ // derived line: a fee may never sit in a payout that has no line for it
437
+ const settlement = await getSettlement(payout);
438
+ expect(settlement.syncedAt).toBeNull();
439
+
440
+ const derived = await PaymentSettlement.select().where('paymentId', feePayment.id).fetch();
441
+ expect(derived).toHaveLength(1);
442
+ expect(derived[0]).toMatchObject({
443
+ settlementId: settlement.id,
444
+ externalId: null,
445
+ amount: 2_50_00,
446
+ });
447
+ });
448
+
449
+ test('a fee walk updates the fee payments of the fees it stored before one failed', async () => {
450
+ const payerPayment = await createPayment();
451
+ const stripeFee = stripeMocker.createApplicationFee({
452
+ amount: 250,
453
+ account: stripeAccount.accountId,
454
+ // Both a service and a transfer part, so the fee is stored as two rows
455
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: payerPayment.id, serviceFee: '30' } }),
456
+ });
457
+
458
+ // Already paid out and invoiced before this walk: the payout it sits in explains it through
459
+ // the derived line of its fee payment
460
+ const settlement = await SettlementService.upsertSettlement({
461
+ provider: PaymentProvider.Stripe,
462
+ externalId: 'po_' + uuidv4(),
463
+ organizationId: membershipOrganization.id,
464
+ amount: 30_00,
465
+ settledAt: arrivalDate,
466
+ });
467
+ const { payment: feePayment } = await createInvoicedFee(stripeFee.id, 30_00, { settlement });
468
+
469
+ stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, source: stripeFee });
470
+
471
+ // The service fee is stored, the transfer fee of the same transaction fails after it
472
+ const upsertFee = ApplicationFeeService.upsertFee.bind(ApplicationFeeService);
473
+ const spy = vi.spyOn(ApplicationFeeService, 'upsertFee').mockImplementation(async (data) => {
474
+ if (data.type === ApplicationFeeType.Transfer) {
475
+ throw new Error('Storing the transfer fee failed');
476
+ }
477
+ return await upsertFee(data);
478
+ });
479
+
480
+ try {
481
+ await expect(createSync().syncFees({ start, end: arrivalDate })).rejects.toThrow(
482
+ STExpect.simpleError({ code: 'stripe_fee_sync_failed' }),
483
+ );
484
+ } finally {
485
+ spy.mockRestore();
486
+ }
487
+
488
+ const derived = await PaymentSettlement.select().where('paymentId', feePayment.id).fetch();
489
+ expect(derived).toHaveLength(1);
490
+ expect(derived[0]).toMatchObject({
491
+ settlementId: settlement.id,
492
+ externalId: null,
493
+ amount: 30_00,
494
+ });
495
+ });
496
+
360
497
  test('a payout that failed after being paid keeps its status current', async () => {
361
498
  const payment = await createPayment();
362
499
  const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate, stripeAccount: stripeAccount.accountId });
@@ -610,6 +747,173 @@ describe('StripeSettlementSync', () => {
610
747
  expect(after.pendingFees).toBe(0);
611
748
  });
612
749
 
750
+ describe('Payers we can no longer reach', () => {
751
+ /**
752
+ * A platform payout that only receives one application fee, plus the payout transaction.
753
+ * The metadata defaults to a payment that no longer exists.
754
+ */
755
+ const createFeePayout = (account: string, metadata: Record<string, string> = {}) => {
756
+ const payout = stripeMocker.createPayout({ amount: 250, arrivalDate });
757
+ const fee = stripeMocker.createApplicationFee({
758
+ amount: 250,
759
+ account,
760
+ originatingTransaction: stripeMocker.createChargeObject({ metadata: { payment: uuidv4(), serviceFee: '30', ...metadata } }),
761
+ });
762
+ stripeMocker.createBalanceTransaction({ type: 'application_fee', amount: 250, created, payout: payout.id, source: fee });
763
+ stripeMocker.createBalanceTransaction({ type: 'payout', amount: -250, created, payout: payout.id, source: null });
764
+ return { payout, fee };
765
+ };
766
+
767
+ test('a fee of an organization that no longer exists is stored as income without a payer', async () => {
768
+ const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { organization: uuidv4() });
769
+
770
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
771
+
772
+ const settlement = await getSettlement(payout);
773
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
774
+ expect(fees).toHaveLength(2);
775
+ for (const row of fees) {
776
+ expect(row.organizationId).toBe(membershipOrganization.id);
777
+ expect(row.settlementId).toBe(settlement.id);
778
+ expect(row.payingOrganizationId).toBeNull();
779
+ expect(row.payingStripeAccountId).toBeNull();
780
+ expect(row.payingPaymentId).toBeNull();
781
+ expect(row.settlementChargeId).toBeNull();
782
+ }
783
+
784
+ // There is no payout of theirs left to deduct it from, but ours still adds up
785
+ expect(await SettlementCharge.select().where('applicationFeeId', fee.id).count()).toBe(0);
786
+ expect(settlement.unexplainedAmount).toBe(0);
787
+ expect(settlement.pendingFees).toBe(0);
788
+ expect(settlement.uncollectibleFees).toBe(2_50_00);
789
+ });
790
+
791
+ test('a fee of a Stripe account we no longer have keeps the payer of its payment', async () => {
792
+ const payment = await createPayment();
793
+ const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { payment: payment.id });
794
+
795
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
796
+
797
+ // The cost is still attributed to the organization, so its own export shows it
798
+ const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
799
+ expect(charges).toHaveLength(2);
800
+ for (const charge of charges) {
801
+ expect(charge.organizationId).toBe(organization.id);
802
+ expect(charge.stripeAccountId).toBeNull();
803
+ expect(charge.paymentId).toBe(payment.id);
804
+ }
805
+
806
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
807
+ for (const row of fees) {
808
+ expect(row.payingOrganizationId).toBe(organization.id);
809
+ expect(row.payingStripeAccountId).toBeNull();
810
+ expect(row.payingPaymentId).toBe(payment.id);
811
+ expect(row.settlementChargeId).not.toBeNull();
812
+ }
813
+
814
+ // Without the account it was deducted from, the invoicer can't bill it per account
815
+ const settlement = await getSettlement(payout);
816
+ expect(settlement.pendingFees).toBe(0);
817
+ expect(settlement.uncollectibleFees).toBe(2_50_00);
818
+ expect(settlement.unexplainedAmount).toBe(0);
819
+ });
820
+
821
+ test('a fee whose payment is gone falls back to the organization in our charge metadata', async () => {
822
+ const { payout, fee } = createFeePayout(stripeMocker.createId('acct'), { organization: organization.id });
823
+
824
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
825
+
826
+ const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
827
+ expect(charges).toHaveLength(2);
828
+ for (const charge of charges) {
829
+ expect(charge.organizationId).toBe(organization.id);
830
+ expect(charge.paymentId).toBeNull();
831
+ }
832
+
833
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
834
+ for (const row of fees) {
835
+ expect(row.payingOrganizationId).toBe(organization.id);
836
+ expect(row.payingPaymentId).toBeNull();
837
+ }
838
+
839
+ expect((await getSettlement(payout)).uncollectibleFees).toBe(2_50_00);
840
+ });
841
+
842
+ test('every unattributable account is reported once, not once per fee', async () => {
843
+ const unknownAccount = stripeMocker.createId('acct');
844
+ createFeePayout(unknownAccount, { organization: uuidv4() });
845
+ createFeePayout(unknownAccount, { organization: uuidv4() });
846
+
847
+ await WebmasterReport.group('Onaanrekenbare applicatiekosten', async () => {
848
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 2, skipped: 0, failed: 0 });
849
+ });
850
+
851
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Onaanrekenbare applicatiekosten'));
852
+ expect(emails).toHaveLength(1);
853
+ expect(emails[0].html).toContain(unknownAccount);
854
+ expect(emails[0].subject).toContain('1 probleem');
855
+ });
856
+
857
+ test('a deleted Stripe account keeps its organization, its account and its payment', async () => {
858
+ const deletedOrganization = await new OrganizationFactory({}).create();
859
+ const deletedAccount = await stripeMocker.createStripeAccount(deletedOrganization.id);
860
+ deletedAccount.status = 'deleted';
861
+ await deletedAccount.save();
862
+
863
+ // Deleting a Stripe account is a soft delete: the organization and its payments stay
864
+ const payment = new Payment();
865
+ payment.organizationId = deletedOrganization.id;
866
+ payment.stripeAccountId = deletedAccount.id;
867
+ payment.method = PaymentMethod.Bancontact;
868
+ payment.provider = PaymentProvider.Stripe;
869
+ payment.status = PaymentStatus.Succeeded;
870
+ payment.type = PaymentType.Payment;
871
+ payment.price = 100_00_00;
872
+ payment.paidAt = created;
873
+ await payment.save();
874
+
875
+ const { payout, fee } = createFeePayout(deletedAccount.accountId, { payment: payment.id });
876
+
877
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
878
+
879
+ const charges = await SettlementCharge.select().where('applicationFeeId', fee.id).fetch();
880
+ expect(charges).toHaveLength(2);
881
+ for (const charge of charges) {
882
+ expect(charge.organizationId).toBe(deletedOrganization.id);
883
+ expect(charge.stripeAccountId).toBe(deletedAccount.id);
884
+ expect(charge.paymentId).toBe(payment.id);
885
+ }
886
+
887
+ const fees = await ApplicationFee.select().where('externalId', fee.id).fetch();
888
+ for (const row of fees) {
889
+ expect(row.payingOrganizationId).toBe(deletedOrganization.id);
890
+ expect(row.payingStripeAccountId).toBe(deletedAccount.id);
891
+ expect(row.payingPaymentId).toBe(payment.id);
892
+ }
893
+
894
+ // Nothing was lost, so it is billed like any other month
895
+ const settlement = await getSettlement(payout);
896
+ expect(settlement.pendingFees).toBe(2_50_00);
897
+ expect(settlement.uncollectibleFees).toBe(0);
898
+ });
899
+
900
+ test('an account still in our database keeps failing the payout on an unresolvable payment', async () => {
901
+ const deletedAccount = await stripeMocker.createStripeAccount(organization.id);
902
+ deletedAccount.status = 'deleted';
903
+ await deletedAccount.save();
904
+
905
+ // A deleted account is no reason to stop checking: an organization may not attach
906
+ // another organization's payment to its fees by deleting its Stripe account first
907
+ for (const account of [stripeAccount, deletedAccount]) {
908
+ const { payout } = createFeePayout(account.accountId);
909
+
910
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 0, skipped: 0, failed: 1 });
911
+ expect((await getSettlement(payout)).syncedAt).toBeNull();
912
+ stripeMocker.clear();
913
+ }
914
+ });
915
+ });
916
+
613
917
  describe('Connected accounts', () => {
614
918
  test('an organization payout writes the mirrored deduction rows, summing to zero against the Received rows', async () => {
615
919
  const payment = await createPayment();
@@ -825,4 +1129,74 @@ describe('StripeSettlementSync', () => {
825
1129
  expect(settlement.syncedAt).toBeNull();
826
1130
  });
827
1131
  });
1132
+
1133
+ describe('Aborting', () => {
1134
+ /**
1135
+ * Aborts the walk while it stores its first row, so the payout is interrupted halfway.
1136
+ */
1137
+ const abortDuringWalk = () => {
1138
+ const abort = new AbortSignal();
1139
+ const upsertCharge = SettlementService.upsertCharge.bind(SettlementService);
1140
+
1141
+ const spy = vi.spyOn(SettlementService, 'upsertCharge').mockImplementation(async (data) => {
1142
+ abort.abort();
1143
+ return await upsertCharge(data);
1144
+ });
1145
+
1146
+ return { abort, restore: () => spy.mockRestore() };
1147
+ };
1148
+
1149
+ test('an interrupted walk gives up its synced state without counting a failure', async () => {
1150
+ const payout = stripeMocker.createPayout({ amount: 2000, arrivalDate });
1151
+ for (let i = 0; i < 2; i++) {
1152
+ stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: payout.id, source: null });
1153
+ }
1154
+
1155
+ expect(await createSync().syncPayouts({ start })).toEqual({ synced: 1, skipped: 0, failed: 0 });
1156
+ expect((await getSettlement(payout)).syncedAt).not.toBeNull();
1157
+
1158
+ const { abort, restore } = abortDuringWalk();
1159
+ try {
1160
+ await WebmasterReport.group('Onderbroken synchronisatie', async () => {
1161
+ await expect(createSync().syncPayouts({ start, force: true, abort })).rejects.toThrow(
1162
+ STExpect.simpleError({ code: 'queue-aborted' }),
1163
+ );
1164
+ });
1165
+ } finally {
1166
+ restore();
1167
+ }
1168
+
1169
+ // Only part of the payout was walked, so it may not keep claiming it is synced. A
1170
+ // restart is not a failure of this payout: it doesn't count towards the retry cap
1171
+ const settlement = await getSettlement(payout);
1172
+ expect(settlement.syncedAt).toBeNull();
1173
+ expect(settlement.syncFailureCount).toBe(0);
1174
+
1175
+ const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Onderbroken synchronisatie'));
1176
+ expect(emails).toHaveLength(0);
1177
+ });
1178
+
1179
+ test('the payouts after an interrupted one are left untouched', async () => {
1180
+ const interrupted = stripeMocker.createPayout({ amount: 2000, arrivalDate });
1181
+ for (let i = 0; i < 2; i++) {
1182
+ stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: interrupted.id, source: null });
1183
+ }
1184
+
1185
+ const untouched = stripeMocker.createPayout({ amount: 1000, arrivalDate });
1186
+ stripeMocker.createBalanceTransaction({ type: 'stripe_fee', amount: 1000, created, payout: untouched.id, source: null });
1187
+
1188
+ const { abort, restore } = abortDuringWalk();
1189
+ try {
1190
+ await expect(createSync().syncPayouts({ start, abort })).rejects.toThrow(
1191
+ STExpect.simpleError({ code: 'queue-aborted' }),
1192
+ );
1193
+ } finally {
1194
+ restore();
1195
+ }
1196
+
1197
+ // The loop stopped at the payout it was walking: the other one was never started
1198
+ expect((await getSettlement(interrupted)).syncedAt).toBeNull();
1199
+ expect(await Settlement.select().where('externalId', untouched.id).count()).toBe(0);
1200
+ });
1201
+ });
828
1202
  });