@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.
- 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/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/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 +346 -0
- package/src/helpers/ApplicationFeeInvoicer.ts +424 -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 +388 -0
- package/src/helpers/SettlementExporter.ts +593 -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 +997 -0
- package/src/helpers/StripeSettlementSync.ts +1053 -0
- package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
- package/src/helpers/StripeSettlementSyncRunner.ts +160 -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 +301 -0
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/SettlementService.test.ts +632 -0
- package/src/services/SettlementService.ts +650 -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,66 @@
|
|
|
1
|
+
import type { Organization } from '@stamhoofd/models';
|
|
2
|
+
import { OrganizationFactory } from '@stamhoofd/models';
|
|
3
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
4
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
|
|
7
|
+
import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
|
|
8
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
9
|
+
import { StripeSettlementSyncRunner } from './StripeSettlementSyncRunner.js';
|
|
10
|
+
|
|
11
|
+
describe('Helper.StripeSettlementSyncRunner', () => {
|
|
12
|
+
const stripeMocker = new StripeMocker();
|
|
13
|
+
let organization: Organization;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
stripeMocker.start();
|
|
17
|
+
organization = await new OrganizationFactory({}).create();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
stripeMocker.stop();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Old dates that no other test uses, so the settledAt window only selects this test's rows
|
|
25
|
+
const settledAt = new Date(1990, 0, 5);
|
|
26
|
+
const windowStart = new Date(1990, 1, 1);
|
|
27
|
+
|
|
28
|
+
const createUnsyncedSettlement = async (failureCount: number) => {
|
|
29
|
+
const settlement = await SettlementService.upsertSettlement({
|
|
30
|
+
provider: PaymentProvider.Stripe,
|
|
31
|
+
externalId: 'po_' + uuidv4(),
|
|
32
|
+
organizationId: organization.id,
|
|
33
|
+
amount: 100_00_00,
|
|
34
|
+
settledAt,
|
|
35
|
+
});
|
|
36
|
+
for (let i = 0; i < failureCount; i++) {
|
|
37
|
+
await SettlementService.markSyncFailed(settlement);
|
|
38
|
+
}
|
|
39
|
+
return settlement;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const retry = async () => {
|
|
43
|
+
const runner = new StripeSettlementSyncRunner({ secretKey: STAMHOOFD.STRIPE_SECRET_KEY! });
|
|
44
|
+
await runner.retryUnsyncedSettlements({ windowStart });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
test('a retry that cannot retrieve the payout anymore still counts towards the cap', async () => {
|
|
48
|
+
const settlement = await createUnsyncedSettlement(1);
|
|
49
|
+
|
|
50
|
+
// The mocker has no payout with this id: the retrieve fails with a 404
|
|
51
|
+
await retry();
|
|
52
|
+
|
|
53
|
+
const fresh = await Settlement.getByID(settlement.id);
|
|
54
|
+
expect(fresh!.syncFailureCount).toBe(2);
|
|
55
|
+
expect(fresh!.syncedAt).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a settlement at the failure cap is not retried anymore', async () => {
|
|
59
|
+
const settlement = await createUnsyncedSettlement(5);
|
|
60
|
+
|
|
61
|
+
await retry();
|
|
62
|
+
|
|
63
|
+
const fresh = await Settlement.getByID(settlement.id);
|
|
64
|
+
expect(fresh!.syncFailureCount).toBe(5);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { StripeAccount } from '@stamhoofd/models';
|
|
2
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
3
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
4
|
+
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
5
|
+
|
|
6
|
+
import { SettlementService } from '../services/SettlementService.js';
|
|
7
|
+
import type { ProviderSettlementSyncRunner, ProviderSyncRunOptions } from './ProviderSettlementSyncRunner.js';
|
|
8
|
+
import { StripeSettlementSync } from './StripeSettlementSync.js';
|
|
9
|
+
import { WebmasterReport } from './WebmasterReport.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A settlement that keeps failing needs a human: stop retrying after this many attempts.
|
|
13
|
+
*/
|
|
14
|
+
const MAXIMUM_FAILURE_COUNT = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stripe-specific knobs; owned here, opaque to the SettlementSyncRunner orchestrator (it forwards
|
|
18
|
+
* them verbatim).
|
|
19
|
+
*/
|
|
20
|
+
export type StripeSyncOptions = {
|
|
21
|
+
/**
|
|
22
|
+
* Re-sync payouts that are already synced.
|
|
23
|
+
*/
|
|
24
|
+
force?: boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* After the window walk, force-retry stuck settlements older than the window.
|
|
28
|
+
*/
|
|
29
|
+
retryUnsynced?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class StripeSettlementSyncRunner implements ProviderSettlementSyncRunner {
|
|
33
|
+
readonly #secretKey: string;
|
|
34
|
+
readonly #force: boolean;
|
|
35
|
+
readonly #retryUnsynced: boolean;
|
|
36
|
+
|
|
37
|
+
constructor({ secretKey, force = false, retryUnsynced = false }: { secretKey: string } & StripeSyncOptions) {
|
|
38
|
+
this.#secretKey = secretKey;
|
|
39
|
+
this.#force = force;
|
|
40
|
+
this.#retryUnsynced = retryUnsynced;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walks the window month by month: fees first, then our platform payouts, then the connected
|
|
45
|
+
* accounts.
|
|
46
|
+
*/
|
|
47
|
+
async run({ start, end, summary, onProgress }: ProviderSyncRunOptions): Promise<void> {
|
|
48
|
+
const platformSync = new StripeSettlementSync({ secretKey: this.#secretKey });
|
|
49
|
+
|
|
50
|
+
let currentMonth = new Date(start.getFullYear(), start.getMonth(), 1);
|
|
51
|
+
|
|
52
|
+
while (true) {
|
|
53
|
+
const { start: monthStartUnix, end: monthEndUnix } = SettlementService.getMonthUnixStartEnd(currentMonth);
|
|
54
|
+
if (monthStartUnix * 1000 > end.getTime()) {
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const windowStart = new Date(Math.max(monthStartUnix * 1000, start.getTime()));
|
|
59
|
+
const windowEnd = new Date(Math.min(monthEndUnix * 1000, end.getTime()));
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await platformSync.syncFees({ start: windowStart, end: windowEnd });
|
|
63
|
+
summary.feeMonths += 1;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// syncFees already emailed nothing: it throws an aggregate, the month is retried by
|
|
66
|
+
// the next run and the month is not invoiced until it completes
|
|
67
|
+
console.error('Fee sync failed for month ' + currentMonth.toISOString(), e);
|
|
68
|
+
summary.failedFeeMonths += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const platformResult = await platformSync.syncPayouts({ start: windowStart, end: windowEnd, force: this.#force });
|
|
72
|
+
summary.synced += platformResult.synced;
|
|
73
|
+
summary.skipped += platformResult.skipped;
|
|
74
|
+
summary.failed += platformResult.failed;
|
|
75
|
+
|
|
76
|
+
const connectedResult = await this.syncConnectedPayouts({ start: windowStart, end: windowEnd });
|
|
77
|
+
summary.synced += connectedResult.synced;
|
|
78
|
+
summary.skipped += connectedResult.skipped;
|
|
79
|
+
summary.failed += connectedResult.failed;
|
|
80
|
+
|
|
81
|
+
onProgress?.();
|
|
82
|
+
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.#retryUnsynced) {
|
|
86
|
+
await this.retryUnsyncedSettlements({ windowStart: start });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walks the payouts of every active connected account.
|
|
92
|
+
*/
|
|
93
|
+
async syncConnectedPayouts({ start, end }: { start: Date; end?: Date }): Promise<{ synced: number; skipped: number; failed: number }> {
|
|
94
|
+
const totals = { synced: 0, skipped: 0, failed: 0 };
|
|
95
|
+
|
|
96
|
+
const accounts = await StripeAccount.select().where('status', 'active').fetch();
|
|
97
|
+
|
|
98
|
+
for (const account of accounts) {
|
|
99
|
+
try {
|
|
100
|
+
const sync = new StripeSettlementSync({ secretKey: this.#secretKey, stripeAccount: account });
|
|
101
|
+
const result = await sync.syncPayouts({ start, end, force: this.#force });
|
|
102
|
+
totals.synced += result.synced;
|
|
103
|
+
totals.skipped += result.skipped;
|
|
104
|
+
totals.failed += result.failed;
|
|
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
|
+
}
|
|
114
|
+
console.error('Failed to sync payouts of Stripe account ' + account.accountId, e);
|
|
115
|
+
totals.failed += 1;
|
|
116
|
+
|
|
117
|
+
WebmasterReport.report('Synchroniseren Stripe uitbetalingen van account ' + account.accountId + ' mislukt', e);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return totals;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Settlements outside the window that never completed a sync (syncedAt IS NULL is the whole
|
|
126
|
+
* error queue). Every failed retry bumps syncFailureCount, so a broken payout stops retrying
|
|
127
|
+
* after MAXIMUM_FAILURE_COUNT attempts and waits in the problem report instead. Always forced,
|
|
128
|
+
* regardless of the force configuration.
|
|
129
|
+
*/
|
|
130
|
+
async retryUnsyncedSettlements({ windowStart }: { windowStart: Date }): Promise<void> {
|
|
131
|
+
const settlements = await Settlement.select()
|
|
132
|
+
.where('provider', PaymentProvider.Stripe)
|
|
133
|
+
.where('syncedAt', null)
|
|
134
|
+
// Only money that actually arrived holds transactions to walk
|
|
135
|
+
.where('status', SettlementStatus.Paid)
|
|
136
|
+
.where('syncFailureCount', '<', MAXIMUM_FAILURE_COUNT)
|
|
137
|
+
// The window walk already attempted (and counted) recent payouts
|
|
138
|
+
.where('settledAt', '<', windowStart)
|
|
139
|
+
.limit(50)
|
|
140
|
+
.fetch();
|
|
141
|
+
|
|
142
|
+
for (const settlement of settlements) {
|
|
143
|
+
const failureCountBefore = settlement.syncFailureCount;
|
|
144
|
+
try {
|
|
145
|
+
const stripeAccount = settlement.stripeAccountId ? await StripeAccount.getByID(settlement.stripeAccountId) : null;
|
|
146
|
+
const sync = new StripeSettlementSync({ secretKey: this.#secretKey, stripeAccount: stripeAccount ?? null });
|
|
147
|
+
await sync.syncPayoutById(settlement.externalId, { force: true });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error('Retry of settlement ' + settlement.externalId + ' failed', e);
|
|
150
|
+
|
|
151
|
+
// Errors before the walk (e.g. the payout can't be retrieved anymore) don't pass
|
|
152
|
+
// markSyncFailed: count them here or the retry cap never triggers
|
|
153
|
+
const fresh = await Settlement.getByID(settlement.id);
|
|
154
|
+
if (fresh && fresh.syncFailureCount === failureCountBefore) {
|
|
155
|
+
await SettlementService.markSyncFailed(fresh);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
2
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
3
|
+
|
|
4
|
+
import { WebmasterReport } from './WebmasterReport.js';
|
|
5
|
+
|
|
6
|
+
describe('WebmasterReport', () => {
|
|
7
|
+
// Emails of other tests can still be in the queue when this one runs: match on the subject
|
|
8
|
+
const getEmails = async (subjectPrefix = 'Synchroniseren uitbetalingen') => {
|
|
9
|
+
return (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith(subjectPrefix));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
test('problems reported inside a group are sent as one email', async () => {
|
|
13
|
+
await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
14
|
+
WebmasterReport.report('Uitbetaling po_1 mislukt', new Error('Boom'));
|
|
15
|
+
WebmasterReport.report('Uitbetaling po_2 mislukt', 'Geen betaling gevonden');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const emails = await getEmails();
|
|
19
|
+
expect(emails).toHaveLength(1);
|
|
20
|
+
expect(emails[0].subject).toBe('Synchroniseren uitbetalingen: 2 problemen');
|
|
21
|
+
expect(emails[0].html).toContain('Uitbetaling po_1 mislukt');
|
|
22
|
+
expect(emails[0].html).toContain('Error: Boom');
|
|
23
|
+
expect(emails[0].html).toContain('Uitbetaling po_2 mislukt');
|
|
24
|
+
expect(emails[0].html).toContain('Geen betaling gevonden');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a group without problems sends nothing', async () => {
|
|
28
|
+
await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
29
|
+
// Nothing to report
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(await getEmails()).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('problems are still reported when the group throws', async () => {
|
|
36
|
+
await expect(WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
37
|
+
WebmasterReport.report('Uitbetaling po_1 mislukt');
|
|
38
|
+
throw new Error('Boom');
|
|
39
|
+
})).rejects.toThrow('Boom');
|
|
40
|
+
|
|
41
|
+
const emails = await getEmails();
|
|
42
|
+
expect(emails).toHaveLength(1);
|
|
43
|
+
expect(emails[0].subject).toBe('Synchroniseren uitbetalingen: 1 probleem');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('only the first problems are listed, the rest is counted', async () => {
|
|
47
|
+
await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
48
|
+
for (let index = 0; index < 100; index++) {
|
|
49
|
+
WebmasterReport.report('Uitbetaling po_' + index + ' mislukt');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const emails = await getEmails();
|
|
54
|
+
expect(emails).toHaveLength(1);
|
|
55
|
+
expect(emails[0].subject).toBe('Synchroniseren uitbetalingen: 100 problemen');
|
|
56
|
+
expect(emails[0].html).toContain('Uitbetaling po_19 mislukt');
|
|
57
|
+
expect(emails[0].html).not.toContain('Uitbetaling po_20 mislukt');
|
|
58
|
+
expect(emails[0].html).toContain('En nog 80 andere problemen');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('problems reported in queued work land in the surrounding group', async () => {
|
|
62
|
+
await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
63
|
+
await QueueHandler.schedule('webmaster-report-test', async () => {
|
|
64
|
+
WebmasterReport.report('Uitbetaling po_1 mislukt');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const emails = await getEmails();
|
|
69
|
+
expect(emails).toHaveLength(1);
|
|
70
|
+
expect(emails[0].subject).toBe('Synchroniseren uitbetalingen: 1 probleem');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('titles and details are escaped', async () => {
|
|
74
|
+
await WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
75
|
+
WebmasterReport.report('Uitbetaling van <b>Chiro & Co</b> mislukt', new Error('Regel 1\nRegel 2'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const [email] = await getEmails();
|
|
79
|
+
expect(email.html).toContain('Uitbetaling van <b>Chiro & Co</b> mislukt');
|
|
80
|
+
expect(email.html).toContain('Regel 1<br>Regel 2');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a problem reported outside a group is sent on its own', async () => {
|
|
84
|
+
WebmasterReport.report('Uitbetaling po_1 mislukt', new Error('Boom'));
|
|
85
|
+
|
|
86
|
+
const emails = await getEmails('Uitbetaling po_1 mislukt');
|
|
87
|
+
expect(emails).toHaveLength(1);
|
|
88
|
+
expect(emails[0].html).toContain('Error: Boom');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('groups only collect the problems reported inside them', async () => {
|
|
92
|
+
await Promise.all([
|
|
93
|
+
WebmasterReport.group('Synchroniseren uitbetalingen', async () => {
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
WebmasterReport.report('Uitbetaling po_1 mislukt');
|
|
96
|
+
}),
|
|
97
|
+
WebmasterReport.group('Aanrekenen applicatiekosten', async () => {
|
|
98
|
+
WebmasterReport.report('Maand 2026-01 overgeslagen');
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const [sync] = await getEmails();
|
|
103
|
+
const [invoicing] = await getEmails('Aanrekenen applicatiekosten');
|
|
104
|
+
expect(sync.subject).toBe('Synchroniseren uitbetalingen: 1 probleem');
|
|
105
|
+
expect(sync.html).toContain('Uitbetaling po_1 mislukt');
|
|
106
|
+
expect(invoicing.subject).toBe('Aanrekenen applicatiekosten: 1 probleem');
|
|
107
|
+
expect(invoicing.html).toContain('Maand 2026-01 overgeslagen');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Email } from '@stamhoofd/email';
|
|
2
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Problems above this are only counted: a flood is one cause repeated per item, so listing them
|
|
7
|
+
* all adds nothing.
|
|
8
|
+
*/
|
|
9
|
+
const MAXIMUM_LISTED_PROBLEMS = 20;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Collects the problems of one run (a settlement sync, an invoicing round) into a single webmaster
|
|
13
|
+
* email instead of one email per failing item.
|
|
14
|
+
*
|
|
15
|
+
* Reporting outside a `group` still sends its own email right away: a caller that forgets to group
|
|
16
|
+
* mails too much, never too little.
|
|
17
|
+
*/
|
|
18
|
+
export class WebmasterReport {
|
|
19
|
+
static #current = new AsyncLocalStorage<WebmasterReport>();
|
|
20
|
+
|
|
21
|
+
readonly #subject: string;
|
|
22
|
+
readonly #problems: string[] = [];
|
|
23
|
+
#count = 0;
|
|
24
|
+
#sent = false;
|
|
25
|
+
|
|
26
|
+
private constructor(subject: string) {
|
|
27
|
+
this.#subject = subject;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emails everything the handler reported after it finished, also when it throws.
|
|
32
|
+
*/
|
|
33
|
+
static async group<T>(subject: string, handler: () => Promise<T>): Promise<T> {
|
|
34
|
+
const report = new WebmasterReport(subject);
|
|
35
|
+
try {
|
|
36
|
+
return await this.#current.run(report, handler);
|
|
37
|
+
} finally {
|
|
38
|
+
report.#send();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static report(title: string, detail?: unknown) {
|
|
43
|
+
if (this.#current.getStore()?.collect(title, detail)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Email.sendWebmaster({
|
|
48
|
+
subject: title,
|
|
49
|
+
html: describeProblem(title, detail),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns false when this report was already sent, so the problem still gets its own email.
|
|
55
|
+
*/
|
|
56
|
+
collect(title: string, detail?: unknown): boolean {
|
|
57
|
+
if (this.#sent) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.#count += 1;
|
|
62
|
+
if (this.#problems.length < MAXIMUM_LISTED_PROBLEMS) {
|
|
63
|
+
this.#problems.push(describeProblem(title, detail));
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#send() {
|
|
69
|
+
this.#sent = true;
|
|
70
|
+
|
|
71
|
+
if (this.#count === 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const notListed = this.#count - this.#problems.length;
|
|
76
|
+
|
|
77
|
+
Email.sendWebmaster({
|
|
78
|
+
subject: this.#subject + ': ' + this.#count + (this.#count === 1 ? ' probleem' : ' problemen'),
|
|
79
|
+
html: this.#problems.join('<br><br>')
|
|
80
|
+
+ (notListed > 0
|
|
81
|
+
? '<br><br>' + (notListed === 1
|
|
82
|
+
? 'En nog 1 ander probleem dat niet in deze mail past.'
|
|
83
|
+
: 'En nog ' + notListed + ' andere problemen die niet in deze mail passen.')
|
|
84
|
+
: ''),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function describeProblem(title: string, detail: unknown): string {
|
|
90
|
+
const description = describe(detail);
|
|
91
|
+
return '<strong>' + escapeLines(title) + '</strong>' + (description ? '<br>' + description : '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describe(detail: unknown): string | null {
|
|
95
|
+
if (detail === undefined || detail === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof detail === 'string') {
|
|
99
|
+
return escapeLines(detail);
|
|
100
|
+
}
|
|
101
|
+
if (detail instanceof Error) {
|
|
102
|
+
// Aggregates (SimpleErrors) put one message per line
|
|
103
|
+
return escapeLines(detail.toString());
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return escapeLines(JSON.stringify(detail) ?? 'Onbekend probleem');
|
|
107
|
+
} catch {
|
|
108
|
+
// Circular structures, BigInt, ...: never fail the error boundary that reports the problem
|
|
109
|
+
return 'Onbekend probleem';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function escapeLines(text: string): string {
|
|
114
|
+
return Formatter.escapeHtml(text).replace(/\n/g, '<br>');
|
|
115
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { StripeCheckoutSession, StripePaymentIntent } from '@stamhoofd/models';
|
|
2
|
+
import Stripe from 'stripe';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
|
|
5
|
+
import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
|
|
6
|
+
import { passthroughFetch } from './passthroughFetch.js';
|
|
7
|
+
import { getPaymentIdForStripeCharge } from './getPaymentIdForStripeCharge.js';
|
|
8
|
+
|
|
9
|
+
describe('getPaymentIdForStripeCharge', () => {
|
|
10
|
+
const stripeMocker = new StripeMocker();
|
|
11
|
+
let stripePlatform: Stripe;
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
stripeMocker.start();
|
|
15
|
+
stripePlatform = new Stripe(STAMHOOFD.STRIPE_SECRET_KEY!, {
|
|
16
|
+
apiVersion: '2024-06-20',
|
|
17
|
+
typescript: true,
|
|
18
|
+
maxNetworkRetries: 0,
|
|
19
|
+
timeout: 10000,
|
|
20
|
+
httpClient: Stripe.createFetchHttpClient(passthroughFetch),
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
stripeMocker.stop();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
stripeMocker.clear();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const asCharge = (data: Record<string, unknown>): Stripe.Charge => {
|
|
33
|
+
return { id: stripeMocker.createId('ch'), object: 'charge', metadata: {}, ...data } as unknown as Stripe.Charge;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
test('the payment metadata on the charge wins', async () => {
|
|
37
|
+
const paymentId = uuidv4();
|
|
38
|
+
const charge = asCharge({ metadata: { payment: paymentId } });
|
|
39
|
+
|
|
40
|
+
expect(await getPaymentIdForStripeCharge(charge, { stripePlatform })).toBe(paymentId);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('falls back to the metadata of the application fee originating transaction', async () => {
|
|
44
|
+
const paymentId = uuidv4();
|
|
45
|
+
const originating = asCharge({ metadata: { payment: paymentId } });
|
|
46
|
+
const charge = asCharge({
|
|
47
|
+
application_fee: stripeMocker.createApplicationFee({
|
|
48
|
+
amount: 250,
|
|
49
|
+
account: 'acct_1',
|
|
50
|
+
originatingTransaction: originating,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(await getPaymentIdForStripeCharge(charge, { stripePlatform })).toBe(paymentId);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('falls back to a stored StripePaymentIntent', async () => {
|
|
58
|
+
const paymentId = uuidv4();
|
|
59
|
+
const intentId = stripeMocker.createId('pi');
|
|
60
|
+
|
|
61
|
+
const intent = new StripePaymentIntent();
|
|
62
|
+
intent.paymentId = paymentId;
|
|
63
|
+
intent.stripeIntentId = intentId;
|
|
64
|
+
await intent.save();
|
|
65
|
+
|
|
66
|
+
const charge = asCharge({ payment_intent: intentId });
|
|
67
|
+
|
|
68
|
+
expect(await getPaymentIdForStripeCharge(charge, { stripePlatform })).toBe(paymentId);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('falls back to the checkout session of the payment intent', async () => {
|
|
72
|
+
const paymentId = uuidv4();
|
|
73
|
+
const intentId = stripeMocker.createId('pi');
|
|
74
|
+
const mockedSession = stripeMocker.createCheckoutSession({ paymentIntent: intentId });
|
|
75
|
+
|
|
76
|
+
const session = new StripeCheckoutSession();
|
|
77
|
+
session.paymentId = paymentId;
|
|
78
|
+
session.stripeSessionId = mockedSession.id;
|
|
79
|
+
await session.save();
|
|
80
|
+
|
|
81
|
+
const charge = asCharge({ payment_intent: intentId });
|
|
82
|
+
|
|
83
|
+
expect(await getPaymentIdForStripeCharge(charge, { stripePlatform })).toBe(paymentId);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('returns null when nothing matches', async () => {
|
|
87
|
+
const charge = asCharge({ payment_intent: stripeMocker.createId('pi') });
|
|
88
|
+
|
|
89
|
+
expect(await getPaymentIdForStripeCharge(charge, { stripePlatform })).toBe(null);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { StripeCheckoutSession, StripePaymentIntent } from '@stamhoofd/models';
|
|
2
|
+
import type Stripe from 'stripe';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find the local payment behind a Stripe charge: charge metadata → application fee's originating
|
|
6
|
+
* transaction metadata → StripePaymentIntent → StripeCheckoutSession. Returns null when nothing
|
|
7
|
+
* matches: the caller decides whether that is an error.
|
|
8
|
+
*
|
|
9
|
+
* The `stripePlatform` client must not be account-scoped: checkout sessions live on the platform
|
|
10
|
+
* account.
|
|
11
|
+
*/
|
|
12
|
+
export async function getPaymentIdForStripeCharge(charge: Stripe.Charge, { stripePlatform }: { stripePlatform: Stripe }): Promise<string | null> {
|
|
13
|
+
// A destination charge shows up twice: as the platform charge (the application fee's
|
|
14
|
+
// originating transaction) and as the charge on the connected account. Both point to the same
|
|
15
|
+
// payment, so both are usable candidates.
|
|
16
|
+
const candidates: Stripe.Charge[] = [charge];
|
|
17
|
+
|
|
18
|
+
if (charge.application_fee && typeof charge.application_fee !== 'string') {
|
|
19
|
+
const originatingTransaction = charge.application_fee.originating_transaction;
|
|
20
|
+
if (originatingTransaction && typeof originatingTransaction !== 'string') {
|
|
21
|
+
candidates.push(originatingTransaction as Stripe.Charge);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
const paymentId = candidate.metadata?.payment;
|
|
27
|
+
if (paymentId) {
|
|
28
|
+
return paymentId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Historical bug where we didn't save payment in metadata: try to look it up by payment intent
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
if (!candidate.payment_intent) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const paymentIntentId = typeof candidate.payment_intent === 'string' ? candidate.payment_intent : candidate.payment_intent.id;
|
|
38
|
+
|
|
39
|
+
const stripePayments = await StripePaymentIntent.where({
|
|
40
|
+
stripeIntentId: paymentIntentId,
|
|
41
|
+
}, { limit: 1 });
|
|
42
|
+
|
|
43
|
+
if (stripePayments.length === 1) {
|
|
44
|
+
console.log('Found missing payment metadata for payment intent', paymentIntentId, stripePayments[0].paymentId);
|
|
45
|
+
return stripePayments[0].paymentId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Probably a card payment: search for the checkout session
|
|
49
|
+
const checkoutSessions = await stripePlatform.checkout.sessions.list({
|
|
50
|
+
payment_intent: paymentIntentId,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (checkoutSessions.data.length !== 1) {
|
|
54
|
+
console.log('No Stripe Checkout Sessions found for payment intent ' + paymentIntentId);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stripeCheckoutSessions = await StripeCheckoutSession.where({
|
|
59
|
+
stripeSessionId: checkoutSessions.data[0].id,
|
|
60
|
+
}, { limit: 1 });
|
|
61
|
+
|
|
62
|
+
if (stripeCheckoutSessions.length === 1) {
|
|
63
|
+
console.log('Found missing payment metadata for payment intent', paymentIntentId, stripeCheckoutSessions[0].paymentId);
|
|
64
|
+
return stripeCheckoutSessions[0].paymentId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('No payment found for checkout session ' + checkoutSessions.data[0].id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|