@stamhoofd/backend 2.140.0 → 2.141.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/fake-settlements.test.ts +129 -8
- package/src/crons/fake-settlements.ts +256 -9
- package/src/crons/index.ts +1 -1
- package/src/crons/invoices.ts +13 -8
- package/src/crons/settlement-sync.test.ts +39 -0
- package/src/crons/settlement-sync.ts +109 -0
- package/src/crons/stripe-invoices.ts +19 -13
- package/src/crons.ts +1 -5
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +4 -4
- package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +3 -3
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
- package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
- package/src/excel-loaders/payments.ts +18 -1
- package/src/helpers/ApplicationFeeDetails.ts +66 -0
- package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
- package/src/helpers/AuthenticatedStructures.ts +14 -1
- package/src/helpers/MollieSettlementSync.test.ts +361 -0
- package/src/helpers/MollieSettlementSync.ts +323 -0
- package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
- package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
- package/src/helpers/SettlementExporter.test.ts +366 -0
- package/src/helpers/SettlementExporter.ts +583 -0
- package/src/helpers/SettlementSyncRunner.test.ts +165 -0
- package/src/helpers/SettlementSyncRunner.ts +64 -0
- package/src/helpers/StripeHelper.ts +2 -1
- package/src/helpers/StripeSettlementSync.test.ts +828 -0
- package/src/helpers/StripeSettlementSync.ts +947 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
- package/src/helpers/WebmasterReport.test.ts +109 -0
- package/src/helpers/WebmasterReport.ts +115 -0
- package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
- package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/helpers/MollieMocker.ts +64 -6
- package/tests/helpers/StripeMocker.ts +209 -17
- package/src/crons/stripe-payout-reports.ts +0 -69
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
- package/src/helpers/CheckSettlements.test.ts +0 -190
- package/src/helpers/CheckSettlements.ts +0 -237
- package/src/helpers/StripeInvoicer.ts +0 -419
- package/src/helpers/StripePayoutChecker.ts +0 -193
- package/src/helpers/StripePayoutExportData.ts +0 -195
- package/src/helpers/StripePayoutExportExcel.ts +0 -280
- package/src/helpers/StripePayoutReporter.test.ts +0 -419
- 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
|
-
|
|
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
|
}
|