@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.
Files changed (41) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +165 -0
  3. package/src/crons/fake-settlements.ts +117 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
  6. package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
  8. package/src/endpoints/auth/MFA.security.test.ts +76 -1
  9. package/src/endpoints/auth/MFA.test.ts +187 -3
  10. package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -1
  11. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
  13. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
  17. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  18. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
  19. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
  20. package/src/excel-loaders/balance-item-payments.ts +145 -0
  21. package/src/excel-loaders/index.ts +1 -0
  22. package/src/excel-loaders/payments.ts +45 -37
  23. package/src/helpers/StripeInvoicer.ts +1 -1
  24. package/src/helpers/TwoFactorHelper.ts +91 -9
  25. package/src/helpers/breakdownRelations.ts +48 -0
  26. package/src/helpers/getNextPageRequest.ts +24 -0
  27. package/src/helpers/streamForBreakdown.test.ts +107 -0
  28. package/src/helpers/streamForBreakdown.ts +104 -0
  29. package/src/services/DocumentRenderService.test.ts +72 -1
  30. package/src/services/PasswordForgotService.ts +31 -1
  31. package/src/services/PaymentService.ts +9 -1
  32. package/src/services/SSOService.ts +14 -1
  33. package/src/sql-filters/balance-item-payments-root.ts +48 -0
  34. package/src/sql-filters/balance-items.ts +55 -0
  35. package/src/sql-filters/orders.ts +6 -2
  36. package/src/sql-filters/payment-settlement.test.ts +68 -0
  37. package/src/sql-filters/payment-settlement.ts +43 -0
  38. package/src/sql-filters/payments.ts +93 -31
  39. package/src/sql-sorters/balance-item-payments.ts +32 -0
  40. package/tests/filters/orders.test.ts +635 -0
  41. 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 { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
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 = organization.id;
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.JSONString,
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.JSONString,
43
+ type: SQLValueType.JSONNumber,
40
44
  nullable: true,
41
45
  }),
42
46
  createdAt: createColumnFilter({