@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.
- 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/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +16 -16
- 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 +14 -14
- 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/TwoFactorHelper.ts +1 -1
- 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/helpers/streamForBreakdown.ts +2 -2
- package/src/services/ApplicationFeeService.test.ts +256 -0
- package/src/services/ApplicationFeeService.ts +283 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/InvoiceService.ts +18 -1
- package/src/services/PaymentService.ts +8 -0
- package/src/services/SettlementService.test.ts +559 -0
- package/src/services/SettlementService.ts +607 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +2 -2
- package/src/sql-filters/payments.ts +73 -0
- package/tests/filters/orders.test.ts +635 -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,109 @@
|
|
|
1
|
+
// Nightly settlement sync for all providers. Everything is an upsert and synced payouts are
|
|
2
|
+
// skipped, so the windowed re-walk is cheap.
|
|
3
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
4
|
+
import { Email } from '@stamhoofd/email';
|
|
5
|
+
import { Settlement } from '@stamhoofd/models/models/Settlement.js';
|
|
6
|
+
import { PaymentProvider } from '@stamhoofd/structures';
|
|
7
|
+
import { SettlementStatus } from '@stamhoofd/structures/settlements/SettlementStatus.js';
|
|
8
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
9
|
+
|
|
10
|
+
import { SettlementSyncRunner } from '../helpers/SettlementSyncRunner.js';
|
|
11
|
+
import { isApplicationFeeInvoicingEnabled } from './stripe-invoices.js';
|
|
12
|
+
|
|
13
|
+
registerCron('settlement-sync', syncSettlements);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many days of settlements the nightly run re-walks.
|
|
17
|
+
*/
|
|
18
|
+
const SYNC_WINDOW_DAYS = 30;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fees received in a platform payout are billed by the monthly invoicer, so pendingFees taking a
|
|
22
|
+
* while to reach 0 is normal. Older than this it means the invoicer failed or skipped something.
|
|
23
|
+
*/
|
|
24
|
+
const MAXIMUM_PENDING_FEES_AGE_MS = 31 * 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
let lastSettlementSync: Date | null = null;
|
|
27
|
+
|
|
28
|
+
async function syncSettlements() {
|
|
29
|
+
if (STAMHOOFD.environment !== 'production') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Between 5 and 6 AM Brussels time.
|
|
34
|
+
if (Formatter.luxon().hour !== 5) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wait for the next day before doing a new sync
|
|
39
|
+
const today = new Date();
|
|
40
|
+
if (lastSettlementSync && Formatter.dateIso(lastSettlementSync) === Formatter.dateIso(today)) {
|
|
41
|
+
console.log('Settlement sync done for this day');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('Syncing settlements...');
|
|
46
|
+
|
|
47
|
+
const start = new Date(today.getTime() - SYNC_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
48
|
+
|
|
49
|
+
const runner = new SettlementSyncRunner();
|
|
50
|
+
await runner.run({
|
|
51
|
+
start,
|
|
52
|
+
providers: [PaymentProvider.Stripe, PaymentProvider.Mollie],
|
|
53
|
+
stripe: { retryUnsynced: true },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await reportProblemSettlements();
|
|
57
|
+
|
|
58
|
+
lastSettlementSync = new Date();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every unsynced settlement or non-zero delta is a real question to answer, not noise.
|
|
63
|
+
*
|
|
64
|
+
* Exported for tests only.
|
|
65
|
+
*/
|
|
66
|
+
export async function reportProblemSettlements() {
|
|
67
|
+
// Only payouts that actually arrived: money that failed or was canceled holds no transactions
|
|
68
|
+
// to reconcile
|
|
69
|
+
const unexplained = await Settlement.select()
|
|
70
|
+
.where('status', SettlementStatus.Paid)
|
|
71
|
+
.where('unexplainedAmount', '!=', 0)
|
|
72
|
+
.limit(50)
|
|
73
|
+
.fetch();
|
|
74
|
+
|
|
75
|
+
const unsynced = await Settlement.select()
|
|
76
|
+
.where('status', SettlementStatus.Paid)
|
|
77
|
+
.where('syncedAt', null)
|
|
78
|
+
.limit(50)
|
|
79
|
+
.fetch();
|
|
80
|
+
|
|
81
|
+
// Fees only stop being pending once the invoicer bills them: while it is off, every platform
|
|
82
|
+
// payout would be reported forever
|
|
83
|
+
const stalePendingFees = isApplicationFeeInvoicingEnabled()
|
|
84
|
+
? await Settlement.select()
|
|
85
|
+
.where('status', SettlementStatus.Paid)
|
|
86
|
+
.where('pendingFees', '!=', 0)
|
|
87
|
+
.where('settledAt', '<', new Date(Date.now() - MAXIMUM_PENDING_FEES_AGE_MS))
|
|
88
|
+
.limit(50)
|
|
89
|
+
.fetch()
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
if (unexplained.length === 0 && unsynced.length === 0 && stalePendingFees.length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
|
|
97
|
+
|
|
98
|
+
const describe = (settlement: Settlement) => {
|
|
99
|
+
return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
Email.sendWebmaster({
|
|
103
|
+
subject: 'Uitbetalingen met problemen: ' + total,
|
|
104
|
+
html: 'Deze uitbetalingen kloppen niet of konden niet gesynchroniseerd worden: <br><br>'
|
|
105
|
+
+ 'Onverklaard verschil:<br>' + (unexplained.map(describe).join('<br>') || 'geen') + '<br><br>'
|
|
106
|
+
+ 'Niet gesynchroniseerd:<br>' + (unsynced.map(describe).join('<br>') || 'geen') + '<br><br>'
|
|
107
|
+
+ 'Kosten te lang niet gefactureerd:<br>' + (stalePendingFees.map(describe).join('<br>') || 'geen'),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
3
|
-
import { StripeInvoicer } from '../helpers/StripeInvoicer.js';
|
|
4
2
|
import { Organization, Platform } from '@stamhoofd/models';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
|
|
5
|
+
import { ApplicationFeeInvoicer } from '../helpers/ApplicationFeeInvoicer.js';
|
|
5
6
|
|
|
6
7
|
registerCron('stripe-invoices', createStripeInvoices);
|
|
7
8
|
|
|
8
9
|
let lastStripeInvoice: Date | null = null;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether application fees are billed automatically. The sync stores what we received either way,
|
|
13
|
+
* so anything that reports on uninvoiced fees has to ask this first: while it is off, fees staying
|
|
14
|
+
* uninvoiced is the expected state, not a problem.
|
|
15
|
+
*
|
|
16
|
+
* The settlements feature is not released yet: allow production at go-live.
|
|
17
|
+
*/
|
|
18
|
+
export function isApplicationFeeInvoicingEnabled(): boolean {
|
|
19
|
+
return (STAMHOOFD.environment as string) === 'development'
|
|
20
|
+
&& STAMHOOFD.userMode !== 'platform'
|
|
21
|
+
&& STAMHOOFD.STRIPE_CONNECT_METHOD !== 'standard';
|
|
22
|
+
}
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
async function createStripeInvoices() {
|
|
25
|
+
if (!isApplicationFeeInvoicingEnabled()) {
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -41,9 +47,9 @@ async function createStripeInvoices() {
|
|
|
41
47
|
|
|
42
48
|
const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
|
|
43
49
|
|
|
44
|
-
const invoicer = new
|
|
50
|
+
const invoicer = new ApplicationFeeInvoicer({
|
|
45
51
|
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
46
52
|
});
|
|
47
|
-
await invoicer.
|
|
53
|
+
await invoicer.generateInvoices(membershipOrganization);
|
|
48
54
|
lastStripeInvoice = new Date();
|
|
49
55
|
}
|
package/src/crons.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { registerCron } from '@stamhoofd/crons';
|
|
|
3
3
|
import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
|
|
4
4
|
import { SQL } from '@stamhoofd/sql';
|
|
5
5
|
import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
|
|
6
|
-
import { checkSettlements } from './helpers/CheckSettlements.js';
|
|
7
6
|
import { OrganizationDNSService } from './services/OrganizationDNSService.js';
|
|
8
7
|
import { PaymentService } from './services/PaymentService.js';
|
|
9
8
|
import { RegistrationService } from './services/RegistrationService.js';
|
|
@@ -193,7 +192,6 @@ async function checkOldPayments() {
|
|
|
193
192
|
// 5 days - 10 days
|
|
194
193
|
async function checkOldDirectDebitPayments() {
|
|
195
194
|
let timeout = 60 * 1000 * 60 * 24 * 5;
|
|
196
|
-
const timeout2 = 60 * 1000 * 60 * 24 * 10;
|
|
197
195
|
|
|
198
196
|
if (STAMHOOFD.environment === 'development') {
|
|
199
197
|
// For Mollie, webhooks won't work, so we poll in the backend
|
|
@@ -207,8 +205,7 @@ async function checkOldDirectDebitPayments() {
|
|
|
207
205
|
PaymentMethod.DirectDebit,
|
|
208
206
|
])
|
|
209
207
|
.and('status', [PaymentStatus.Created, PaymentStatus.Pending])
|
|
210
|
-
.and('createdAt', '<', new Date(new Date().getTime() - timeout))
|
|
211
|
-
.and('createdAt', '>', new Date(new Date().getTime() - timeout2)),
|
|
208
|
+
.and('createdAt', '<', new Date(new Date().getTime() - timeout)),
|
|
212
209
|
)
|
|
213
210
|
.orderBy('createdAt', 'ASC')
|
|
214
211
|
.limit(500)
|
|
@@ -357,7 +354,6 @@ async function checkReservedUntil() {
|
|
|
357
354
|
}
|
|
358
355
|
}
|
|
359
356
|
|
|
360
|
-
registerCron('checkSettlements', checkSettlements);
|
|
361
357
|
registerCron('checkExpirationEmails', checkExpirationEmails);
|
|
362
358
|
registerCron('checkReservedUntil', checkReservedUntil);
|
|
363
359
|
registerCron('checkDNS', checkDNS);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
3
|
import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
|
|
4
|
-
import { PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
4
|
+
import { NewUser, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
5
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { authenticator } from 'otplib';
|
|
@@ -16,6 +16,7 @@ import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
|
|
|
16
16
|
import { DeleteTOTPEndpoint } from './DeleteTOTPEndpoint.js';
|
|
17
17
|
import { GetMFAChallengeEndpoint } from './GetMFAChallengeEndpoint.js';
|
|
18
18
|
import { GetMFAStatusEndpoint } from './GetMFAStatusEndpoint.js';
|
|
19
|
+
import { PatchUserEndpoint } from './PatchUserEndpoint.js';
|
|
19
20
|
import { RegisterPasskeyOptionsEndpoint } from './RegisterPasskeyOptionsEndpoint.js';
|
|
20
21
|
import { SetupTOTPEndpoint } from './SetupTOTPEndpoint.js';
|
|
21
22
|
import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
|
|
@@ -508,6 +509,80 @@ describe('MFA security', () => {
|
|
|
508
509
|
});
|
|
509
510
|
});
|
|
510
511
|
|
|
512
|
+
// -----------------------------------------------------------------------
|
|
513
|
+
// Absorbing someone else's account by taking over their email address
|
|
514
|
+
// -----------------------------------------------------------------------
|
|
515
|
+
describe('merging accounts', () => {
|
|
516
|
+
/**
|
|
517
|
+
* Verifying an email address that already belongs to another account merges that
|
|
518
|
+
* account into yours. Reading the victim's mailbox is a single primary credential,
|
|
519
|
+
* exactly the one a second factor exists to back up, so it must not be enough to
|
|
520
|
+
* pull an account that has a second factor (and its permissions) into an account
|
|
521
|
+
* that does not.
|
|
522
|
+
*/
|
|
523
|
+
async function requestEmailChange(user: User, token: Token, newEmail: string, organization: Organization): Promise<EmailVerificationCode> {
|
|
524
|
+
const request = Request.patch({
|
|
525
|
+
path: '/user/' + user.id,
|
|
526
|
+
host: organization.getApiHost(),
|
|
527
|
+
headers: { authorization: 'Bearer ' + token.accessToken },
|
|
528
|
+
body: NewUser.patch({ id: user.id, email: newEmail }),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const err = await captureError(testServer.test(new PatchUserEndpoint(), request));
|
|
532
|
+
expect(err.code).toBe('verify_email');
|
|
533
|
+
|
|
534
|
+
const verificationToken = (err.meta as { token: string }).token;
|
|
535
|
+
const code = await EmailVerificationCode.select().where('token', verificationToken).first(true);
|
|
536
|
+
expect(code.email).toBe(newEmail);
|
|
537
|
+
return code;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
test('taking over the email address of an account with a second factor is refused', async () => {
|
|
541
|
+
const organization = await new OrganizationFactory({}).create();
|
|
542
|
+
const victim = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
543
|
+
const victimTotp = await addConfirmedTOTP(victim);
|
|
544
|
+
|
|
545
|
+
const attacker = await new UserFactory({ organization, password }).create();
|
|
546
|
+
const attackerToken = await freshToken(attacker);
|
|
547
|
+
|
|
548
|
+
const code = await requestEmailChange(attacker, attackerToken, victim.email, organization);
|
|
549
|
+
|
|
550
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), attackerToken)));
|
|
551
|
+
expect(err.code).toBe('email_in_use');
|
|
552
|
+
|
|
553
|
+
// The victim still owns their account, their permissions and their factor.
|
|
554
|
+
const storedVictim = await User.getByID(victim.id);
|
|
555
|
+
expect(storedVictim).toBeDefined();
|
|
556
|
+
expect(storedVictim!.email).toBe(victim.email);
|
|
557
|
+
expect(await MFATOTP.getByID(victimTotp.id)).toBeDefined();
|
|
558
|
+
|
|
559
|
+
const storedAttacker = await User.getByID(attacker.id);
|
|
560
|
+
expect(storedAttacker!.email).toBe(attacker.email);
|
|
561
|
+
expect(storedAttacker!.permissions).toBeNull();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('an account without a second factor can still be merged', async () => {
|
|
565
|
+
// Merging is a real feature for people who signed up twice. Without a factor,
|
|
566
|
+
// whoever reads the mailbox could take that account over with a password reset
|
|
567
|
+
// anyway, so nothing is bypassed here.
|
|
568
|
+
const organization = await new OrganizationFactory({}).create();
|
|
569
|
+
const other = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
570
|
+
|
|
571
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
572
|
+
const token = await freshToken(user);
|
|
573
|
+
|
|
574
|
+
const code = await requestEmailChange(user, token, other.email, organization);
|
|
575
|
+
|
|
576
|
+
const response = await testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), token));
|
|
577
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
578
|
+
|
|
579
|
+
expect(await User.getByID(other.id)).toBeUndefined();
|
|
580
|
+
const stored = await User.getByID(user.id);
|
|
581
|
+
expect(stored!.email).toBe(other.email);
|
|
582
|
+
expect(stored!.permissions).not.toBeNull();
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
511
586
|
// -----------------------------------------------------------------------
|
|
512
587
|
// Changing the factors of an account ends the sessions the user is not on
|
|
513
588
|
// -----------------------------------------------------------------------
|
|
@@ -82,6 +82,20 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
82
82
|
const other = await User.getForAuthentication(user.organizationId, code.email, { allowWithoutAccount: true });
|
|
83
83
|
|
|
84
84
|
if (other) {
|
|
85
|
+
// Merging absorbs the other account (its permissions included) and then
|
|
86
|
+
// deletes it, second factor and all. Reading the mailbox is exactly the
|
|
87
|
+
// single credential a second factor exists to back up, so an account that
|
|
88
|
+
// has one may not be taken over this way: nothing in this request proves
|
|
89
|
+
// the caller can pass it.
|
|
90
|
+
if (await TwoFactorHelper.userHasFactors(other.id)) {
|
|
91
|
+
throw new SimpleError({
|
|
92
|
+
code: 'email_in_use',
|
|
93
|
+
message: 'This e-mail is already in use by an account with two-factor authentication',
|
|
94
|
+
human: $t('%Zis'),
|
|
95
|
+
statusCode: 400,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
// Delete the other user, but merge data
|
|
86
100
|
await user.merge(other);
|
|
87
101
|
if (user.organizationId) {
|
package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Request } from '@simonbackx/simple-endpoints';
|
|
|
2
2
|
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
3
|
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice,
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, TranslatedString } from '@stamhoofd/structures';
|
|
6
6
|
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
7
|
import type { BalanceItemBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
8
|
import { BreakdownAmountType, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
@@ -254,13 +254,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
describe('Grouping by payout', () => {
|
|
257
|
-
const march =
|
|
258
|
-
const april =
|
|
257
|
+
const march = SettlementReference.create({ id: 'stl_march', reference: '1234567.0312.01', settledAt: new Date(2026, 2, 12), amount: 0 });
|
|
258
|
+
const april = SettlementReference.create({ id: 'stl_april', reference: '1234567.0409.01', settledAt: new Date(2026, 3, 9), amount: 0 });
|
|
259
259
|
|
|
260
260
|
/**
|
|
261
261
|
* Pays a part of a balance item, the way a payment provider settles it afterwards.
|
|
262
262
|
*/
|
|
263
|
-
const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?:
|
|
263
|
+
const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?: SettlementReference; status?: PaymentStatus; method?: PaymentMethod }) => {
|
|
264
264
|
const payment = new Payment();
|
|
265
265
|
payment.organizationId = organization.id;
|
|
266
266
|
payment.method = options.method ?? PaymentMethod.Bancontact;
|
|
@@ -317,17 +317,17 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
317
317
|
expect(response.status).toBe(200);
|
|
318
318
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
319
319
|
{ name: '1234567.0312.01', price: 40_00 },
|
|
320
|
-
{ name: $t('
|
|
320
|
+
{ name: $t('%1OL'), price: 30_00 },
|
|
321
321
|
// Only what was tried sits under the failed payment, the rest was never attempted
|
|
322
|
-
{ name: $t('
|
|
323
|
-
{ name: $t('
|
|
322
|
+
{ name: $t('%ZjW'), price: 20_00 },
|
|
323
|
+
{ name: $t('%ZjC'), price: 10_00 },
|
|
324
324
|
]);
|
|
325
325
|
|
|
326
326
|
// Every part of what was charged ends up in exactly one row
|
|
327
327
|
expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
|
|
328
328
|
|
|
329
329
|
// Running the rows through the database gives back the balance items they were added up from
|
|
330
|
-
for (const name of [$t('
|
|
330
|
+
for (const name of [$t('%ZjW'), $t('%ZjC')]) {
|
|
331
331
|
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
332
332
|
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
333
333
|
expect(exported.body.balanceItemCount).toBe(1);
|
|
@@ -348,19 +348,19 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
348
348
|
expect(response.status).toBe(200);
|
|
349
349
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
350
350
|
{ name: '1234567.0312.01', price: 50_00 },
|
|
351
|
-
{ name: $t('
|
|
352
|
-
{ name: $t('
|
|
351
|
+
{ name: $t('%10b'), price: -50_00 },
|
|
352
|
+
{ name: $t('%1Ni'), price: 25_00 },
|
|
353
353
|
]);
|
|
354
354
|
|
|
355
355
|
// A canceled item is not charged anymore, so it doesn't add to what is open
|
|
356
356
|
expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
|
|
357
357
|
|
|
358
358
|
// Each row selects exactly the balance items it was added up from
|
|
359
|
-
const refund = response.body.bySettlement.find(g => g.name.toString() === $t('
|
|
359
|
+
const refund = response.body.bySettlement.find(g => g.name.toString() === $t('%10b'))!;
|
|
360
360
|
const refunded = await getBreakdown({ organization, user, filter: refund.selection!.listFilter });
|
|
361
361
|
expect(refunded.body.balanceItemCount).toBe(1);
|
|
362
362
|
|
|
363
|
-
const open = response.body.bySettlement.find(g => g.name.toString() === $t('
|
|
363
|
+
const open = response.body.bySettlement.find(g => g.name.toString() === $t('%1Ni'))!;
|
|
364
364
|
const stillOpen = await getBreakdown({ organization, user, filter: open.selection!.listFilter });
|
|
365
365
|
expect(stillOpen.body.balanceItemCount).toBe(1);
|
|
366
366
|
expect(stillOpen.body.price).toBe(25_00);
|
|
@@ -381,13 +381,13 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
381
381
|
|
|
382
382
|
expect(response.status).toBe(200);
|
|
383
383
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
384
|
-
{ name: $t('
|
|
384
|
+
{ name: $t('%ZjW'), price: 100_00 },
|
|
385
385
|
{ name: '1234567.0312.01', price: 40_00 },
|
|
386
|
-
{ name: $t('
|
|
386
|
+
{ name: $t('%1Ni'), price: 25_00 },
|
|
387
387
|
]);
|
|
388
388
|
|
|
389
389
|
// Both rows select exactly the balance items they were added up from
|
|
390
|
-
for (const [name, count] of [[$t('
|
|
390
|
+
for (const [name, count] of [[$t('%ZjW'), 1], [$t('%1Ni'), 1]] as [string, number][]) {
|
|
391
391
|
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
392
392
|
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
393
393
|
expect(exported.body.balanceItemCount).toBe(count);
|
|
@@ -405,7 +405,7 @@ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
|
|
|
405
405
|
await payItem(organization, processing, { price: 25_00, status: PaymentStatus.Pending });
|
|
406
406
|
|
|
407
407
|
const all = await getBreakdown({ organization, user });
|
|
408
|
-
const row = all.body.bySettlement.find(g => g.name.toString() === $t('
|
|
408
|
+
const row = all.body.bySettlement.find(g => g.name.toString() === $t('%1OL'))!;
|
|
409
409
|
|
|
410
410
|
const narrowed = await getBreakdown({
|
|
411
411
|
organization,
|
|
@@ -7,8 +7,8 @@ import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
|
7
7
|
import { PermissionLevel } from '@stamhoofd/structures';
|
|
8
8
|
|
|
9
9
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
10
|
-
import { checkMollieSettlementsFor } from '../../../../helpers/CheckSettlements.js';
|
|
11
10
|
import { Context } from '../../../../helpers/Context.js';
|
|
11
|
+
import { MollieSettlementSync } from '../../../../helpers/MollieSettlementSync.js';
|
|
12
12
|
import { MollieService } from '../../../../services/MollieService.js';
|
|
13
13
|
|
|
14
14
|
type Params = Record<string, never>;
|
|
@@ -53,7 +53,7 @@ export class ConnectMollieEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
53
53
|
await service.setupOnboarding();
|
|
54
54
|
|
|
55
55
|
// Check settlements after linking (shouldn't block)
|
|
56
|
-
|
|
56
|
+
new MollieSettlementSync({ token: mollieToken }).syncSettlements({ start: organization.createdAt }).catch(console.error);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
return new Response(await AuthenticatedStructures.organization(organization));
|
|
@@ -667,7 +667,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
667
667
|
* A custom PEPPOL endpoint id may only be set by full platform admins for now.
|
|
668
668
|
*/
|
|
669
669
|
private requirePeppolPermission() {
|
|
670
|
-
if (!Context.auth.hasPlatformFullAccess()) {
|
|
670
|
+
if (!Context.auth.hasPlatformFullAccess() && STAMHOOFD.userMode === 'platform') {
|
|
671
671
|
throw new SimpleError({
|
|
672
672
|
code: 'permission_denied',
|
|
673
673
|
message: 'Only platform admins can set a custom PEPPOL endpoint id',
|
|
@@ -2,7 +2,7 @@ import { Request } from '@simonbackx/simple-endpoints';
|
|
|
2
2
|
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
3
|
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, StripeAccount, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
4
|
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice,
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, SettlementReference, StripeBusinessProfile, StripeMetaData, TransferSettings, TranslatedString } from '@stamhoofd/structures';
|
|
6
6
|
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
7
|
import type { PaymentBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
8
|
import { BreakdownGraphUnit, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
@@ -142,7 +142,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
142
142
|
iban?: string;
|
|
143
143
|
transferDescription?: string;
|
|
144
144
|
transferFee?: number;
|
|
145
|
-
settlement?:
|
|
145
|
+
settlement?: SettlementReference;
|
|
146
146
|
/**
|
|
147
147
|
* What this payment rounded away, because a payment goes to the cent while what was charged
|
|
148
148
|
* goes to four digits after the comma.
|
|
@@ -367,7 +367,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
367
367
|
|
|
368
368
|
expect(response.status).toBe(200);
|
|
369
369
|
expect(response.body.byArticle).toHaveLength(1);
|
|
370
|
-
expect(response.body.byArticle[0].name.toString()).toBe($t('
|
|
370
|
+
expect(response.body.byArticle[0].name.toString()).toBe($t('%Zjb'));
|
|
371
371
|
expect(response.body.byArticle[0].price).toBe(1_00);
|
|
372
372
|
});
|
|
373
373
|
|
|
@@ -391,7 +391,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
391
391
|
const response = await getBreakdown({ organization, user });
|
|
392
392
|
|
|
393
393
|
expect(response.status).toBe(200);
|
|
394
|
-
expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('
|
|
394
|
+
expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('%Zik')]);
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
|
|
@@ -631,7 +631,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
631
631
|
|
|
632
632
|
describe('Grouping by payout', () => {
|
|
633
633
|
const createSettlement = (reference: string, settledAt: Date) => {
|
|
634
|
-
return
|
|
634
|
+
return SettlementReference.create({ id: 'stl_' + reference, reference, settledAt, amount: 0 });
|
|
635
635
|
};
|
|
636
636
|
|
|
637
637
|
test('per payout, with what is not online and what still has to be paid out apart', async () => {
|
|
@@ -652,8 +652,8 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
652
652
|
expect(response.status).toBe(200);
|
|
653
653
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price, count: g.count }))).toEqual([
|
|
654
654
|
{ name: '1234567.0312.01', price: 50_00, count: 2 },
|
|
655
|
-
{ name: $t('
|
|
656
|
-
{ name: $t('
|
|
655
|
+
{ name: $t('%Zjn'), price: 30_00, count: 1 },
|
|
656
|
+
{ name: $t('%ZjN'), price: 20_00, count: 1 },
|
|
657
657
|
]);
|
|
658
658
|
});
|
|
659
659
|
|
|
@@ -717,7 +717,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
717
717
|
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
718
718
|
|
|
719
719
|
const all = await getBreakdown({ organization, user });
|
|
720
|
-
const pending = all.body.bySettlement.find(g => g.name.toString() === $t('
|
|
720
|
+
const pending = all.body.bySettlement.find(g => g.name.toString() === $t('%Zjn'))!;
|
|
721
721
|
|
|
722
722
|
const exported = await getBreakdown({ organization, user, filter: pending.selection!.filter });
|
|
723
723
|
expect(exported.body.price).toBe(30_00);
|
|
@@ -735,7 +735,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
735
735
|
await createPayment(organization, { items: [[item, 10_00]], iban: 'BE68539007547034' });
|
|
736
736
|
|
|
737
737
|
const all = await getBreakdown({ organization, user });
|
|
738
|
-
const offline = all.body.bySettlement.find(g => g.name.toString() === $t('
|
|
738
|
+
const offline = all.body.bySettlement.find(g => g.name.toString() === $t('%ZjN'))!;
|
|
739
739
|
|
|
740
740
|
const exported = await getBreakdown({ organization, user, filter: offline.selection!.filter });
|
|
741
741
|
expect(exported.body.price).toBe(30_00);
|
|
@@ -756,9 +756,9 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
756
756
|
const all = await getBreakdown({ organization, user });
|
|
757
757
|
|
|
758
758
|
expect(all.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
759
|
-
{ name: $t('
|
|
759
|
+
{ name: $t('%Zjn'), price: 40_00 },
|
|
760
760
|
{ name: getPaymentProviderName(PaymentProvider.Buckaroo), price: 25_00 },
|
|
761
|
-
{ name: $t('
|
|
761
|
+
{ name: $t('%ZjN'), price: 20_00 },
|
|
762
762
|
{ name: getPaymentProviderName(PaymentProvider.Payconiq), price: 15_00 },
|
|
763
763
|
]);
|
|
764
764
|
|
|
@@ -812,7 +812,7 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
812
812
|
|
|
813
813
|
expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
814
814
|
{ name: 'Kapoenen', price: 14_6652 },
|
|
815
|
-
{ name: $t('
|
|
815
|
+
{ name: $t('%1b6'), price: 48 },
|
|
816
816
|
]);
|
|
817
817
|
|
|
818
818
|
// Everything the payment is worth is accounted for
|
|
@@ -870,12 +870,12 @@ describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
|
870
870
|
|
|
871
871
|
expect(response.status).toBe(200);
|
|
872
872
|
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
873
|
-
{ name: $t('
|
|
873
|
+
{ name: $t('%ZjN'), price: 40_00 },
|
|
874
874
|
{ name: PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), price: 10_00 },
|
|
875
875
|
]);
|
|
876
876
|
|
|
877
877
|
// Running the rows through the database keeps them apart
|
|
878
|
-
for (const [name, price] of [[$t('
|
|
878
|
+
for (const [name, price] of [[$t('%ZjN'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
|
|
879
879
|
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
880
880
|
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
881
881
|
expect(exported.body.price).toBe(price);
|