@stamhoofd/backend 2.140.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.
Files changed (65) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +129 -8
  3. package/src/crons/fake-settlements.ts +256 -9
  4. package/src/crons/index.ts +1 -1
  5. package/src/crons/invoices.ts +13 -8
  6. package/src/crons/settlement-sync.test.ts +39 -0
  7. package/src/crons/settlement-sync.ts +109 -0
  8. package/src/crons/stripe-invoices.ts +19 -13
  9. package/src/crons.ts +1 -5
  10. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
  12. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
  13. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
  14. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
  15. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  16. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  17. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
  18. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  19. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  22. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  23. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  24. package/src/excel-loaders/payments.ts +18 -1
  25. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  26. package/src/helpers/ApplicationFeeInvoicer.test.ts +346 -0
  27. package/src/helpers/ApplicationFeeInvoicer.ts +424 -0
  28. package/src/helpers/AuthenticatedStructures.ts +14 -1
  29. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  30. package/src/helpers/MollieSettlementSync.ts +323 -0
  31. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  32. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  33. package/src/helpers/SettlementExporter.test.ts +388 -0
  34. package/src/helpers/SettlementExporter.ts +593 -0
  35. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  36. package/src/helpers/SettlementSyncRunner.ts +64 -0
  37. package/src/helpers/StripeHelper.ts +2 -1
  38. package/src/helpers/StripeSettlementSync.test.ts +997 -0
  39. package/src/helpers/StripeSettlementSync.ts +1053 -0
  40. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  41. package/src/helpers/StripeSettlementSyncRunner.ts +160 -0
  42. package/src/helpers/WebmasterReport.test.ts +109 -0
  43. package/src/helpers/WebmasterReport.ts +115 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  45. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +301 -0
  48. package/src/services/InvoiceService.ts +18 -1
  49. package/src/services/SettlementService.test.ts +632 -0
  50. package/src/services/SettlementService.ts +650 -0
  51. package/src/sql-filters/payment-settlement.test.ts +2 -2
  52. package/src/sql-filters/payments.ts +73 -0
  53. package/tests/helpers/MollieMocker.ts +64 -6
  54. package/tests/helpers/StripeMocker.ts +209 -17
  55. package/src/crons/stripe-payout-reports.ts +0 -69
  56. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  57. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  58. package/src/helpers/CheckSettlements.test.ts +0 -190
  59. package/src/helpers/CheckSettlements.ts +0 -237
  60. package/src/helpers/StripeInvoicer.ts +0 -419
  61. package/src/helpers/StripePayoutChecker.ts +0 -193
  62. package/src/helpers/StripePayoutExportData.ts +0 -195
  63. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  64. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  65. package/src/helpers/StripePayoutReporter.ts +0 -585
@@ -0,0 +1,165 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { MolliePayment, OrganizationFactory, Payment } from '@stamhoofd/models';
3
+ import { Settlement } from '@stamhoofd/models/models/Settlement.js';
4
+ import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ import { vi } from 'vitest';
7
+
8
+ import type { MollieMockPayment } from '../../tests/helpers/MollieMocker.js';
9
+ import { MollieMocker } from '../../tests/helpers/MollieMocker.js';
10
+ import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
11
+ import { initMembershipOrganization } from '../../tests/init/initMembershipOrganization.js';
12
+ import { SettlementService } from '../services/SettlementService.js';
13
+ import { SettlementSyncRunner } from './SettlementSyncRunner.js';
14
+ import { StripeSettlementSyncRunner } from './StripeSettlementSyncRunner.js';
15
+
16
+ describe('Helper.SettlementSyncRunner', () => {
17
+ const stripeMocker = new StripeMocker();
18
+ const mollieMocker = new MollieMocker();
19
+
20
+ beforeAll(async () => {
21
+ await initMembershipOrganization();
22
+ stripeMocker.start();
23
+ mollieMocker.start();
24
+ });
25
+
26
+ afterAll(() => {
27
+ // Both mockers are nock-based and stop() clears every interceptor, so stopping one is
28
+ // stopping both
29
+ stripeMocker.stop();
30
+ });
31
+
32
+ const createStripePayment = async (organization: Organization) => {
33
+ const payment = new Payment();
34
+ payment.organizationId = organization.id;
35
+ payment.method = PaymentMethod.Bancontact;
36
+ payment.provider = PaymentProvider.Stripe;
37
+ payment.status = PaymentStatus.Succeeded;
38
+ payment.price = 100_00_00;
39
+ payment.paidAt = new Date(2026, 0, 15);
40
+ await payment.save();
41
+ return payment;
42
+ };
43
+
44
+ const createMolliePayment = async (organization: Organization) => {
45
+ const payment = new Payment();
46
+ payment.organizationId = organization.id;
47
+ payment.method = PaymentMethod.Bancontact;
48
+ payment.provider = PaymentProvider.Mollie;
49
+ payment.status = PaymentStatus.Succeeded;
50
+ payment.price = 50_0000;
51
+ payment.paidAt = new Date(2026, 0, 15);
52
+ await payment.save();
53
+
54
+ const mockPayment: MollieMockPayment = {
55
+ id: mollieMocker.createId('tr'),
56
+ status: 'paid',
57
+ amount: { currency: 'EUR', value: '50.00' },
58
+ internalPaymentId: payment.id,
59
+ redirectUrl: null,
60
+ sequenceType: 'oneoff',
61
+ customerId: null,
62
+ mandateId: null,
63
+ isCancelable: false,
64
+ details: null,
65
+ };
66
+ mollieMocker.payments.push(mockPayment);
67
+
68
+ const link = new MolliePayment();
69
+ link.paymentId = payment.id;
70
+ link.mollieId = mockPayment.id;
71
+ await link.save();
72
+
73
+ return { payment, mockPayment };
74
+ };
75
+
76
+ test('One run syncs the settlements of both providers', async () => {
77
+ // Stripe: a payment settled in a platform payout
78
+ const organization = await new OrganizationFactory({}).create();
79
+ const stripePayment = await createStripePayment(organization);
80
+
81
+ const payout = stripeMocker.createPayout({ amount: 10000, arrivalDate: new Date(2026, 0, 20) });
82
+ stripeMocker.createBalanceTransaction({
83
+ type: 'charge',
84
+ amount: 10000,
85
+ created: new Date(2026, 0, 15),
86
+ payout: payout.id,
87
+ source: stripeMocker.createChargeObject({ metadata: { payment: stripePayment.id } }),
88
+ });
89
+
90
+ // Mollie: a payment settled in a settlement of an organization token
91
+ const mollieOrganization = await new OrganizationFactory({}).create();
92
+ await mollieMocker.setupToken(mollieOrganization);
93
+ const { mockPayment } = await createMolliePayment(mollieOrganization);
94
+ const mollieSettlement = mollieMocker.createSettlement({
95
+ payments: [mockPayment],
96
+ value: '50.00',
97
+ settledAt: new Date(2026, 0, 20),
98
+ });
99
+
100
+ const runner = new SettlementSyncRunner();
101
+ const summary = await runner.run({
102
+ start: new Date(2026, 0, 1),
103
+ end: new Date(2026, 0, 31),
104
+ providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
105
+ });
106
+
107
+ const stripeRow = await Settlement.select().where('externalId', payout.id).first(true);
108
+ expect(stripeRow.syncedAt).not.toBeNull();
109
+
110
+ const mollieRow = await Settlement.select().where('externalId', mollieSettlement.id).first(true);
111
+ expect(mollieRow.syncedAt).not.toBeNull();
112
+
113
+ expect(summary.synced).toBeGreaterThanOrEqual(2);
114
+ });
115
+
116
+ test('A hard Stripe failure does not block the Mollie walk', async () => {
117
+ const mollieOrganization = await new OrganizationFactory({}).create();
118
+ await mollieMocker.setupToken(mollieOrganization);
119
+ const { mockPayment } = await createMolliePayment(mollieOrganization);
120
+ const mollieSettlement = mollieMocker.createSettlement({
121
+ payments: [mockPayment],
122
+ value: '50.00',
123
+ settledAt: new Date(2026, 0, 20),
124
+ });
125
+
126
+ const spy = vi.spyOn(StripeSettlementSyncRunner.prototype, 'run').mockRejectedValue(new Error('Stripe is down'));
127
+ try {
128
+ const summary = await new SettlementSyncRunner().run({
129
+ start: new Date(2026, 0, 1),
130
+ end: new Date(2026, 0, 31),
131
+ providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
132
+ });
133
+ expect(spy).toHaveBeenCalled();
134
+ expect(summary.failed).toBeGreaterThanOrEqual(1);
135
+ } finally {
136
+ spy.mockRestore();
137
+ }
138
+
139
+ const mollieRow = await Settlement.select().where('externalId', mollieSettlement.id).first(true);
140
+ expect(mollieRow.syncedAt).not.toBeNull();
141
+ });
142
+
143
+ test('The stripe retryUnsynced option flows through the run', async () => {
144
+ const organization = await new OrganizationFactory({}).create();
145
+ const settlement = await SettlementService.upsertSettlement({
146
+ provider: PaymentProvider.Stripe,
147
+ externalId: 'po_' + uuidv4(),
148
+ organizationId: organization.id,
149
+ amount: 100_00_00,
150
+ settledAt: new Date(1991, 0, 5),
151
+ });
152
+
153
+ await new SettlementSyncRunner().run({
154
+ start: new Date(2026, 0, 1),
155
+ end: new Date(2026, 0, 31),
156
+ providers: [PaymentProvider.Stripe],
157
+ stripe: { retryUnsynced: true },
158
+ });
159
+
160
+ // The mocker has no payout with this id: the failed retry is counted on the settlement
161
+ const fresh = await Settlement.getByID(settlement.id);
162
+ expect(fresh!.syncFailureCount).toBe(1);
163
+ expect(fresh!.syncedAt).toBeNull();
164
+ });
165
+ });
@@ -0,0 +1,64 @@
1
+ import { PaymentProvider } from '@stamhoofd/structures';
2
+
3
+ import { MollieSettlementSyncRunner } from './MollieSettlementSyncRunner.js';
4
+ import type { SettlementSyncSummary } from './ProviderSettlementSyncRunner.js';
5
+ import type { StripeSyncOptions } from './StripeSettlementSyncRunner.js';
6
+ import { StripeSettlementSyncRunner } from './StripeSettlementSyncRunner.js';
7
+ import { WebmasterReport } from './WebmasterReport.js';
8
+
9
+ /**
10
+ * Runs a full settlement sync over a period by wiring up one runner per provider, all sharing one
11
+ * summary, one progress callback and one problem report. Provider-specific configuration is opaque
12
+ * here: the `stripe` options go to the StripeSettlementSyncRunner constructor verbatim; Mollie
13
+ * needs none. Everything is an upsert, so re-running (the nightly cron and a manual backfill) is
14
+ * cheap.
15
+ */
16
+ export class SettlementSyncRunner {
17
+ /**
18
+ * Called after every processed unit of work, to report progress.
19
+ */
20
+ callback: ((summary: SettlementSyncSummary) => void) | null = null;
21
+
22
+ async run({ start = new Date(2025, 0, 1), end, providers, stripe }: {
23
+ start?: Date;
24
+ end?: Date | null;
25
+ providers?: PaymentProvider[] | null;
26
+ stripe?: StripeSyncOptions;
27
+ } = {}): Promise<SettlementSyncSummary> {
28
+ const summary: SettlementSyncSummary = { feeMonths: 0, failedFeeMonths: 0, synced: 0, skipped: 0, failed: 0 };
29
+ const rangeEnd = end ?? new Date();
30
+ const onProgress = () => this.callback?.(summary);
31
+
32
+ const includeStripe = !providers || providers.includes(PaymentProvider.Stripe);
33
+ const includeMollie = !providers || providers.includes(PaymentProvider.Mollie);
34
+
35
+ // A single cause (a first sync, a bug) fails every payout it touches: report all of them in
36
+ // one email instead of one per payout
37
+ return await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
38
+ // One provider's outage must not starve the others (or the caller's follow-up
39
+ // reporting): unsynced settlements stay behind with syncedAt null, so the next run picks
40
+ // them up
41
+ if (includeStripe && STAMHOOFD.STRIPE_SECRET_KEY) {
42
+ const runner = new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY, ...stripe });
43
+ try {
44
+ await runner.run({ start, end: rangeEnd, summary, onProgress });
45
+ } catch (e) {
46
+ console.error('Stripe settlement sync failed', e);
47
+ summary.failed += 1;
48
+ }
49
+ }
50
+
51
+ if (includeMollie) {
52
+ const runner = new MollieSettlementSyncRunner();
53
+ try {
54
+ await runner.run({ start, end: rangeEnd, summary, onProgress });
55
+ } catch (e) {
56
+ console.error('Mollie settlement sync failed', e);
57
+ summary.failed += 1;
58
+ }
59
+ }
60
+
61
+ return summary;
62
+ });
63
+ }
64
+ }
@@ -38,7 +38,8 @@ export class StripeHelper {
38
38
  // This is a direct charge
39
39
 
40
40
  if (charge.balance_transaction !== null && typeof charge.balance_transaction !== 'string') {
41
- const fees = charge.balance_transaction.fee;
41
+ // Stripe counts in cents, we count in 1/10000
42
+ const fees = charge.balance_transaction.fee * 100;
42
43
  payment.transferFee = fees - payment.serviceFeePayout;
43
44
  }
44
45
  }