@stamhoofd/backend 2.138.2 → 2.139.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 +165 -0
- package/src/crons/fake-settlements.ts +117 -0
- package/src/crons/index.ts +1 -0
- package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
- package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
- package/src/endpoints/auth/MFA.test.ts +187 -3
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
- package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
- package/src/excel-loaders/balance-item-payments.ts +145 -0
- package/src/excel-loaders/index.ts +1 -0
- package/src/excel-loaders/payments.ts +45 -37
- package/src/helpers/StripeInvoicer.ts +1 -1
- package/src/helpers/TwoFactorHelper.ts +91 -9
- package/src/helpers/breakdownRelations.ts +48 -0
- package/src/helpers/getNextPageRequest.ts +24 -0
- package/src/helpers/streamForBreakdown.test.ts +107 -0
- package/src/helpers/streamForBreakdown.ts +104 -0
- package/src/services/PasswordForgotService.ts +31 -1
- package/src/services/PaymentService.ts +1 -1
- package/src/services/SSOService.ts +14 -1
- package/src/sql-filters/balance-item-payments-root.ts +48 -0
- package/src/sql-filters/balance-items.ts +55 -0
- package/src/sql-filters/payment-settlement.test.ts +68 -0
- package/src/sql-filters/payment-settlement.ts +43 -0
- package/src/sql-filters/payments.ts +93 -31
- package/src/sql-sorters/balance-item-payments.ts +32 -0
- package/tests/helpers/ExportSlice.ts +71 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { StripeAccount } from '@stamhoofd/models';
|
|
5
|
+
import type { PaymentGeneral } from '@stamhoofd/structures';
|
|
6
|
+
import { StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
|
|
7
|
+
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
8
|
+
import { PaymentBreakdownBuilder } from '@stamhoofd/structures/breakdown/PaymentBreakdownBuilder.js';
|
|
9
|
+
import type { PaymentBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
10
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
11
|
+
import { loadOrdersForBreakdown } from '../../../../helpers/breakdownRelations.js';
|
|
12
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
13
|
+
import { streamForBreakdown } from '../../../../helpers/streamForBreakdown.js';
|
|
14
|
+
import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
|
|
15
|
+
|
|
16
|
+
type Params = Record<string, never>;
|
|
17
|
+
type Query = BreakdownRequest;
|
|
18
|
+
type Body = undefined;
|
|
19
|
+
type ResponseBody = PaymentBreakdown;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The Stripe accounts the payments arrived on, so they can be named after their holder. There are only
|
|
23
|
+
* a few of them, so they are kept for as long as the breakdown runs.
|
|
24
|
+
*/
|
|
25
|
+
class StripeAccountCache {
|
|
26
|
+
// An account that no longer exists is remembered as null, so it is not looked up again on every page
|
|
27
|
+
private accounts = new Map<string, StripeAccountStruct | null>();
|
|
28
|
+
|
|
29
|
+
async load(payments: PaymentGeneral[]): Promise<StripeAccountStruct[]> {
|
|
30
|
+
const missing = Formatter.uniqueArray(
|
|
31
|
+
payments.flatMap(p => p.stripeAccountId && !this.accounts.has(p.stripeAccountId) ? [p.stripeAccountId] : []),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (missing.length > 0) {
|
|
35
|
+
for (const id of missing) {
|
|
36
|
+
this.accounts.set(id, null);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const account of await StripeAccount.getByIDs(...missing)) {
|
|
40
|
+
this.accounts.set(account.id, StripeAccountStruct.create(account));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [...this.accounts.values()].filter(account => account !== null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Breaks a selection of payments down into where the money arrived, what it was for and which articles
|
|
50
|
+
* were paid.
|
|
51
|
+
*
|
|
52
|
+
* Reads the same payments the Excel export would and groups them with the same rules the rest of the
|
|
53
|
+
* app uses (see PaymentBreakdownBuilder in @stamhoofd/structures), so the numbers always describe
|
|
54
|
+
* exactly the payments that end up in the file.
|
|
55
|
+
*/
|
|
56
|
+
export class GetPaymentBreakdownEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
57
|
+
queryDecoder = BreakdownRequest as Decoder<BreakdownRequest>;
|
|
58
|
+
|
|
59
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
60
|
+
if (request.method !== 'GET') {
|
|
61
|
+
return [false];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const params = Endpoint.parseParameters(request.url, '/payments/breakdown', {});
|
|
65
|
+
|
|
66
|
+
if (params) {
|
|
67
|
+
return [true, params as Params];
|
|
68
|
+
}
|
|
69
|
+
return [false];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
73
|
+
await Context.setOrganizationScope();
|
|
74
|
+
const { user } = await Context.authenticate();
|
|
75
|
+
|
|
76
|
+
const organization = Context.organization;
|
|
77
|
+
|
|
78
|
+
if (!organization) {
|
|
79
|
+
throw Context.auth.error();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!await Context.auth.canManagePayments(organization.id)) {
|
|
83
|
+
throw Context.auth.error();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const builder = new PaymentBreakdownBuilder(request.query.path);
|
|
87
|
+
const stripeAccounts = new StripeAccountCache();
|
|
88
|
+
|
|
89
|
+
await streamForBreakdown<PaymentGeneral>({
|
|
90
|
+
userId: user.id,
|
|
91
|
+
filter: request.query.readFilter,
|
|
92
|
+
search: request.query.search,
|
|
93
|
+
count: async (countRequest) => {
|
|
94
|
+
return await (await GetPaymentsEndpoint.buildQuery(countRequest)).count();
|
|
95
|
+
},
|
|
96
|
+
fetch: async (pageRequest) => {
|
|
97
|
+
return await GetPaymentsEndpoint.buildData(pageRequest);
|
|
98
|
+
},
|
|
99
|
+
handle: async (payments) => {
|
|
100
|
+
builder.add(payments, {
|
|
101
|
+
stripeAccounts: await stripeAccounts.load(payments),
|
|
102
|
+
orders: await loadOrdersForBreakdown(
|
|
103
|
+
payments.flatMap(p => p.balanceItemPayments.map(bp => bp.balanceItem.orderId)),
|
|
104
|
+
),
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return new Response(builder.build(request.query.filter));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -5,12 +5,12 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
5
5
|
import { Payment } from '@stamhoofd/models';
|
|
6
6
|
import { SQL, applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
|
|
7
7
|
import type { CountFilteredRequest, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
|
|
8
|
-
import { LimitedFilteredRequest, PaginatedResponse,
|
|
8
|
+
import { LimitedFilteredRequest, PaginatedResponse, assertSort, getSortFilter } from '@stamhoofd/structures';
|
|
9
9
|
|
|
10
10
|
import type { SQLResultNamespacedRow } from '@simonbackx/simple-database';
|
|
11
11
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
12
12
|
import { Context } from '../../../../helpers/Context.js';
|
|
13
|
-
import { paymentFilterCompilers } from '../../../../sql-filters/payments.js';
|
|
13
|
+
import { getPaymentSearchFilter, paymentFilterCompilers } from '../../../../sql-filters/payments.js';
|
|
14
14
|
import { paymentSorters } from '../../../../sql-sorters/payments.js';
|
|
15
15
|
|
|
16
16
|
type Params = Record<string, never>;
|
|
@@ -69,83 +69,7 @@ export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
if (q.search) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let searchFilter: StamhoofdFilter | null = null;
|
|
75
|
-
searchFilter = {
|
|
76
|
-
$or: [
|
|
77
|
-
{
|
|
78
|
-
customer: {
|
|
79
|
-
name: {
|
|
80
|
-
$contains: q.search,
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
customer: {
|
|
86
|
-
company: {
|
|
87
|
-
name: {
|
|
88
|
-
$contains: q.search,
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
balanceItemPayments: {
|
|
95
|
-
$elemMatch: {
|
|
96
|
-
balanceItem: {
|
|
97
|
-
description: {
|
|
98
|
-
$contains: q.search,
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
transferDescription: {
|
|
106
|
-
$contains: q.search,
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
if (q.search.includes('@')) {
|
|
113
|
-
searchFilter = {
|
|
114
|
-
$or: [
|
|
115
|
-
{
|
|
116
|
-
customer: {
|
|
117
|
-
email: {
|
|
118
|
-
$contains: q.search,
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
customer: {
|
|
124
|
-
company: {
|
|
125
|
-
administrationEmail: {
|
|
126
|
-
$contains: q.search,
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
],
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const transferDescription = q.search.replaceAll('+', '').replaceAll('/', '');
|
|
136
|
-
if (transferDescription.length === '562100153542'.length && !isNaN(parseInt(transferDescription))) {
|
|
137
|
-
// Format to
|
|
138
|
-
const formatted = TransferSettings.structureOGM(transferDescription);
|
|
139
|
-
|
|
140
|
-
// Search for structured transfer
|
|
141
|
-
searchFilter = {
|
|
142
|
-
transferDescription: formatted,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (searchFilter) {
|
|
147
|
-
query.where(await compileToSQLFilter(searchFilter, filterCompilers));
|
|
148
|
-
}
|
|
72
|
+
query.where(await compileToSQLFilter(getPaymentSearchFilter(q.search), filterCompilers));
|
|
149
73
|
}
|
|
150
74
|
|
|
151
75
|
if (q instanceof LimitedFilteredRequest) {
|
|
@@ -213,7 +213,7 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
213
213
|
const balanceItemPayment = new BalanceItemPayment();
|
|
214
214
|
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
215
215
|
balanceItemPayment.paymentId = payment.id;
|
|
216
|
-
balanceItemPayment.organizationId =
|
|
216
|
+
balanceItemPayment.organizationId = payment.organizationId;
|
|
217
217
|
balanceItemPayment.price = balanceItem.price;
|
|
218
218
|
await balanceItemPayment.save();
|
|
219
219
|
|
|
@@ -20,7 +20,9 @@ export class GetPaymentEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
20
20
|
|
|
21
21
|
const params = Endpoint.parseParameters(request.url, '/payments/@id', { id: String });
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// /payments/count and /payments/breakdown are endpoints of their own, which are not guaranteed
|
|
24
|
+
// to be matched before this one
|
|
25
|
+
if (params && params.id !== 'count' && params.id !== 'breakdown') {
|
|
24
26
|
return [true, params as Params];
|
|
25
27
|
}
|
|
26
28
|
return [false];
|
|
@@ -234,7 +234,7 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
234
234
|
const balanceItemPayment = new BalanceItemPayment();
|
|
235
235
|
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
236
236
|
balanceItemPayment.paymentId = payment.id;
|
|
237
|
-
balanceItemPayment.organizationId =
|
|
237
|
+
balanceItemPayment.organizationId = payment.organizationId;
|
|
238
238
|
balanceItemPayment.price = balanceItem.price;
|
|
239
239
|
await balanceItemPayment.save();
|
|
240
240
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { BalanceItemPayment, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { applySQLSorter, compileToSQLFilter, SQL } from '@stamhoofd/sql';
|
|
4
|
+
import type { IPaginatedResponse, LimitedFilteredRequest } from '@stamhoofd/structures';
|
|
5
|
+
import { assertSort, ExcelExportType } from '@stamhoofd/structures';
|
|
6
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
7
|
+
import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
|
|
8
|
+
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
|
|
9
|
+
import { Context } from '../helpers/Context.js';
|
|
10
|
+
import { getNextPageRequest } from '../helpers/getNextPageRequest.js';
|
|
11
|
+
import { balanceItemPaymentRootFilterCompilers } from '../sql-filters/balance-item-payments-root.js';
|
|
12
|
+
import { getPaymentSearchFilter } from '../sql-filters/payments.js';
|
|
13
|
+
import { balanceItemPaymentSorters } from '../sql-sorters/balance-item-payments.js';
|
|
14
|
+
import type { PaymentWithItem } from './payments.js';
|
|
15
|
+
import { getBalanceItemPaymentColumns, PaymentGeneralWithStripeAccount } from './payments.js';
|
|
16
|
+
|
|
17
|
+
const sorters = balanceItemPaymentSorters;
|
|
18
|
+
const filterCompilers = balanceItemPaymentRootFilterCompilers;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What one payment paid for one balance item, as a row of its own.
|
|
22
|
+
*
|
|
23
|
+
* Money that is spread over several payments, or that paid for several things at once, only exists at
|
|
24
|
+
* this level: a breakdown that adds up parts of payments or of balance items selects them here, so the
|
|
25
|
+
* file holds exactly what was added up instead of the whole payments around it.
|
|
26
|
+
*
|
|
27
|
+
* A webshop order is not split into the articles that were ordered here, the way the payments export
|
|
28
|
+
* does: a row is one balance item payment, which is the unit that was counted.
|
|
29
|
+
*/
|
|
30
|
+
ExportToExcelEndpoint.loaders.set(ExcelExportType.BalanceItemPayments, {
|
|
31
|
+
fetch: async (requestQuery: LimitedFilteredRequest) => {
|
|
32
|
+
const balanceItemPayments = await fetchPage(requestQuery);
|
|
33
|
+
|
|
34
|
+
const response: IPaginatedResponse<PaymentWithItem[], LimitedFilteredRequest> = {
|
|
35
|
+
results: await toRows(balanceItemPayments),
|
|
36
|
+
next: getNextPageRequest(balanceItemPayments, requestQuery, sorters),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return response;
|
|
40
|
+
},
|
|
41
|
+
getSheets: () => [
|
|
42
|
+
{
|
|
43
|
+
id: 'balanceItemPayments',
|
|
44
|
+
name: $t(`%Ly`),
|
|
45
|
+
columns: getBalanceItemPaymentColumns(),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads one page of balance item payments, joined to both parents so a filter can reach either of them.
|
|
52
|
+
*/
|
|
53
|
+
async function fetchPage(requestQuery: LimitedFilteredRequest): Promise<BalanceItemPayment[]> {
|
|
54
|
+
const organization = Context.organization;
|
|
55
|
+
|
|
56
|
+
if (!organization) {
|
|
57
|
+
throw Context.auth.error();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!await Context.auth.canManagePayments(organization.id)) {
|
|
61
|
+
throw Context.auth.error();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const query = BalanceItemPayment.select()
|
|
65
|
+
.setMaxExecutionTime(15 * 1000)
|
|
66
|
+
.join(
|
|
67
|
+
SQL.join(SQL.table('payments')).where(
|
|
68
|
+
SQL.column('payments', 'id'),
|
|
69
|
+
SQL.column('balance_item_payments', 'paymentId'),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
.join(
|
|
73
|
+
SQL.join(SQL.table('balance_items')).where(
|
|
74
|
+
SQL.column('balance_items', 'id'),
|
|
75
|
+
SQL.column('balance_item_payments', 'balanceItemId'),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
query.where(await compileToSQLFilter({ organizationId: organization.id }, filterCompilers));
|
|
80
|
+
|
|
81
|
+
if (requestQuery.filter) {
|
|
82
|
+
query.where(await compileToSQLFilter(requestQuery.filter, filterCompilers));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (requestQuery.search) {
|
|
86
|
+
// Searching selects the same payments as the list this export started from, but a row here is
|
|
87
|
+
// one balance item payment, so the filter has to reach the payment it belongs to
|
|
88
|
+
query.where(await compileToSQLFilter({ payment: getPaymentSearchFilter(requestQuery.search) }, filterCompilers));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (requestQuery.pageFilter) {
|
|
92
|
+
query.where(await compileToSQLFilter(requestQuery.pageFilter, filterCompilers));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
requestQuery.sort = assertSort(requestQuery.sort, [{ key: 'id' }]);
|
|
96
|
+
applySQLSorter(query, requestQuery.sort, sorters);
|
|
97
|
+
query.limit(requestQuery.limit);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return await query.fetch();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.message.includes('ER_QUERY_TIMEOUT')) {
|
|
104
|
+
throw new SimpleError({
|
|
105
|
+
code: 'timeout',
|
|
106
|
+
message: 'Query took too long',
|
|
107
|
+
human: $t(`%Cv`),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pairs every balance item payment with the payment it came in with, keeping the order of the page so
|
|
116
|
+
* the next page picks up where this one stopped.
|
|
117
|
+
*/
|
|
118
|
+
async function toRows(balanceItemPayments: BalanceItemPayment[]): Promise<PaymentWithItem[]> {
|
|
119
|
+
if (balanceItemPayments.length === 0) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const paymentIds = Formatter.uniqueArray(balanceItemPayments.map(p => p.paymentId));
|
|
124
|
+
const payments = await AuthenticatedStructures.paymentsGeneral(await Payment.getByIDs(...paymentIds), true);
|
|
125
|
+
|
|
126
|
+
// One payment can pay for many rows, so it is only built once even though every row repeats it
|
|
127
|
+
const paymentsById = new Map<string, PaymentGeneralWithStripeAccount>(
|
|
128
|
+
payments.map(payment => [payment.id, PaymentGeneralWithStripeAccount.create(payment)]),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return balanceItemPayments.flatMap((balanceItemPayment) => {
|
|
132
|
+
const payment = paymentsById.get(balanceItemPayment.paymentId);
|
|
133
|
+
const detailed = payment?.balanceItemPayments.find(p => p.id === balanceItemPayment.id);
|
|
134
|
+
|
|
135
|
+
if (!payment || !detailed) {
|
|
136
|
+
// The payment was removed while the file was being written
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [{
|
|
141
|
+
payment,
|
|
142
|
+
balanceItemPayment: Object.assign(detailed, { customTitle: null }),
|
|
143
|
+
}];
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -9,7 +9,7 @@ import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEn
|
|
|
9
9
|
import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
|
|
10
10
|
import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
|
|
11
11
|
|
|
12
|
-
type PaymentWithItem = {
|
|
12
|
+
export type PaymentWithItem = {
|
|
13
13
|
payment: PaymentGeneralWithStripeAccount;
|
|
14
14
|
balanceItemPayment: PaymentExportBalanceItemPayment;
|
|
15
15
|
};
|
|
@@ -82,46 +82,54 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Payments, {
|
|
|
82
82
|
payment: data,
|
|
83
83
|
balanceItemPayment: p,
|
|
84
84
|
})),
|
|
85
|
-
columns:
|
|
86
|
-
...getBalanceItemColumns(),
|
|
87
|
-
|
|
88
|
-
// Repeating columns need to de-transform again
|
|
89
|
-
...[
|
|
90
|
-
...getGeneralColumns(),
|
|
91
|
-
...getInvoiceColumns(),
|
|
92
|
-
...getPayingOrganizationColumns(),
|
|
93
|
-
].map((c) => {
|
|
94
|
-
if ('match' in c) {
|
|
95
|
-
return {
|
|
96
|
-
...c,
|
|
97
|
-
match: (id: string) => {
|
|
98
|
-
const result = c.match(id);
|
|
99
|
-
if (!result) {
|
|
100
|
-
return result;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return result.map(cc => ({
|
|
104
|
-
...cc,
|
|
105
|
-
getValue: (object: PaymentWithItem) => {
|
|
106
|
-
return cc.getValue(object.payment);
|
|
107
|
-
},
|
|
108
|
-
}));
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
...c,
|
|
115
|
-
getValue: (object: PaymentWithItem) => {
|
|
116
|
-
return c.getValue(object.payment);
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
}),
|
|
120
|
-
],
|
|
85
|
+
columns: getBalanceItemPaymentColumns(),
|
|
121
86
|
},
|
|
122
87
|
],
|
|
123
88
|
});
|
|
124
89
|
|
|
90
|
+
/**
|
|
91
|
+
* What one payment paying one part of one balance item is written out as: what it was for, plus the
|
|
92
|
+
* payment it came in with.
|
|
93
|
+
*/
|
|
94
|
+
export function getBalanceItemPaymentColumns(): XlsxTransformerColumn<PaymentWithItem>[] {
|
|
95
|
+
return [
|
|
96
|
+
...getBalanceItemColumns(),
|
|
97
|
+
|
|
98
|
+
// Repeating columns need to de-transform again
|
|
99
|
+
...[
|
|
100
|
+
...getGeneralColumns(),
|
|
101
|
+
...getInvoiceColumns(),
|
|
102
|
+
...getPayingOrganizationColumns(),
|
|
103
|
+
].map((c) => {
|
|
104
|
+
if ('match' in c) {
|
|
105
|
+
return {
|
|
106
|
+
...c,
|
|
107
|
+
match: (id: string) => {
|
|
108
|
+
const result = c.match(id);
|
|
109
|
+
if (!result) {
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result.map(cc => ({
|
|
114
|
+
...cc,
|
|
115
|
+
getValue: (object: PaymentWithItem) => {
|
|
116
|
+
return cc.getValue(object.payment);
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
...c,
|
|
125
|
+
getValue: (object: PaymentWithItem) => {
|
|
126
|
+
return c.getValue(object.payment);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
export function expandPaymentBalanceItemPayments(
|
|
126
134
|
payment: PaymentGeneral,
|
|
127
135
|
orderMap: Map<string, PaymentExportOrder>,
|
|
@@ -317,7 +317,7 @@ export class StripeReportInvoicer {
|
|
|
317
317
|
const balanceItemPayment = new BalanceItemPayment();
|
|
318
318
|
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
319
319
|
balanceItemPayment.paymentId = payment.id;
|
|
320
|
-
balanceItemPayment.organizationId =
|
|
320
|
+
balanceItemPayment.organizationId = payment.organizationId;
|
|
321
321
|
balanceItemPayment.price = balanceItem.priceWithVAT;
|
|
322
322
|
await balanceItemPayment.save();
|
|
323
323
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import type { I18n } from '@stamhoofd/backend-i18n/I18n';
|
|
2
3
|
import type { User } from '@stamhoofd/models';
|
|
3
4
|
import { MFARecoveryCode, MFATOTP, MFAToken, Organization, Platform, RateLimiter, Token, WebauthnCredential } from '@stamhoofd/models';
|
|
4
5
|
import type { User as UserStruct } from '@stamhoofd/structures';
|
|
@@ -6,6 +7,7 @@ import { MFAChallengeResponse, MFAEnrollmentResult, MFAMethodType, MFASetupRespo
|
|
|
6
7
|
|
|
7
8
|
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
|
|
8
9
|
|
|
10
|
+
import { PasswordForgotService } from '../services/PasswordForgotService.js';
|
|
9
11
|
import { RecoveryCodeHelper } from './RecoveryCodeHelper.js';
|
|
10
12
|
import { WebauthnHelper } from './WebauthnHelper.js';
|
|
11
13
|
import { Formatter, Sorter } from '@stamhoofd/utility';
|
|
@@ -29,6 +31,34 @@ export const mfaVerificationRateLimiter = new RateLimiter({
|
|
|
29
31
|
],
|
|
30
32
|
});
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* An admin who has to enroll a second factor, but never did, first has to prove they still
|
|
36
|
+
* read the email address on the account when they were inactive for this long. A dormant
|
|
37
|
+
* account is the one most likely to have a leaked or reused password, and enrolling a
|
|
38
|
+
* factor with only that password would hand the account to whoever holds it — protected by
|
|
39
|
+
* the very second factor the organization asked for.
|
|
40
|
+
*/
|
|
41
|
+
export const INACTIVE_ADMIN_ENROLLMENT_DAYS = 45;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The confirmation email is triggered by a login attempt, so cap how often one account can
|
|
45
|
+
* send it: whoever knows the password could otherwise flood the mailbox. Like every rate
|
|
46
|
+
* limiter here this counts per process and in a window shared by all keys, so it thins out
|
|
47
|
+
* a flood rather than enforcing an exact number of emails.
|
|
48
|
+
*/
|
|
49
|
+
const enrollmentConfirmationRateLimiter = new RateLimiter({
|
|
50
|
+
limits: [
|
|
51
|
+
{
|
|
52
|
+
limit: 3,
|
|
53
|
+
duration: 60 * 60 * 1000,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
limit: 10,
|
|
57
|
+
duration: 24 * 60 * 60 * 1000,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
|
|
32
62
|
/**
|
|
33
63
|
* What still has to happen before a session may be issued for a user whose primary
|
|
34
64
|
* credential was just accepted.
|
|
@@ -36,7 +66,8 @@ export const mfaVerificationRateLimiter = new RateLimiter({
|
|
|
36
66
|
export type SecondFactorRequirement
|
|
37
67
|
= | { type: 'none' }
|
|
38
68
|
| { type: 'challenge'; challenge: MFAChallengeResponse }
|
|
39
|
-
| { type: 'setup'; setupToken: MFAToken }
|
|
69
|
+
| { type: 'setup'; setupToken: MFAToken }
|
|
70
|
+
| { type: 'confirm-email' };
|
|
40
71
|
|
|
41
72
|
export class TwoFactorHelper {
|
|
42
73
|
/**
|
|
@@ -87,21 +118,35 @@ export class TwoFactorHelper {
|
|
|
87
118
|
return false;
|
|
88
119
|
}
|
|
89
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Whether the account was unused for long enough that its password alone is no longer
|
|
123
|
+
* enough to start a forced enrollment. Accounts that never signed in fall back to their
|
|
124
|
+
* creation date, so an admin who was just invited is not locked out right away.
|
|
125
|
+
*/
|
|
126
|
+
static isInactiveForEnrollment(user: User): boolean {
|
|
127
|
+
const lastActiveAt = user.lastActiveAt ?? user.createdAt;
|
|
128
|
+
return lastActiveAt.getTime() < Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * 24 * 60 * 60 * 1000;
|
|
129
|
+
}
|
|
130
|
+
|
|
90
131
|
/**
|
|
91
132
|
* What still has to happen before a session may be handed out, after a primary
|
|
92
133
|
* credential was accepted.
|
|
93
134
|
*
|
|
94
135
|
* `loginMethod` describes the credential that was just verified:
|
|
95
|
-
* - 'password':
|
|
96
|
-
*
|
|
97
|
-
* set up here if the user does not have one yet
|
|
136
|
+
* - 'password': the account password. A single credential that says nothing about
|
|
137
|
+
* whether the user still reads the email address on the account, so a required
|
|
138
|
+
* second factor must be set up here if the user does not have one yet — and a
|
|
139
|
+
* long-inactive admin has to confirm their email address before they may.
|
|
140
|
+
* - 'email': a password token or email verification code. Also a single credential,
|
|
141
|
+
* but one that only reaches someone who reads the account's email, so it is the way
|
|
142
|
+
* out of that email confirmation.
|
|
98
143
|
* - 'sso': an external identity provider already authenticated the user, and is
|
|
99
144
|
* trusted to apply its own second factor. An enrolled factor is still verified
|
|
100
145
|
* (the user asked us to protect their account), but we do not force enrollment —
|
|
101
146
|
* unless the account ALSO has a password, because then the password remains a way
|
|
102
147
|
* in that bypasses whatever the provider enforces.
|
|
103
148
|
*/
|
|
104
|
-
static async getSecondFactorRequirement(user: User, organization: Organization | null, { loginMethod }: { loginMethod: 'password' | 'sso' }): Promise<SecondFactorRequirement> {
|
|
149
|
+
static async getSecondFactorRequirement(user: User, organization: Organization | null, { loginMethod }: { loginMethod: 'password' | 'email' | 'sso' }): Promise<SecondFactorRequirement> {
|
|
105
150
|
if (await TwoFactorHelper.userHasFactors(user.id)) {
|
|
106
151
|
return { type: 'challenge', challenge: await TwoFactorHelper.createLoginChallenge(user) };
|
|
107
152
|
}
|
|
@@ -111,17 +156,43 @@ export class TwoFactorHelper {
|
|
|
111
156
|
}
|
|
112
157
|
|
|
113
158
|
if (await TwoFactorHelper.isTwoFactorRequired(user, organization)) {
|
|
159
|
+
if (loginMethod === 'password' && TwoFactorHelper.isInactiveForEnrollment(user)) {
|
|
160
|
+
return { type: 'confirm-email' };
|
|
161
|
+
}
|
|
114
162
|
return { type: 'setup', setupToken: await MFAToken.createFor(user.id, 'setup') };
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
return { type: 'none' };
|
|
118
166
|
}
|
|
119
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Send the password recovery link a long-inactive admin needs to get back to enrolling
|
|
170
|
+
* their second factor. Never throws: the login is blocked either way, and turning a
|
|
171
|
+
* mail failure into a 500 would only hide why the user cannot sign in.
|
|
172
|
+
*/
|
|
173
|
+
static async sendEnrollmentConfirmationEmail(user: User, organization: Organization | null, i18n: I18n): Promise<void> {
|
|
174
|
+
try {
|
|
175
|
+
enrollmentConfirmationRateLimiter.track(user.id);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Sent often enough already.
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await PasswordForgotService.sendPasswordRecoveryEmail(user, organization, i18n);
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
console.error('Could not send the two-factor enrollment confirmation email', e);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
120
190
|
/**
|
|
121
191
|
* Enforce the second-factor / forced-enrollment step after a successful primary
|
|
122
192
|
* authentication (password login, password-reset token, email verification). Throws a
|
|
123
|
-
* `require_mfa` or `
|
|
124
|
-
*
|
|
193
|
+
* `require_mfa`, `require_mfa_setup` or `require_email_confirmation` error when the user
|
|
194
|
+
* must still do something before a session may be issued; returns normally when the
|
|
195
|
+
* login may proceed. The last one also emails the user the link they need to get past it.
|
|
125
196
|
*
|
|
126
197
|
* This MUST be called from every path that mints a full session from a single primary
|
|
127
198
|
* credential, otherwise that path becomes an MFA bypass. The SSO callback is a redirect
|
|
@@ -132,8 +203,19 @@ export class TwoFactorHelper {
|
|
|
132
203
|
* whoever holds the link could enroll one and get a session anyway, and the client
|
|
133
204
|
* needs a session to let the user choose a password before enrolling.
|
|
134
205
|
*/
|
|
135
|
-
static async assertSecondFactorOrThrow(user: User, organization: Organization | null, version: number, { allowTemporarySession = false }: { allowTemporarySession?: boolean }
|
|
136
|
-
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod
|
|
206
|
+
static async assertSecondFactorOrThrow(user: User, organization: Organization | null, version: number, { loginMethod, i18n, allowTemporarySession = false }: { loginMethod: 'password' | 'email'; i18n: I18n; allowTemporarySession?: boolean }): Promise<void> {
|
|
207
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod });
|
|
208
|
+
|
|
209
|
+
if (requirement.type === 'confirm-email') {
|
|
210
|
+
await TwoFactorHelper.sendEnrollmentConfirmationEmail(user, organization, i18n);
|
|
211
|
+
|
|
212
|
+
throw new SimpleError({
|
|
213
|
+
code: 'require_email_confirmation',
|
|
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() }),
|
|
216
|
+
statusCode: 403,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
137
219
|
|
|
138
220
|
if (requirement.type === 'challenge') {
|
|
139
221
|
throw new SimpleError({
|