@stamhoofd/backend 2.138.2 → 2.140.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.security.test.ts +76 -1
- package/src/endpoints/auth/MFA.test.ts +187 -3
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -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/DocumentRenderService.test.ts +72 -1
- package/src/services/PasswordForgotService.ts +31 -1
- package/src/services/PaymentService.ts +9 -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/orders.ts +6 -2
- 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/filters/orders.test.ts +635 -0
- package/tests/helpers/ExportSlice.ts +71 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BalanceItem, Order } from '@stamhoofd/models';
|
|
2
|
+
import type { OrderData } from '@stamhoofd/structures';
|
|
3
|
+
import { BalanceItemPaymentWithPrivatePayment, PrivatePayment } from '@stamhoofd/structures';
|
|
4
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A webshop order is charged as one balance item, so the articles that were ordered are only visible
|
|
8
|
+
* inside the order itself.
|
|
9
|
+
*/
|
|
10
|
+
export async function loadOrdersForBreakdown(orderIds: (string | null)[]): Promise<Map<string, OrderData>> {
|
|
11
|
+
const ids = Formatter.uniqueArray(orderIds.flatMap(id => id ? [id] : []));
|
|
12
|
+
|
|
13
|
+
if (ids.length === 0) {
|
|
14
|
+
return new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const orders = await Order.getByIDs(...ids);
|
|
18
|
+
return new Map(orders.map(order => [order.id, order.data]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The payments that paid for these balance items, per balance item id. A balance item doesn't know how
|
|
23
|
+
* it was paid, so the payouts it was part of are only visible through its payments (see
|
|
24
|
+
* PaymentSettlementGroup).
|
|
25
|
+
*/
|
|
26
|
+
export async function loadPaymentsForBreakdown(balanceItemIds: string[]): Promise<Map<string, BalanceItemPaymentWithPrivatePayment[]>> {
|
|
27
|
+
if (balanceItemIds.length === 0) {
|
|
28
|
+
return new Map();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { payments, balanceItemPayments } = await BalanceItem.loadPayments(balanceItemIds.map(id => ({ id })));
|
|
32
|
+
const paymentsById = new Map(payments.map(payment => [payment.id, PrivatePayment.create(payment)]));
|
|
33
|
+
const result = new Map<string, BalanceItemPaymentWithPrivatePayment[]>();
|
|
34
|
+
|
|
35
|
+
for (const balanceItemPayment of balanceItemPayments) {
|
|
36
|
+
const payment = paymentsById.get(balanceItemPayment.paymentId);
|
|
37
|
+
|
|
38
|
+
if (!payment) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const list = result.get(balanceItemPayment.balanceItemId) ?? [];
|
|
43
|
+
list.push(BalanceItemPaymentWithPrivatePayment.create({ ...balanceItemPayment, payment }));
|
|
44
|
+
result.set(balanceItemPayment.balanceItemId, list);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SQLSortDefinitions } from '@stamhoofd/sql';
|
|
2
|
+
import { getSortFilter, LimitedFilteredRequest } from '@stamhoofd/structures';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The request that reads the page after this one, or undefined when this was the last page.
|
|
6
|
+
*
|
|
7
|
+
* Pages are walked with a cursor built from the last object of the page instead of an offset, so a page
|
|
8
|
+
* is never skipped or read twice while the list changes underneath. A cursor that doesn't move past the
|
|
9
|
+
* one it came from would read the same page forever, so it ends the list instead.
|
|
10
|
+
*/
|
|
11
|
+
export function getNextPageRequest<T>(objects: T[], requestQuery: LimitedFilteredRequest, sorters: SQLSortDefinitions<T>): LimitedFilteredRequest | undefined {
|
|
12
|
+
if (objects.length < requestQuery.limit) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const nextFilter = getSortFilter(objects[objects.length - 1], sorters, requestQuery.sort);
|
|
17
|
+
|
|
18
|
+
if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
|
|
19
|
+
console.error('Found infinite loading loop for', requestQuery);
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new LimitedFilteredRequest({ ...requestQuery, pageFilter: nextFilter });
|
|
24
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
2
|
+
import { LimitedFilteredRequest } from '@stamhoofd/structures';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { MAX_BREAKDOWN_OBJECTS, streamForBreakdown } from './streamForBreakdown.js';
|
|
5
|
+
|
|
6
|
+
describe('streamForBreakdown', () => {
|
|
7
|
+
/**
|
|
8
|
+
* Hands out pages of numbers, the way an endpoint hands out pages of objects: where to continue is
|
|
9
|
+
* carried by the request itself, so a page is only skipped or read twice when the caller loses it.
|
|
10
|
+
*/
|
|
11
|
+
const createFetcher = (totalObjects: number) => {
|
|
12
|
+
const requests: LimitedFilteredRequest[] = [];
|
|
13
|
+
|
|
14
|
+
const fetch = (request: LimitedFilteredRequest) => {
|
|
15
|
+
requests.push(request);
|
|
16
|
+
|
|
17
|
+
const start = (request.pageFilter as { id: { $gt: number } } | null)?.id.$gt ?? 0;
|
|
18
|
+
const results = Array.from({ length: Math.min(request.limit, Math.max(0, totalObjects - start)) }, (_, index) => start + index);
|
|
19
|
+
const end = start + results.length;
|
|
20
|
+
|
|
21
|
+
return Promise.resolve({
|
|
22
|
+
results,
|
|
23
|
+
next: end < totalObjects
|
|
24
|
+
? new LimitedFilteredRequest({
|
|
25
|
+
filter: request.filter,
|
|
26
|
+
search: request.search,
|
|
27
|
+
sort: request.sort,
|
|
28
|
+
limit: request.limit,
|
|
29
|
+
pageFilter: { id: { $gt: end } },
|
|
30
|
+
})
|
|
31
|
+
: undefined,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return { fetch, requests };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const stream = async (totalObjects: number, selection: { filter?: StamhoofdFilter; search?: string | null } = {}) => {
|
|
39
|
+
const { fetch, requests } = createFetcher(totalObjects);
|
|
40
|
+
const handled: number[] = [];
|
|
41
|
+
const countRequests: { filter: StamhoofdFilter; search: string | null }[] = [];
|
|
42
|
+
|
|
43
|
+
await streamForBreakdown<number>({
|
|
44
|
+
userId: uuidv4(),
|
|
45
|
+
filter: selection.filter ?? null,
|
|
46
|
+
search: selection.search ?? null,
|
|
47
|
+
count: (request) => {
|
|
48
|
+
countRequests.push({ filter: request.filter, search: request.search });
|
|
49
|
+
return Promise.resolve(totalObjects);
|
|
50
|
+
},
|
|
51
|
+
fetch,
|
|
52
|
+
handle: (results) => {
|
|
53
|
+
handled.push(...results);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { handled, requests, countRequests };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
test('every object is handed over once, page by page', async () => {
|
|
61
|
+
const { handled, requests } = await stream(250);
|
|
62
|
+
|
|
63
|
+
// No object is read twice or skipped, whatever the pages look like
|
|
64
|
+
expect(handled).toEqual(Array.from({ length: 250 }, (_, index) => index));
|
|
65
|
+
|
|
66
|
+
// Read in pages, sorted so the pages line up
|
|
67
|
+
expect(requests.length).toBeGreaterThan(1);
|
|
68
|
+
expect(requests[0].sort.map(s => s.key)).toEqual(['id']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the selection is read exactly as it was asked for', async () => {
|
|
72
|
+
const filter = { status: 'Succeeded' };
|
|
73
|
+
const { requests, countRequests } = await stream(250, { filter, search: 'Peeters' });
|
|
74
|
+
|
|
75
|
+
// Every page reads the same selection, so a page never holds objects the breakdown did not count
|
|
76
|
+
for (const request of requests) {
|
|
77
|
+
expect(request.filter).toEqual(filter);
|
|
78
|
+
expect(request.search).toBe('Peeters');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(countRequests).toEqual([{ filter, search: 'Peeters' }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('an empty selection hands over nothing', async () => {
|
|
85
|
+
const { handled } = await stream(0);
|
|
86
|
+
expect(handled).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a selection that is too large to break down asks the user to narrow it down', async () => {
|
|
90
|
+
await expect(stream(MAX_BREAKDOWN_OBJECTS + 1)).rejects.toThrow('Too many objects to break down');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a selection that is too large is refused before it is read', async () => {
|
|
94
|
+
const { fetch, requests } = createFetcher(MAX_BREAKDOWN_OBJECTS + 1);
|
|
95
|
+
|
|
96
|
+
await expect(streamForBreakdown<number>({
|
|
97
|
+
userId: uuidv4(),
|
|
98
|
+
filter: null,
|
|
99
|
+
search: null,
|
|
100
|
+
count: () => Promise.resolve(MAX_BREAKDOWN_OBJECTS + 1),
|
|
101
|
+
fetch,
|
|
102
|
+
handle: () => {},
|
|
103
|
+
})).rejects.toThrow('Too many objects to break down');
|
|
104
|
+
|
|
105
|
+
expect(requests).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { RateLimiter } from '@stamhoofd/models';
|
|
3
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
4
|
+
import type { CountFilteredRequest, IPaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
+
import { LimitedFilteredRequest, SortItemDirection } from '@stamhoofd/structures';
|
|
6
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
7
|
+
import { fetchToAsyncIterator } from './fetchToAsyncIterator.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A breakdown groups the objects one by one instead of in SQL, so the grouping rules are the same ones
|
|
11
|
+
* the rest of the app uses. That means every object has to be read, which is the same work an Excel
|
|
12
|
+
* export does.
|
|
13
|
+
*
|
|
14
|
+
* They are read page by page and added up right away, so they are never all in memory at once. Above
|
|
15
|
+
* this many objects a breakdown takes too long to wait for, so we ask the user to narrow down instead.
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_BREAKDOWN_OBJECTS = 10000;
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 100;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Breakdowns are read-only, but they read a lot. They are protected the same way exports are: a limit
|
|
23
|
+
* per user, one at a time per user and a maximum number running at the same time.
|
|
24
|
+
*/
|
|
25
|
+
export const breakdownLimiter = new RateLimiter({
|
|
26
|
+
limits: [
|
|
27
|
+
{
|
|
28
|
+
limit: 100,
|
|
29
|
+
duration: 5 * 60 * 1000,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
limit: 1000,
|
|
33
|
+
duration: 60 * 1000 * 60 * 24,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads every object matching the filter, page by page, and hands each page over to be added up.
|
|
40
|
+
*/
|
|
41
|
+
export async function streamForBreakdown<T>(options: {
|
|
42
|
+
userId: string;
|
|
43
|
+
filter: StamhoofdFilter;
|
|
44
|
+
search: string | null;
|
|
45
|
+
count: (request: CountFilteredRequest) => Promise<number>;
|
|
46
|
+
fetch: (request: LimitedFilteredRequest) => Promise<IPaginatedResponse<T[], LimitedFilteredRequest>>;
|
|
47
|
+
handle: (results: T[]) => Promise<void> | void;
|
|
48
|
+
}): Promise<void> {
|
|
49
|
+
// Clicking through the tabs of a breakdown would otherwise stack up requests that each block a
|
|
50
|
+
// connection until the one before it is done
|
|
51
|
+
if (QueueHandler.isRunning('breakdown-' + options.userId)) {
|
|
52
|
+
throw new SimpleError({
|
|
53
|
+
code: 'breakdown_pending',
|
|
54
|
+
message: 'A breakdown is already running for this user',
|
|
55
|
+
human: $t('%Zj3'),
|
|
56
|
+
statusCode: 429,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
breakdownLimiter.track(options.userId, 1);
|
|
61
|
+
|
|
62
|
+
// One breakdown per user at a time, and never more than 5 for all users together
|
|
63
|
+
await QueueHandler.schedule('breakdown-' + options.userId, async () => {
|
|
64
|
+
await QueueHandler.schedule('breakdown', async () => {
|
|
65
|
+
const request = new LimitedFilteredRequest({
|
|
66
|
+
filter: options.filter,
|
|
67
|
+
search: options.search,
|
|
68
|
+
limit: PAGE_SIZE,
|
|
69
|
+
sort: [
|
|
70
|
+
{ key: 'id', order: SortItemDirection.ASC },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Asked up front so a selection that is too big costs one query instead of reading it all
|
|
75
|
+
assertBreakdownSize(await options.count(request));
|
|
76
|
+
|
|
77
|
+
let count = 0;
|
|
78
|
+
|
|
79
|
+
for await (const results of fetchToAsyncIterator(request, { fetch: options.fetch })) {
|
|
80
|
+
count += results.length;
|
|
81
|
+
|
|
82
|
+
// The selection can still have grown since it was counted
|
|
83
|
+
assertBreakdownSize(count);
|
|
84
|
+
|
|
85
|
+
await options.handle(results);
|
|
86
|
+
}
|
|
87
|
+
}, 5);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assertBreakdownSize(count: number) {
|
|
92
|
+
if (count <= MAX_BREAKDOWN_OBJECTS) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new SimpleError({
|
|
97
|
+
code: 'too_many_objects',
|
|
98
|
+
message: 'Too many objects to break down',
|
|
99
|
+
human: $t('%Ziw', {
|
|
100
|
+
limit: Formatter.integer(MAX_BREAKDOWN_OBJECTS),
|
|
101
|
+
}),
|
|
102
|
+
statusCode: 400,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import { Document, DocumentTemplateFactory, Organization } from '@stamhoofd/models';
|
|
3
|
-
import {
|
|
3
|
+
import { render } from '@stamhoofd/models/helpers/Handlebars.js';
|
|
4
|
+
import type { RecordAnswer } from '@stamhoofd/structures';
|
|
5
|
+
import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordCheckboxAnswer, RecordDateAnswer, RecordPriceAnswer, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
|
|
4
6
|
import { Country } from '@stamhoofd/types/Country';
|
|
7
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
8
|
import { DocumentRenderService } from './DocumentRenderService.js';
|
|
6
9
|
|
|
7
10
|
function createImage(id: string) {
|
|
@@ -66,6 +69,49 @@ function createInvalidFieldAnswers() {
|
|
|
66
69
|
|
|
67
70
|
const xmlExport = '<documents>{{#each documents}}<document>{{{this.number}}}</document>{{/each}}</documents>';
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The day price row of the participation template (templates/participation.html in the dashboard).
|
|
74
|
+
*/
|
|
75
|
+
const dayPriceTemplate = '{{#if (and registration.showDayPrice (coalesce registration.price registration.priceOriginal 0)) }}'
|
|
76
|
+
+ 'Bedrag per dag: {{ formatPrice (div (coalesce registration.price registration.priceOriginal 0) (coalesce registration.days (days registration.startDate registration.endDate))) round=true }}'
|
|
77
|
+
+ '{{/if}}';
|
|
78
|
+
|
|
79
|
+
function createParticipationDocument(answers: { showDayPrice: boolean; price?: number | null; priceOriginal?: number | null; startDate?: Date; endDate?: Date }) {
|
|
80
|
+
const settingsFor = (id: string, type: RecordType) => RecordSettings.create({ id, type });
|
|
81
|
+
|
|
82
|
+
const fieldAnswers = new Map<string, RecordAnswer>([
|
|
83
|
+
['registration.showDayPrice', RecordCheckboxAnswer.create({
|
|
84
|
+
settings: settingsFor('registration.showDayPrice', RecordType.Checkbox),
|
|
85
|
+
selected: answers.showDayPrice,
|
|
86
|
+
})],
|
|
87
|
+
['registration.price', RecordPriceAnswer.create({
|
|
88
|
+
settings: settingsFor('registration.price', RecordType.Price),
|
|
89
|
+
value: answers.price ?? null,
|
|
90
|
+
})],
|
|
91
|
+
['registration.priceOriginal', RecordPriceAnswer.create({
|
|
92
|
+
settings: settingsFor('registration.priceOriginal', RecordType.Price),
|
|
93
|
+
value: answers.priceOriginal ?? null,
|
|
94
|
+
})],
|
|
95
|
+
['registration.startDate', RecordDateAnswer.create({
|
|
96
|
+
settings: settingsFor('registration.startDate', RecordType.Date),
|
|
97
|
+
dateValue: answers.startDate ?? new Date(2024, 6, 1),
|
|
98
|
+
})],
|
|
99
|
+
['registration.endDate', RecordDateAnswer.create({
|
|
100
|
+
settings: settingsFor('registration.endDate', RecordType.Date),
|
|
101
|
+
dateValue: answers.endDate ?? new Date(2024, 6, 7),
|
|
102
|
+
})],
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
const document = createDocument();
|
|
106
|
+
document.data.fieldAnswers = fieldAnswers;
|
|
107
|
+
return document;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function renderDayPrice(answers: Parameters<typeof createParticipationDocument>[0]) {
|
|
111
|
+
const context = DocumentRenderService.buildDocumentContext(createParticipationDocument(answers), createOrganization(), createPlatform({}));
|
|
112
|
+
return await render(dayPriceTemplate, context);
|
|
113
|
+
}
|
|
114
|
+
|
|
69
115
|
/**
|
|
70
116
|
* Builds a locked template: a locked template makes buildAll return the documents unchanged, so the
|
|
71
117
|
* numbers set up here are exactly what the renumbering logic gets to see.
|
|
@@ -150,6 +196,31 @@ describe('DocumentRenderService', () => {
|
|
|
150
196
|
});
|
|
151
197
|
});
|
|
152
198
|
|
|
199
|
+
describe('day price', () => {
|
|
200
|
+
// 1 July until 7 July are 7 days, so €25 is rounded to €3,57 per day
|
|
201
|
+
const dayPriceOf25 = 'Bedrag per dag: ' + Formatter.price(3_5700);
|
|
202
|
+
|
|
203
|
+
test('It divides the price by the amount of days, both start and end date included', async () => {
|
|
204
|
+
await expect(renderDayPrice({ showDayPrice: true, price: 25_0000 })).resolves.toBe(dayPriceOf25);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('It uses the original price when the price itself is hidden on the document', async () => {
|
|
208
|
+
await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: 25_0000 })).resolves.toBe(dayPriceOf25);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('It prefers the price of the document over the original price', async () => {
|
|
212
|
+
await expect(renderDayPrice({ showDayPrice: true, price: 14_0000, priceOriginal: 25_0000 })).resolves.toBe('Bedrag per dag: ' + Formatter.price(2_0000));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('It is left out when the checkbox is not selected', async () => {
|
|
216
|
+
await expect(renderDayPrice({ showDayPrice: false, price: 25_0000 })).resolves.toBe('');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('It is left out when no price is known', async () => {
|
|
220
|
+
await expect(renderDayPrice({ showDayPrice: true, price: null, priceOriginal: null })).resolves.toBe('');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
153
224
|
describe('getRenderedHtml', () => {
|
|
154
225
|
test('It returns null and logs when building the context fails', async () => {
|
|
155
226
|
const template = await new DocumentTemplateFactory({ groups: [] }).create();
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { I18n } from '@stamhoofd/backend-i18n';
|
|
2
2
|
import type { Organization } from '@stamhoofd/models';
|
|
3
3
|
import { PasswordToken, Platform, User } from '@stamhoofd/models';
|
|
4
|
-
import { getAppHost } from '@stamhoofd/structures';
|
|
4
|
+
import { EmailTemplateType, getAppHost, Recipient, Replacement } from '@stamhoofd/structures';
|
|
5
|
+
|
|
6
|
+
import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Builds the password recovery links. Which app the link points to depends on whether the user is an
|
|
@@ -17,6 +19,34 @@ export class PasswordForgotService {
|
|
|
17
19
|
return await this.getPasswordRecoveryUrlForToken(token, organization, i18n, user);
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Create a new password token and email the recovery link to the user. Always sent to the
|
|
24
|
+
* stored address, never to whatever the request asked for.
|
|
25
|
+
*/
|
|
26
|
+
static async sendPasswordRecoveryEmail(user: User, organization: Organization | null, i18n: I18n) {
|
|
27
|
+
const recoveryUrl = await this.getPasswordRecoveryUrl(user, organization, i18n);
|
|
28
|
+
|
|
29
|
+
await sendEmailTemplate(organization, {
|
|
30
|
+
recipients: [
|
|
31
|
+
Recipient.create({
|
|
32
|
+
firstName: user.firstName,
|
|
33
|
+
lastName: user.lastName,
|
|
34
|
+
email: user.email,
|
|
35
|
+
replacements: [
|
|
36
|
+
Replacement.create({
|
|
37
|
+
token: 'resetUrl',
|
|
38
|
+
value: recoveryUrl,
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
template: {
|
|
44
|
+
type: EmailTemplateType.ForgotPassword,
|
|
45
|
+
},
|
|
46
|
+
type: 'transactional',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
/**
|
|
21
51
|
* Build the recovery url for an already created password token.
|
|
22
52
|
* Pass the user to avoid an extra query when it is already loaded.
|
|
@@ -517,6 +517,14 @@ export class PaymentService {
|
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
// DirectDebit expires after 3 weeks
|
|
521
|
+
if ((status === PaymentStatus.Pending || status === PaymentStatus.Created) && payment.method === PaymentMethod.DirectDebit) {
|
|
522
|
+
// If payment is not succeeded after one day, mark as failed
|
|
523
|
+
if (payment.createdAt < new Date(new Date().getTime() - 60 * 1000 * 60 * 24 * 7 * 3)) {
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
520
528
|
if (STAMHOOFD.environment === 'development') {
|
|
521
529
|
// In development, we expire all direct debits and other paymetns after 1 hour, because they need manual changes
|
|
522
530
|
// otherwise they will remain stuck in the dev environment, poluting the UI
|
|
@@ -1349,7 +1357,7 @@ export class PaymentService {
|
|
|
1349
1357
|
const balanceItemPayment = new BalanceItemPayment();
|
|
1350
1358
|
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
1351
1359
|
balanceItemPayment.paymentId = payment.id;
|
|
1352
|
-
balanceItemPayment.organizationId =
|
|
1360
|
+
balanceItemPayment.organizationId = payment.organizationId;
|
|
1353
1361
|
balanceItemPayment.price = price;
|
|
1354
1362
|
|
|
1355
1363
|
await balanceItemPayment.save();
|
|
@@ -707,7 +707,7 @@ export class SSOServiceWithSession {
|
|
|
707
707
|
// enrollment endpoints check this themselves.
|
|
708
708
|
redirectUri.searchParams.set('oid_mfa_passkeys', user.canUsePasskeys() ? '1' : '0');
|
|
709
709
|
redirectUri.searchParams.set('s', session.spaState);
|
|
710
|
-
} else {
|
|
710
|
+
} else if (requirement.type === 'none') {
|
|
711
711
|
const token = await Token.createExpiredToken(user);
|
|
712
712
|
|
|
713
713
|
if (!token) {
|
|
@@ -722,6 +722,19 @@ export class SSOServiceWithSession {
|
|
|
722
722
|
const st = new TokenStruct(token);
|
|
723
723
|
redirectUri.searchParams.set('oid_rt', st.refreshToken);
|
|
724
724
|
redirectUri.searchParams.set('s', session.spaState);
|
|
725
|
+
} else if (requirement.type === 'confirm-email') {
|
|
726
|
+
// Only password logins reach this: an SSO login is not the credential
|
|
727
|
+
// that has to be confirmed. Fail closed rather than fall through to a
|
|
728
|
+
// session, which would make this redirect an enrollment bypass.
|
|
729
|
+
throw new SimpleError({
|
|
730
|
+
code: 'error',
|
|
731
|
+
message: 'Email confirmation is not supported for SSO logins',
|
|
732
|
+
statusCode: 500,
|
|
733
|
+
});
|
|
734
|
+
} else {
|
|
735
|
+
// Compile error when a requirement is added without handling it here.
|
|
736
|
+
const unsupported: never = requirement;
|
|
737
|
+
throw new Error('Unsupported second factor requirement: ' + JSON.stringify(unsupported));
|
|
725
738
|
}
|
|
726
739
|
} else if (this.session.reauthenticateAccessToken) {
|
|
727
740
|
// The provider confirmed the identity of the user that is already signed in,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SQLFilterDefinitions } from '@stamhoofd/sql';
|
|
2
|
+
import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
|
|
3
|
+
import { balanceItemPaymentsCompilers } from './balance-item-payments.js';
|
|
4
|
+
import { paymentFilterCompilers } from './payments.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How a balance item payment is selected when it is queried directly, instead of through the payment or
|
|
8
|
+
* the balance item it belongs to.
|
|
9
|
+
*
|
|
10
|
+
* This is the level at which money that is spread over several payments, or that paid for several
|
|
11
|
+
* things at once, can be selected exactly: one row is one payment paying one part of one balance item.
|
|
12
|
+
*
|
|
13
|
+
* Lives in its own file because it reaches both parents, while balance-item-payments.ts is used inside
|
|
14
|
+
* a subquery that only joins the balance items.
|
|
15
|
+
*/
|
|
16
|
+
export const balanceItemPaymentRootFilterCompilers: SQLFilterDefinitions = {
|
|
17
|
+
...baseSQLFilterCompilers,
|
|
18
|
+
// id, price and the balance item filters
|
|
19
|
+
...balanceItemPaymentsCompilers,
|
|
20
|
+
|
|
21
|
+
organizationId: createColumnFilter({
|
|
22
|
+
expression: SQL.column('balance_item_payments', 'organizationId'),
|
|
23
|
+
type: SQLValueType.String,
|
|
24
|
+
nullable: false,
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
paymentId: createColumnFilter({
|
|
28
|
+
expression: SQL.column('balance_item_payments', 'paymentId'),
|
|
29
|
+
type: SQLValueType.String,
|
|
30
|
+
nullable: false,
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
balanceItemId: createColumnFilter({
|
|
34
|
+
expression: SQL.column('balance_item_payments', 'balanceItemId'),
|
|
35
|
+
type: SQLValueType.String,
|
|
36
|
+
nullable: false,
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
createdAt: createColumnFilter({
|
|
40
|
+
expression: SQL.column('balance_item_payments', 'createdAt'),
|
|
41
|
+
type: SQLValueType.Datetime,
|
|
42
|
+
nullable: false,
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
// The same compilers the payments use, which are qualified with the payments table so they keep
|
|
46
|
+
// working here: this query joins it instead of selecting from it
|
|
47
|
+
payment: paymentFilterCompilers,
|
|
48
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SQLFilterDefinitions } from '@stamhoofd/sql';
|
|
2
2
|
import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
|
|
3
3
|
import { BalanceItemRelationType } from '@stamhoofd/structures';
|
|
4
|
+
import { paymentSettlementFilterCompilers } from './payment-settlement.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Filters on the relations of a balance item (registration group, webshop order and membership type).
|
|
@@ -52,6 +53,29 @@ export const balanceItemRelationFilterCompilers: SQLFilterDefinitions = {
|
|
|
52
53
|
type: SQLValueType.JSONString,
|
|
53
54
|
nullable: true,
|
|
54
55
|
}),
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Filter on the id of any relation, e.g. { relations: { Group: { id: '...' } } }.
|
|
59
|
+
*
|
|
60
|
+
* Used to narrow down to one category of a breakdown (see PaymentBreakdown), so every kind of
|
|
61
|
+
* metadata a balance item carries is filterable without a dedicated compiler per relation.
|
|
62
|
+
*/
|
|
63
|
+
relations: {
|
|
64
|
+
...baseSQLFilterCompilers,
|
|
65
|
+
...Object.fromEntries(
|
|
66
|
+
Object.values(BalanceItemRelationType).map(relationType => [
|
|
67
|
+
relationType,
|
|
68
|
+
{
|
|
69
|
+
...baseSQLFilterCompilers,
|
|
70
|
+
id: createColumnFilter({
|
|
71
|
+
expression: SQL.jsonExtract(SQL.column('balance_items', 'relations'), `$.value.${relationType}.id`),
|
|
72
|
+
type: SQLValueType.JSONString,
|
|
73
|
+
nullable: true,
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
]),
|
|
77
|
+
),
|
|
78
|
+
},
|
|
55
79
|
};
|
|
56
80
|
|
|
57
81
|
/**
|
|
@@ -113,4 +137,35 @@ export const balanceItemFilterCompilers: SQLFilterDefinitions = {
|
|
|
113
137
|
type: SQLValueType.Number,
|
|
114
138
|
nullable: false,
|
|
115
139
|
}),
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The payments that paid (a part of) this balance item, e.g.
|
|
143
|
+
* `{ payments: { $elemMatch: { payment: { settlement: { reference: '...' } } } } }`.
|
|
144
|
+
*
|
|
145
|
+
* Used to narrow a breakdown down to the balance items that were part of one payout (see
|
|
146
|
+
* PaymentSettlementGroup): a balance item doesn't know how it was paid, its payments do.
|
|
147
|
+
*/
|
|
148
|
+
payments: createExistsFilter(
|
|
149
|
+
SQL.select()
|
|
150
|
+
.from(SQL.table('balance_item_payments'))
|
|
151
|
+
.join(
|
|
152
|
+
SQL.join(SQL.table('payments')).where(
|
|
153
|
+
SQL.column('payments', 'id'),
|
|
154
|
+
SQL.column('balance_item_payments', 'paymentId'),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
.where(
|
|
158
|
+
SQL.column('balance_item_payments', 'balanceItemId'),
|
|
159
|
+
SQL.column('balance_items', 'id'),
|
|
160
|
+
),
|
|
161
|
+
{
|
|
162
|
+
...baseSQLFilterCompilers,
|
|
163
|
+
price: createColumnFilter({
|
|
164
|
+
expression: SQL.column('balance_item_payments', 'price'),
|
|
165
|
+
type: SQLValueType.Number,
|
|
166
|
+
nullable: false,
|
|
167
|
+
}),
|
|
168
|
+
payment: paymentSettlementFilterCompilers,
|
|
169
|
+
},
|
|
170
|
+
),
|
|
116
171
|
};
|
|
@@ -30,13 +30,17 @@ export const orderFilterCompilers: SQLFilterDefinitions = {
|
|
|
30
30
|
nullable: false,
|
|
31
31
|
}),
|
|
32
32
|
timeSlotEndTime: createColumnFilter({
|
|
33
|
+
// Stored as a number (minutes since midnight), so it must be typed as a JSON number to allow
|
|
34
|
+
// numeric comparisons and to match the in-memory filter.
|
|
33
35
|
expression: SQL.jsonExtract(SQL.column('data'), '$.value.timeSlot.endTime'),
|
|
34
|
-
type: SQLValueType.
|
|
36
|
+
type: SQLValueType.JSONNumber,
|
|
35
37
|
nullable: true,
|
|
36
38
|
}),
|
|
37
39
|
timeSlotStartTime: createColumnFilter({
|
|
40
|
+
// Stored as a number (minutes since midnight), so it must be typed as a JSON number to allow
|
|
41
|
+
// numeric comparisons and to match the in-memory filter.
|
|
38
42
|
expression: SQL.jsonExtract(SQL.column('data'), '$.value.timeSlot.startTime'),
|
|
39
|
-
type: SQLValueType.
|
|
43
|
+
type: SQLValueType.JSONNumber,
|
|
40
44
|
nullable: true,
|
|
41
45
|
}),
|
|
42
46
|
createdAt: createColumnFilter({
|