@stamhoofd/backend 2.134.0 → 2.136.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 +18 -17
- package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
- package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
- package/src/crons/index.ts +1 -0
- package/src/email-recipient-loaders/orders.ts +7 -9
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
- package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
- package/src/excel-loaders/members.test.ts +59 -0
- package/src/excel-loaders/members.ts +17 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -0
- package/src/helpers/GlobalHelper.ts +1 -1
- package/src/helpers/MemberMerger.test.ts +70 -2
- package/src/helpers/MemberMerger.ts +43 -1
- package/src/helpers/MemberUserSyncer.ts +5 -0
- package/src/helpers/MembershipCharger.test.ts +110 -0
- package/src/helpers/MembershipCharger.ts +12 -33
- package/src/helpers/UitpasTokenRepository.ts +12 -11
- package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
- package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
- package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
- package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
- package/src/services/BalanceItemService.ts +1 -1
- package/src/services/PaymentService.ts +33 -8
- package/src/services/PlatformMembershipService.ts +2 -1
- package/src/services/RegistrationService.ts +5 -0
- package/src/services/uitpas/UitpasService.ts +3 -4
- package/src/sql-filters/members.ts +10 -0
- package/src/sql-filters/orders.ts +5 -0
- package/src/sql-sorters/members.ts +12 -1
- package/vitest.config.js +1 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Endpoint } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
3
|
-
import type { RegistrationPeriod } from '@stamhoofd/models';
|
|
3
|
+
import type { MemberWithUsersRegistrationsAndGroups, RegistrationPeriod } from '@stamhoofd/models';
|
|
4
4
|
import { EventFactory, GroupFactory, MemberFactory, OrganizationFactory, RecordCategoryFactory, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
5
|
-
import {
|
|
5
|
+
import type { SortList } from '@stamhoofd/structures';
|
|
6
|
+
import { AccessRight, EventMeta, GroupType, LimitedFilteredRequest, NamedObject, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, RecordAnswer, RecordDateAnswer, RecordTextAnswer, RecordType, ResourcePermissions, SortItemDirection } from '@stamhoofd/structures';
|
|
6
7
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
7
8
|
import { GetMembersEndpoint } from './GetMembersEndpoint.js';
|
|
8
9
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
@@ -1445,6 +1446,124 @@ describe('Endpoint.GetMembersEndpoint', () => {
|
|
|
1445
1446
|
});
|
|
1446
1447
|
|
|
1447
1448
|
describe('Record answer filtering', () => {
|
|
1449
|
+
test('A user can filter members on the date of a date record answer', async () => {
|
|
1450
|
+
const resources = new Map();
|
|
1451
|
+
|
|
1452
|
+
const organization = await new OrganizationFactory({ period, roles: [] })
|
|
1453
|
+
.create();
|
|
1454
|
+
|
|
1455
|
+
const recordCategory = await new RecordCategoryFactory({
|
|
1456
|
+
records: [
|
|
1457
|
+
{
|
|
1458
|
+
type: RecordType.Date,
|
|
1459
|
+
},
|
|
1460
|
+
],
|
|
1461
|
+
}).create();
|
|
1462
|
+
|
|
1463
|
+
await initPlatformRecordCategory({ recordCategory });
|
|
1464
|
+
const record = recordCategory.records[0];
|
|
1465
|
+
|
|
1466
|
+
const user = await new UserFactory({
|
|
1467
|
+
organization,
|
|
1468
|
+
permissions: Permissions.create({
|
|
1469
|
+
level: PermissionLevel.None,
|
|
1470
|
+
roles: [],
|
|
1471
|
+
resources,
|
|
1472
|
+
}),
|
|
1473
|
+
})
|
|
1474
|
+
.create();
|
|
1475
|
+
|
|
1476
|
+
const token = await Token.createToken(user);
|
|
1477
|
+
|
|
1478
|
+
// The member we are looking for: answered with a time of day that is not midnight
|
|
1479
|
+
const member1 = await new MemberFactory({}).create();
|
|
1480
|
+
const answer1 = RecordDateAnswer.create({ settings: record });
|
|
1481
|
+
answer1.dateValue = new Date(2023, 5, 10, 14, 30, 15);
|
|
1482
|
+
member1.details.recordAnswers.set(record.id, answer1);
|
|
1483
|
+
await member1.save();
|
|
1484
|
+
|
|
1485
|
+
// Answered with a different date
|
|
1486
|
+
const member2 = await new MemberFactory({}).create();
|
|
1487
|
+
const answer2 = RecordDateAnswer.create({ settings: record });
|
|
1488
|
+
answer2.dateValue = new Date(2023, 5, 11, 14, 30, 15);
|
|
1489
|
+
member2.details.recordAnswers.set(record.id, answer2);
|
|
1490
|
+
await member2.save();
|
|
1491
|
+
|
|
1492
|
+
// Did not answer the question
|
|
1493
|
+
const member3 = await new MemberFactory({}).create();
|
|
1494
|
+
|
|
1495
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
1496
|
+
|
|
1497
|
+
resources.set(
|
|
1498
|
+
PermissionsResourceType.Groups, new Map([[
|
|
1499
|
+
group.id,
|
|
1500
|
+
ResourcePermissions.create({
|
|
1501
|
+
level: PermissionLevel.Read,
|
|
1502
|
+
accessRights: [],
|
|
1503
|
+
}),
|
|
1504
|
+
]]),
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
resources.set(
|
|
1508
|
+
PermissionsResourceType.RecordCategories, new Map([[
|
|
1509
|
+
recordCategory.id,
|
|
1510
|
+
ResourcePermissions.create({
|
|
1511
|
+
level: PermissionLevel.Read,
|
|
1512
|
+
accessRights: [],
|
|
1513
|
+
}),
|
|
1514
|
+
]]),
|
|
1515
|
+
);
|
|
1516
|
+
|
|
1517
|
+
await user.save();
|
|
1518
|
+
|
|
1519
|
+
await new RegistrationFactory({ member: member1, group }).create();
|
|
1520
|
+
await new RegistrationFactory({ member: member2, group }).create();
|
|
1521
|
+
await new RegistrationFactory({ member: member3, group }).create();
|
|
1522
|
+
|
|
1523
|
+
const request = Request.get({
|
|
1524
|
+
path: baseUrl,
|
|
1525
|
+
host: organization.getApiHost(),
|
|
1526
|
+
query: new LimitedFilteredRequest({
|
|
1527
|
+
filter: {
|
|
1528
|
+
registrations: {
|
|
1529
|
+
$elemMatch: {
|
|
1530
|
+
groupId: group.id,
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
details: {
|
|
1534
|
+
recordAnswers: {
|
|
1535
|
+
[record.id]: {
|
|
1536
|
+
// Same filter as the date filter in the UI builds for 'equals'
|
|
1537
|
+
$and: [
|
|
1538
|
+
{
|
|
1539
|
+
dateValue: {
|
|
1540
|
+
$gte: new Date(2023, 5, 10),
|
|
1541
|
+
},
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
dateValue: {
|
|
1545
|
+
$lte: new Date(2023, 5, 10, 23, 59, 59, 999),
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
],
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
},
|
|
1552
|
+
},
|
|
1553
|
+
limit: 10,
|
|
1554
|
+
}),
|
|
1555
|
+
headers: {
|
|
1556
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
1557
|
+
},
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
const response = await testServer.test(endpoint, request);
|
|
1561
|
+
expect(response.status).toBe(200);
|
|
1562
|
+
expect(response.body.results.members).toIncludeSameMembers([
|
|
1563
|
+
expect.objectContaining({ id: member1.id }),
|
|
1564
|
+
]);
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1448
1567
|
test('[REGRESSION] A user with minimal access can also view platform record answers in platform scope', async () => {
|
|
1449
1568
|
/**
|
|
1450
1569
|
* When fetching members via the admin api, without organization scope, we need to calculate which records to return and which not.
|
|
@@ -1959,4 +2078,225 @@ describe('Endpoint.GetMembersEndpoint', () => {
|
|
|
1959
2078
|
]);
|
|
1960
2079
|
});
|
|
1961
2080
|
});
|
|
2081
|
+
|
|
2082
|
+
describe('Sorting', () => {
|
|
2083
|
+
// Deliberately not in chronological order, so a passing test cannot be explained by insertion order
|
|
2084
|
+
const january = new Date(Date.UTC(2023, 0, 10, 8, 30, 0));
|
|
2085
|
+
const february = new Date(Date.UTC(2023, 1, 1, 0, 0, 0));
|
|
2086
|
+
const march = new Date(Date.UTC(2023, 2, 5, 12, 0, 0));
|
|
2087
|
+
const may = new Date(Date.UTC(2023, 4, 15, 16, 45, 0));
|
|
2088
|
+
const june = new Date(Date.UTC(2023, 5, 20, 22, 15, 0));
|
|
2089
|
+
|
|
2090
|
+
/**
|
|
2091
|
+
* Creates one member per passed value, all registered in the same group of a new organization,
|
|
2092
|
+
* and returns an admin with full access to that organization.
|
|
2093
|
+
*/
|
|
2094
|
+
async function setupMembers(lastRegisteredAtValues: (Date | null)[]) {
|
|
2095
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
2096
|
+
|
|
2097
|
+
const user = await new UserFactory({
|
|
2098
|
+
organization,
|
|
2099
|
+
permissions: Permissions.create({
|
|
2100
|
+
level: PermissionLevel.Full,
|
|
2101
|
+
}),
|
|
2102
|
+
}).create();
|
|
2103
|
+
|
|
2104
|
+
const token = await Token.createToken(user);
|
|
2105
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
2106
|
+
|
|
2107
|
+
const members: MemberWithUsersRegistrationsAndGroups[] = [];
|
|
2108
|
+
|
|
2109
|
+
for (const lastRegisteredAt of lastRegisteredAtValues) {
|
|
2110
|
+
const member = await new MemberFactory({}).create();
|
|
2111
|
+
await new RegistrationFactory({ member, group }).create();
|
|
2112
|
+
|
|
2113
|
+
// The registration factory does not maintain lastRegisteredAt (only RegistrationService does),
|
|
2114
|
+
// so we set it explicitly to get deterministic values - including legacy members that never got one.
|
|
2115
|
+
member.lastRegisteredAt = lastRegisteredAt;
|
|
2116
|
+
await member.save();
|
|
2117
|
+
|
|
2118
|
+
members.push(member);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
return { host: organization.getApiHost(), token, members };
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* Requests every page by following the 'next' request the endpoint returns, exactly like the frontend does.
|
|
2126
|
+
* That next request is built from the sorter's getValue, so this covers the full sort + pagination flow:
|
|
2127
|
+
* getValue -> page filter -> encoded in the query -> decoded -> compiled back to SQL.
|
|
2128
|
+
*
|
|
2129
|
+
* Returns the ids of all members across all pages, in the order they were received.
|
|
2130
|
+
*/
|
|
2131
|
+
async function fetchAllPages({ host, token, sort, limit }: { host: string; token: Token; sort: SortList; limit: number }) {
|
|
2132
|
+
const ids: string[] = [];
|
|
2133
|
+
let query: LimitedFilteredRequest | undefined = new LimitedFilteredRequest({ sort, limit });
|
|
2134
|
+
let pages = 0;
|
|
2135
|
+
|
|
2136
|
+
while (query) {
|
|
2137
|
+
const request = Request.get({
|
|
2138
|
+
path: baseUrl,
|
|
2139
|
+
host,
|
|
2140
|
+
query,
|
|
2141
|
+
headers: {
|
|
2142
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
const response = await testServer.test(endpoint, request);
|
|
2147
|
+
expect(response.status).toBe(200);
|
|
2148
|
+
|
|
2149
|
+
ids.push(...response.body.results.members.map(m => m.id));
|
|
2150
|
+
query = response.body.next;
|
|
2151
|
+
pages += 1;
|
|
2152
|
+
|
|
2153
|
+
if (pages > 10) {
|
|
2154
|
+
throw new Error('Pagination did not terminate');
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
return ids;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
test('Members are sorted by lastRegisteredAt ascending across all pages', async () => {
|
|
2162
|
+
const { host, token, members } = await setupMembers([march, january, june, february, may]);
|
|
2163
|
+
const [marchMember, januaryMember, juneMember, februaryMember, mayMember] = members;
|
|
2164
|
+
|
|
2165
|
+
// A limit lower than the total forces the endpoint to build a next page filter from getValue
|
|
2166
|
+
const ids = await fetchAllPages({
|
|
2167
|
+
host,
|
|
2168
|
+
token,
|
|
2169
|
+
sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.ASC }],
|
|
2170
|
+
limit: 2,
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
expect(ids).toEqual([
|
|
2174
|
+
januaryMember.id,
|
|
2175
|
+
februaryMember.id,
|
|
2176
|
+
marchMember.id,
|
|
2177
|
+
mayMember.id,
|
|
2178
|
+
juneMember.id,
|
|
2179
|
+
]);
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
test('Members are sorted by lastRegisteredAt descending across all pages', async () => {
|
|
2183
|
+
const { host, token, members } = await setupMembers([march, january, june, february, may]);
|
|
2184
|
+
const [marchMember, januaryMember, juneMember, februaryMember, mayMember] = members;
|
|
2185
|
+
|
|
2186
|
+
const ids = await fetchAllPages({
|
|
2187
|
+
host,
|
|
2188
|
+
token,
|
|
2189
|
+
sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.DESC }],
|
|
2190
|
+
limit: 2,
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
expect(ids).toEqual([
|
|
2194
|
+
juneMember.id,
|
|
2195
|
+
mayMember.id,
|
|
2196
|
+
marchMember.id,
|
|
2197
|
+
februaryMember.id,
|
|
2198
|
+
januaryMember.id,
|
|
2199
|
+
]);
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
test('Members without a lastRegisteredAt are sorted first when ascending', async () => {
|
|
2203
|
+
const { host, token, members } = await setupMembers([march, null, january]);
|
|
2204
|
+
const [marchMember, neverRegisteredMember, januaryMember] = members;
|
|
2205
|
+
|
|
2206
|
+
// A limit of 1 puts the page boundary right after the member without a lastRegisteredAt,
|
|
2207
|
+
// so the next page filter is built from a null value.
|
|
2208
|
+
const ids = await fetchAllPages({
|
|
2209
|
+
host,
|
|
2210
|
+
token,
|
|
2211
|
+
sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.ASC }],
|
|
2212
|
+
limit: 1,
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
expect(ids).toEqual([
|
|
2216
|
+
neverRegisteredMember.id,
|
|
2217
|
+
januaryMember.id,
|
|
2218
|
+
marchMember.id,
|
|
2219
|
+
]);
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
test('Members without a lastRegisteredAt are sorted last when descending', async () => {
|
|
2223
|
+
const { host, token, members } = await setupMembers([march, null, january]);
|
|
2224
|
+
const [marchMember, neverRegisteredMember, januaryMember] = members;
|
|
2225
|
+
|
|
2226
|
+
const ids = await fetchAllPages({
|
|
2227
|
+
host,
|
|
2228
|
+
token,
|
|
2229
|
+
sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.DESC }],
|
|
2230
|
+
limit: 1,
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
expect(ids).toEqual([
|
|
2234
|
+
marchMember.id,
|
|
2235
|
+
januaryMember.id,
|
|
2236
|
+
neverRegisteredMember.id,
|
|
2237
|
+
]);
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
test('The next page filter compares lastRegisteredAt as a UTC datetime string', async () => {
|
|
2241
|
+
const { host, token, members } = await setupMembers([january, february]);
|
|
2242
|
+
const [januaryMember] = members;
|
|
2243
|
+
|
|
2244
|
+
const request = Request.get({
|
|
2245
|
+
path: baseUrl,
|
|
2246
|
+
host,
|
|
2247
|
+
query: new LimitedFilteredRequest({
|
|
2248
|
+
sort: [{ key: 'lastRegisteredAt', order: SortItemDirection.ASC }],
|
|
2249
|
+
limit: 1,
|
|
2250
|
+
}),
|
|
2251
|
+
headers: {
|
|
2252
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
2253
|
+
},
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
const response = await testServer.test(endpoint, request);
|
|
2257
|
+
expect(response.status).toBe(200);
|
|
2258
|
+
expect(response.body.results.members).toHaveLength(1);
|
|
2259
|
+
|
|
2260
|
+
// The value has to be formatted in UTC, because that is how MySQL stores the datetime column.
|
|
2261
|
+
// Comparing against a raw Date or a localized string would shift the page boundary.
|
|
2262
|
+
expect(response.body.next?.pageFilter).toMatchObject({
|
|
2263
|
+
$or: [
|
|
2264
|
+
{ lastRegisteredAt: { $gt: '2023-01-10 08:30:00' } },
|
|
2265
|
+
{
|
|
2266
|
+
$and: [
|
|
2267
|
+
{ lastRegisteredAt: '2023-01-10 08:30:00' },
|
|
2268
|
+
{ id: { $gt: januaryMember.id } },
|
|
2269
|
+
],
|
|
2270
|
+
},
|
|
2271
|
+
],
|
|
2272
|
+
});
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
test('Members are sorted by createdAt across all pages, even if they never registered', async () => {
|
|
2276
|
+
const { host, token, members } = await setupMembers([null, null, null]);
|
|
2277
|
+
const [first, second, third] = members;
|
|
2278
|
+
|
|
2279
|
+
// createdAt keeps an explicitly set value on save
|
|
2280
|
+
first.createdAt = march;
|
|
2281
|
+
second.createdAt = january;
|
|
2282
|
+
third.createdAt = february;
|
|
2283
|
+
|
|
2284
|
+
await first.save();
|
|
2285
|
+
await second.save();
|
|
2286
|
+
await third.save();
|
|
2287
|
+
|
|
2288
|
+
const ids = await fetchAllPages({
|
|
2289
|
+
host,
|
|
2290
|
+
token,
|
|
2291
|
+
sort: [{ key: 'createdAt', order: SortItemDirection.ASC }],
|
|
2292
|
+
limit: 1,
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
expect(ids).toEqual([
|
|
2296
|
+
second.id,
|
|
2297
|
+
third.id,
|
|
2298
|
+
first.id,
|
|
2299
|
+
]);
|
|
2300
|
+
});
|
|
2301
|
+
});
|
|
1962
2302
|
});
|
|
@@ -240,6 +240,53 @@ describe('Endpoint.RegisterMembers', () => {
|
|
|
240
240
|
]);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
test('Should pay a balance item that is due soon (dueAt in the near future)', async () => {
|
|
244
|
+
const { member, user, organization, token } = await initData();
|
|
245
|
+
|
|
246
|
+
// Items that are due within 7 days are part of the outstanding balance, so a member
|
|
247
|
+
// can pay them before their due date.
|
|
248
|
+
const dueAt = new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000);
|
|
249
|
+
|
|
250
|
+
const balanceItem1 = await new BalanceItemFactory({
|
|
251
|
+
organizationId: organization.id,
|
|
252
|
+
memberId: member.id,
|
|
253
|
+
userId: user.id,
|
|
254
|
+
type: BalanceItemType.Registration,
|
|
255
|
+
amount: 10,
|
|
256
|
+
unitPrice: 200,
|
|
257
|
+
dueAt,
|
|
258
|
+
status: BalanceItemStatus.Due,
|
|
259
|
+
}).create();
|
|
260
|
+
|
|
261
|
+
const body = IDRegisterCheckout.create({
|
|
262
|
+
cart: IDRegisterCart.create({
|
|
263
|
+
items: [],
|
|
264
|
+
balanceItems: [
|
|
265
|
+
BalanceItemCartItem.create({
|
|
266
|
+
item: balanceItem1.getStructure(),
|
|
267
|
+
price: 2000,
|
|
268
|
+
}),
|
|
269
|
+
],
|
|
270
|
+
deleteRegistrationIds: [],
|
|
271
|
+
}),
|
|
272
|
+
administrationFee: 0,
|
|
273
|
+
freeContribution: 0,
|
|
274
|
+
paymentMethod: PaymentMethod.PointOfSale,
|
|
275
|
+
totalPrice: 2000,
|
|
276
|
+
customer: null,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const response = await post(body, organization, token);
|
|
280
|
+
|
|
281
|
+
// The member really has to pay it: it must be part of the payment
|
|
282
|
+
expect(response.body.payment).not.toBeNull();
|
|
283
|
+
expect(response.body.payment!.price).toBe(2000);
|
|
284
|
+
|
|
285
|
+
// Point of sale isn't paid right away, but the amount is pending in the payment
|
|
286
|
+
await balanceItem1.refresh();
|
|
287
|
+
expect(balanceItem1.pricePending).toBe(2000);
|
|
288
|
+
});
|
|
289
|
+
|
|
243
290
|
test('Should fail if balance item deleted', async () => {
|
|
244
291
|
const { member, user, organization, token } = await initData();
|
|
245
292
|
|
|
@@ -960,17 +1007,120 @@ describe('Endpoint.RegisterMembers', () => {
|
|
|
960
1007
|
// assert
|
|
961
1008
|
expect(response.body).toBeDefined();
|
|
962
1009
|
expect(response.body.registrations.length).toBe(1);
|
|
1010
|
+
// Nothing is due during a trial, but the registration should still be activated
|
|
1011
|
+
// (marked valid) so it is visible in the registration lists and member portal.
|
|
1012
|
+
expect(response.body.registrations[0]).toMatchObject({
|
|
1013
|
+
registeredAt: expect.any(Date),
|
|
1014
|
+
deactivatedAt: null,
|
|
1015
|
+
});
|
|
1016
|
+
const dbRegistration = await Registration.getByID(response.body.registrations[0].id);
|
|
1017
|
+
expect(dbRegistration?.registeredAt).not.toBeNull();
|
|
1018
|
+
expect(dbRegistration?.deactivatedAt).toBeNull();
|
|
963
1019
|
const trialUntil = response.body.registrations[0].trialUntil;
|
|
964
1020
|
expect(trialUntil).not.toBeNull();
|
|
965
1021
|
// 2023-05-14
|
|
966
1022
|
expect(trialUntil!.getFullYear()).toBe(2023);
|
|
967
1023
|
expect(trialUntil!.getMonth()).toBe(4);
|
|
968
1024
|
expect(trialUntil!.getDate()).toBe(19);
|
|
1025
|
+
|
|
1026
|
+
// Nothing is paid during the trial, but the balance item should be due (not hidden)
|
|
1027
|
+
// so it is still billed once the trial ends.
|
|
1028
|
+
const balanceItems = await BalanceItem.where({ registrationId: dbRegistration!.id });
|
|
1029
|
+
expect(balanceItems.length).toBe(1);
|
|
1030
|
+
expect(balanceItems[0]).toMatchObject({
|
|
1031
|
+
status: BalanceItemStatus.Due,
|
|
1032
|
+
dueAt: dbRegistration!.trialUntil,
|
|
1033
|
+
pricePaid: 0,
|
|
1034
|
+
});
|
|
969
1035
|
} finally {
|
|
970
1036
|
vitest.useRealTimers();
|
|
971
1037
|
}
|
|
972
1038
|
}, 20_00000);
|
|
973
1039
|
|
|
1040
|
+
test('A trial registration is activated immediately, while a paid registration in the same cart waits for the online payment', async () => {
|
|
1041
|
+
// #region arrange
|
|
1042
|
+
const { member, group, groupPrice, organization, token } = await initData();
|
|
1043
|
+
await initPayconiq({ organization });
|
|
1044
|
+
|
|
1045
|
+
// The trial group also limits its members, so a reservedUntil is set and has to be cleared again
|
|
1046
|
+
group.settings.trialDays = 5;
|
|
1047
|
+
group.settings.maxMembers = 1;
|
|
1048
|
+
await group.save();
|
|
1049
|
+
|
|
1050
|
+
const paidGroup = await new GroupFactory({
|
|
1051
|
+
organization,
|
|
1052
|
+
price: 1500,
|
|
1053
|
+
maxMembers: 1,
|
|
1054
|
+
}).create();
|
|
1055
|
+
|
|
1056
|
+
const body = IDRegisterCheckout.create({
|
|
1057
|
+
cart: IDRegisterCart.create({
|
|
1058
|
+
items: [
|
|
1059
|
+
IDRegisterItem.create({
|
|
1060
|
+
id: uuidv4(),
|
|
1061
|
+
groupPrice,
|
|
1062
|
+
organizationId: organization.id,
|
|
1063
|
+
groupId: group.id,
|
|
1064
|
+
memberId: member.id,
|
|
1065
|
+
trial: true,
|
|
1066
|
+
}),
|
|
1067
|
+
IDRegisterItem.create({
|
|
1068
|
+
id: uuidv4(),
|
|
1069
|
+
groupPrice: paidGroup.settings.prices[0],
|
|
1070
|
+
organizationId: organization.id,
|
|
1071
|
+
groupId: paidGroup.id,
|
|
1072
|
+
memberId: member.id,
|
|
1073
|
+
}),
|
|
1074
|
+
],
|
|
1075
|
+
}),
|
|
1076
|
+
paymentMethod: PaymentMethod.Payconiq,
|
|
1077
|
+
redirectUrl: new URL('https://www.example.com'),
|
|
1078
|
+
cancelUrl: new URL('https://www.example.com'),
|
|
1079
|
+
// Only the non-trial registration has to be paid now
|
|
1080
|
+
totalPrice: 1500,
|
|
1081
|
+
});
|
|
1082
|
+
// #endregion
|
|
1083
|
+
|
|
1084
|
+
// act
|
|
1085
|
+
const response = await post(body, organization, token);
|
|
1086
|
+
|
|
1087
|
+
// assert
|
|
1088
|
+
expect(response.body.registrations.length).toBe(2);
|
|
1089
|
+
expect(response.body.paymentUrl).toMatch(/payconiq-checkout\.test/);
|
|
1090
|
+
|
|
1091
|
+
// The trial is free, so it is activated right away and its reservation is released
|
|
1092
|
+
const trialRegistration = response.body.registrations.find(r => r.group.id === group.id)!;
|
|
1093
|
+
expect(trialRegistration).toMatchObject({
|
|
1094
|
+
registeredAt: expect.any(Date),
|
|
1095
|
+
deactivatedAt: null,
|
|
1096
|
+
reservedUntil: null,
|
|
1097
|
+
trialUntil: expect.any(Date),
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const trialBalanceItems = await BalanceItem.where({ registrationId: trialRegistration.id });
|
|
1101
|
+
expect(trialBalanceItems.length).toBe(1);
|
|
1102
|
+
expect(trialBalanceItems[0]).toMatchObject({
|
|
1103
|
+
status: BalanceItemStatus.Due,
|
|
1104
|
+
pricePaid: 0,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// The paid registration is not activated: it still waits for the online payment
|
|
1108
|
+
const paidRegistration = response.body.registrations.find(r => r.group.id === paidGroup.id)!;
|
|
1109
|
+
expect(paidRegistration).toMatchObject({
|
|
1110
|
+
registeredAt: null,
|
|
1111
|
+
deactivatedAt: null,
|
|
1112
|
+
reservedUntil: expect.any(Date),
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
await group.refresh();
|
|
1116
|
+
expect(group.settings.registeredMembers).toBe(1);
|
|
1117
|
+
expect(group.settings.reservedMembers).toBe(0);
|
|
1118
|
+
|
|
1119
|
+
await paidGroup.refresh();
|
|
1120
|
+
expect(paidGroup.settings.registeredMembers).toBe(0);
|
|
1121
|
+
expect(paidGroup.settings.reservedMembers).toBe(1);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
974
1124
|
test('Should update group stock reservations', async () => {
|
|
975
1125
|
// #region arrange
|
|
976
1126
|
const { organization, group, groupPrice, token, member } = await initData();
|
|
@@ -835,9 +835,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
835
835
|
const mappedBalanceItems = new Map<BalanceItem, number>();
|
|
836
836
|
|
|
837
837
|
for (const item of createdBalanceItems) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
838
|
+
// Items that aren't due yet (the trial period) are not paid now, so they are passed
|
|
839
|
+
// along with an amount of 0: createPayment keeps them out of the payment itself, but
|
|
840
|
+
// does mark them due + valid (which activates the registration).
|
|
841
|
+
mappedBalanceItems.set(item, item.dueAt !== null ? 0 : item.price);
|
|
841
842
|
}
|
|
842
843
|
|
|
843
844
|
for (const item of checkout.cart.balanceItems) {
|