@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.
Files changed (37) 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.test.ts +187 -3
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +1 -1
  10. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
  11. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
  16. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  17. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
  18. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
  19. package/src/excel-loaders/balance-item-payments.ts +145 -0
  20. package/src/excel-loaders/index.ts +1 -0
  21. package/src/excel-loaders/payments.ts +45 -37
  22. package/src/helpers/StripeInvoicer.ts +1 -1
  23. package/src/helpers/TwoFactorHelper.ts +91 -9
  24. package/src/helpers/breakdownRelations.ts +48 -0
  25. package/src/helpers/getNextPageRequest.ts +24 -0
  26. package/src/helpers/streamForBreakdown.test.ts +107 -0
  27. package/src/helpers/streamForBreakdown.ts +104 -0
  28. package/src/services/PasswordForgotService.ts +31 -1
  29. package/src/services/PaymentService.ts +1 -1
  30. package/src/services/SSOService.ts +14 -1
  31. package/src/sql-filters/balance-item-payments-root.ts +48 -0
  32. package/src/sql-filters/balance-items.ts +55 -0
  33. package/src/sql-filters/payment-settlement.test.ts +68 -0
  34. package/src/sql-filters/payment-settlement.ts +43 -0
  35. package/src/sql-filters/payments.ts +93 -31
  36. package/src/sql-sorters/balance-item-payments.ts +32 -0
  37. 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('Er worden al statistieken berekend, probeer het zo opnieuw.'),
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('Deze selectie bevat meer dan {limit} items, dat zijn er te veel om statistieken van te maken. Kies een kortere periode of verfijn je selectie.', {
100
+ limit: Formatter.integer(MAX_BREAKDOWN_OBJECTS),
101
+ }),
102
+ statusCode: 400,
103
+ });
104
+ }
@@ -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.
@@ -1349,7 +1349,7 @@ export class PaymentService {
1349
1349
  const balanceItemPayment = new BalanceItemPayment();
1350
1350
  balanceItemPayment.balanceItemId = balanceItem.id;
1351
1351
  balanceItemPayment.paymentId = payment.id;
1352
- balanceItemPayment.organizationId = organization.id;
1352
+ balanceItemPayment.organizationId = payment.organizationId;
1353
1353
  balanceItemPayment.price = price;
1354
1354
 
1355
1355
  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
  };
@@ -0,0 +1,68 @@
1
+ import { compileToSQLFilter } from '@stamhoofd/sql';
2
+ import { PaymentMethod, PaymentProvider, PaymentStatus, Settlement } from '@stamhoofd/structures';
3
+ import { toBalanceItemFilter } from '@stamhoofd/structures/breakdown/breakdownFilters.js';
4
+ import type { SettleablePayment } from '@stamhoofd/structures/PaymentSettlement.js';
5
+ import { ACCOUNT_DEDUCTIONS_ID, FAILED_PAYMENT_ID, getPaymentSettlement, PENDING_PAYMENT_ID } from '@stamhoofd/structures/PaymentSettlement.js';
6
+ import { balanceItemFilterCompilers } from './balance-items.js';
7
+ import { paymentFilterCompilers } from './payments.js';
8
+
9
+ describe('paymentSettlementFilterCompilers', () => {
10
+ const settlement = Settlement.create({
11
+ id: 'settlement-1',
12
+ reference: 'ST-2026-01',
13
+ settledAt: new Date(2026, 0, 15),
14
+ amount: 100_00,
15
+ });
16
+
17
+ /**
18
+ * At least one payment for every group getPaymentSettlement can hand out, so a group that gets a new
19
+ * field in its filter is covered here.
20
+ */
21
+ const payments: SettleablePayment[] = [
22
+ // Money that never arrived
23
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement: null, status: PaymentStatus.Pending },
24
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement: null, status: PaymentStatus.Failed },
25
+ // Paid out, and waiting to be paid out
26
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement, status: PaymentStatus.Succeeded },
27
+ { method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe, settlement: null, status: PaymentStatus.Succeeded },
28
+ // Online, but from a provider that tells us nothing about its payouts
29
+ { method: PaymentMethod.Payconiq, provider: PaymentProvider.Payconiq, settlement: null, status: PaymentStatus.Succeeded },
30
+ { method: PaymentMethod.CreditCard, provider: null, settlement: null, status: PaymentStatus.Succeeded },
31
+ // Never online
32
+ { method: PaymentMethod.Transfer, provider: null, settlement: null, status: PaymentStatus.Succeeded },
33
+ { method: PaymentMethod.PointOfSale, provider: null, settlement: null, status: PaymentStatus.Succeeded },
34
+ { method: PaymentMethod.AccountDeductions, provider: null, settlement: null, status: PaymentStatus.Succeeded },
35
+ ];
36
+
37
+ const groups = payments.map(payment => getPaymentSettlement(payment));
38
+
39
+ test('every payout group can be compiled against the payments table', async () => {
40
+ for (const group of groups) {
41
+ await expect(compileToSQLFilter(group.filter, paymentFilterCompilers)).resolves.toBeDefined();
42
+ }
43
+ });
44
+
45
+ test('every payout group survives being asked about the balance items it paid for', async () => {
46
+ // A balance item doesn't carry how it was paid, so the payout tab of a balance item breakdown
47
+ // selects the items through their payments. A field that only the payments table knows about
48
+ // would only break there, at runtime.
49
+ for (const group of groups) {
50
+ await expect(compileToSQLFilter(toBalanceItemFilter(group.filter), balanceItemFilterCompilers)).resolves.toBeDefined();
51
+ }
52
+ });
53
+
54
+ test('covers every kind of payout group', () => {
55
+ // Guards the fixtures above: a new kind of group has to be added here before it is covered
56
+ expect([...new Set(groups.map(group => group.id))].sort()).toEqual([
57
+ ACCOUNT_DEDUCTIONS_ID,
58
+ FAILED_PAYMENT_ID,
59
+ 'no-payout-info-none',
60
+ 'no-payout-info-' + PaymentProvider.Payconiq,
61
+ 'not-settled',
62
+ // Every method that brings money in outside a provider shares one group
63
+ 'offline',
64
+ PENDING_PAYMENT_ID,
65
+ 'settlement-' + PaymentProvider.Stripe + '-' + settlement.reference + '-' + settlement.settledAt.getTime(),
66
+ ].sort());
67
+ });
68
+ });
@@ -0,0 +1,43 @@
1
+ import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+
4
+ /**
5
+ * How a payment is selected by the payout it was part of (see PaymentSettlementGroup).
6
+ *
7
+ * Shared by the payments filters and by the balance items filters, which reach the same columns through
8
+ * the payments that paid for them, so both select exactly the same payments.
9
+ *
10
+ * All expressions are qualified with the payments table so they keep working in a subquery that joins
11
+ * payments (see balance-items.ts).
12
+ */
13
+ export const paymentSettlementFilterCompilers: SQLFilterDefinitions = {
14
+ ...baseSQLFilterCompilers,
15
+ method: createColumnFilter({
16
+ expression: SQL.column('payments', 'method'),
17
+ type: SQLValueType.String,
18
+ nullable: false,
19
+ }),
20
+ provider: createColumnFilter({
21
+ expression: SQL.column('payments', 'provider'),
22
+ type: SQLValueType.String,
23
+ nullable: true,
24
+ }),
25
+ status: createColumnFilter({
26
+ expression: SQL.column('payments', 'status'),
27
+ type: SQLValueType.String,
28
+ nullable: false,
29
+ }),
30
+ settlement: {
31
+ ...baseSQLFilterCompilers,
32
+ reference: createColumnFilter({
33
+ expression: SQL.jsonExtract(SQL.column('payments', 'settlement'), '$.value.reference'),
34
+ type: SQLValueType.JSONString,
35
+ nullable: true,
36
+ }),
37
+ settledAt: createColumnFilter({
38
+ expression: SQL.jsonExtract(SQL.column('payments', 'settlement'), '$.value.settledAt'),
39
+ type: SQLValueType.JSONDate,
40
+ nullable: true,
41
+ }),
42
+ },
43
+ };