@stamhoofd/backend 2.139.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.
Files changed (69) 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/auth/MFA.security.test.ts +76 -1
  11. package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
  13. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +2 -2
  14. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +14 -14
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +150 -2
  17. package/src/endpoints/organization/dashboard/{stripe/GetStripePayoutsExportStatusEndpoint.ts → settlements/GetSettlementsSyncStatusEndpoint.ts} +9 -7
  18. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +157 -0
  19. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.ts +140 -0
  20. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +110 -0
  21. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.ts +104 -0
  22. package/src/excel-loaders/payments.ts +18 -1
  23. package/src/helpers/ApplicationFeeDetails.ts +66 -0
  24. package/src/helpers/ApplicationFeeInvoicer.test.ts +285 -0
  25. package/src/helpers/ApplicationFeeInvoicer.ts +419 -0
  26. package/src/helpers/AuthenticatedStructures.ts +14 -1
  27. package/src/helpers/MollieSettlementSync.test.ts +361 -0
  28. package/src/helpers/MollieSettlementSync.ts +323 -0
  29. package/src/helpers/MollieSettlementSyncRunner.ts +53 -0
  30. package/src/helpers/ProviderSettlementSyncRunner.ts +37 -0
  31. package/src/helpers/SettlementExporter.test.ts +366 -0
  32. package/src/helpers/SettlementExporter.ts +583 -0
  33. package/src/helpers/SettlementSyncRunner.test.ts +165 -0
  34. package/src/helpers/SettlementSyncRunner.ts +64 -0
  35. package/src/helpers/StripeHelper.ts +2 -1
  36. package/src/helpers/StripeSettlementSync.test.ts +828 -0
  37. package/src/helpers/StripeSettlementSync.ts +947 -0
  38. package/src/helpers/StripeSettlementSyncRunner.test.ts +66 -0
  39. package/src/helpers/StripeSettlementSyncRunner.ts +152 -0
  40. package/src/helpers/TwoFactorHelper.ts +1 -1
  41. package/src/helpers/WebmasterReport.test.ts +109 -0
  42. package/src/helpers/WebmasterReport.ts +115 -0
  43. package/src/helpers/getPaymentIdForStripeCharge.test.ts +91 -0
  44. package/src/helpers/getPaymentIdForStripeCharge.ts +71 -0
  45. package/src/helpers/streamForBreakdown.ts +2 -2
  46. package/src/services/ApplicationFeeService.test.ts +256 -0
  47. package/src/services/ApplicationFeeService.ts +283 -0
  48. package/src/services/DocumentRenderService.test.ts +72 -1
  49. package/src/services/InvoiceService.ts +18 -1
  50. package/src/services/PaymentService.ts +8 -0
  51. package/src/services/SettlementService.test.ts +559 -0
  52. package/src/services/SettlementService.ts +607 -0
  53. package/src/sql-filters/orders.ts +6 -2
  54. package/src/sql-filters/payment-settlement.test.ts +2 -2
  55. package/src/sql-filters/payments.ts +73 -0
  56. package/tests/filters/orders.test.ts +635 -0
  57. package/tests/helpers/MollieMocker.ts +64 -6
  58. package/tests/helpers/StripeMocker.ts +209 -17
  59. package/src/crons/stripe-payout-reports.ts +0 -69
  60. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +0 -103
  61. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +0 -125
  62. package/src/helpers/CheckSettlements.test.ts +0 -190
  63. package/src/helpers/CheckSettlements.ts +0 -237
  64. package/src/helpers/StripeInvoicer.ts +0 -419
  65. package/src/helpers/StripePayoutChecker.ts +0 -193
  66. package/src/helpers/StripePayoutExportData.ts +0 -195
  67. package/src/helpers/StripePayoutExportExcel.ts +0 -280
  68. package/src/helpers/StripePayoutReporter.test.ts +0 -419
  69. 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,152 @@
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
+ console.error('Failed to sync payouts of Stripe account ' + account.accountId, e);
107
+ totals.failed += 1;
108
+
109
+ WebmasterReport.report('Synchroniseren Stripe uitbetalingen van account ' + account.accountId + ' mislukt', e);
110
+ }
111
+ }
112
+
113
+ return totals;
114
+ }
115
+
116
+ /**
117
+ * Settlements outside the window that never completed a sync (syncedAt IS NULL is the whole
118
+ * error queue). Every failed retry bumps syncFailureCount, so a broken payout stops retrying
119
+ * after MAXIMUM_FAILURE_COUNT attempts and waits in the problem report instead. Always forced,
120
+ * regardless of the force configuration.
121
+ */
122
+ async retryUnsyncedSettlements({ windowStart }: { windowStart: Date }): Promise<void> {
123
+ const settlements = await Settlement.select()
124
+ .where('provider', PaymentProvider.Stripe)
125
+ .where('syncedAt', null)
126
+ // Only money that actually arrived holds transactions to walk
127
+ .where('status', SettlementStatus.Paid)
128
+ .where('syncFailureCount', '<', MAXIMUM_FAILURE_COUNT)
129
+ // The window walk already attempted (and counted) recent payouts
130
+ .where('settledAt', '<', windowStart)
131
+ .limit(50)
132
+ .fetch();
133
+
134
+ for (const settlement of settlements) {
135
+ const failureCountBefore = settlement.syncFailureCount;
136
+ try {
137
+ const stripeAccount = settlement.stripeAccountId ? await StripeAccount.getByID(settlement.stripeAccountId) : null;
138
+ const sync = new StripeSettlementSync({ secretKey: this.#secretKey, stripeAccount: stripeAccount ?? null });
139
+ await sync.syncPayoutById(settlement.externalId, { force: true });
140
+ } catch (e) {
141
+ console.error('Retry of settlement ' + settlement.externalId + ' failed', e);
142
+
143
+ // Errors before the walk (e.g. the payout can't be retrieved anymore) don't pass
144
+ // markSyncFailed: count them here or the retry cap never triggers
145
+ const fresh = await Settlement.getByID(settlement.id);
146
+ if (fresh && fresh.syncFailureCount === failureCountBefore) {
147
+ await SettlementService.markSyncFailed(fresh);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
@@ -212,7 +212,7 @@ export class TwoFactorHelper {
212
212
  throw new SimpleError({
213
213
  code: 'require_email_confirmation',
214
214
  message: 'Email confirmation required before two-factor authentication setup',
215
- human: $t('Je account is beheerder en moet beveiligd worden met tweestapsverificatie, maar je logde al meer dan {days} dagen niet meer in. Bevestig eerst je e-mailadres: we stuurden je een e-mail met een link waarmee je opnieuw toegang krijgt en tweestapsverificatie kan instellen. Kreeg je geen e-mail? Dan kan je ook een nieuwe link aanvragen via wachtwoord vergeten.', { days: INACTIVE_ADMIN_ENROLLMENT_DAYS.toString() }),
215
+ human: $t('%Zjm', { days: INACTIVE_ADMIN_ENROLLMENT_DAYS.toString() }),
216
216
  statusCode: 403,
217
217
  });
218
218
  }
@@ -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 &lt;b&gt;Chiro &amp; Co&lt;/b&gt; 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
+ }
@@ -52,7 +52,7 @@ export async function streamForBreakdown<T>(options: {
52
52
  throw new SimpleError({
53
53
  code: 'breakdown_pending',
54
54
  message: 'A breakdown is already running for this user',
55
- human: $t('Er worden al statistieken berekend, probeer het zo opnieuw.'),
55
+ human: $t('%Zj3'),
56
56
  statusCode: 429,
57
57
  });
58
58
  }
@@ -96,7 +96,7 @@ function assertBreakdownSize(count: number) {
96
96
  throw new SimpleError({
97
97
  code: 'too_many_objects',
98
98
  message: 'Too many objects to break down',
99
- human: $t('Deze selectie bevat meer dan {limit} items, dat zijn er te veel om statistieken van te maken. Kies een kortere periode of verfijn je selectie.', {
99
+ human: $t('%Ziw', {
100
100
  limit: Formatter.integer(MAX_BREAKDOWN_OBJECTS),
101
101
  }),
102
102
  statusCode: 400,