@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,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 =
|
|
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
|
+
};
|