@stamhoofd/backend 2.132.1 → 2.134.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 -18
- package/src/crons/invoices.ts +5 -3
- package/src/endpoints/global/groups/GetGroupsEndpoint.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +190 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +72 -14
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +183 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +189 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +26 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +158 -0
- package/src/helpers/StripeInvoicer.ts +13 -2
- package/src/helpers/ViesHelper.test.ts +145 -1
- package/src/helpers/ViesHelper.ts +39 -0
- package/src/services/InvoiceXMLService.test.ts +161 -0
- package/src/services/InvoiceXMLService.ts +11 -9
- package/src/services/PeppolDirectoryService.test.ts +139 -0
- package/src/services/PeppolDirectoryService.ts +111 -0
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/sql-filters/balance-item-payments.ts +4 -56
- package/src/sql-filters/balance-items.ts +69 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.134.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -53,24 +53,24 @@
|
|
|
53
53
|
"@bwip-js/node": "4.11.1",
|
|
54
54
|
"@mollie/api-client": "4.5.0",
|
|
55
55
|
"@simonbackx/simple-database": "1.37.1",
|
|
56
|
-
"@simonbackx/simple-encoding": "2.26.
|
|
56
|
+
"@simonbackx/simple-encoding": "2.26.11",
|
|
57
57
|
"@simonbackx/simple-endpoints": "1.21.1",
|
|
58
58
|
"@simonbackx/simple-errors": "1.5.0",
|
|
59
59
|
"@simonbackx/simple-logging": "1.0.1",
|
|
60
|
-
"@stamhoofd/backend-env": "2.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
60
|
+
"@stamhoofd/backend-env": "2.134.0",
|
|
61
|
+
"@stamhoofd/backend-i18n": "2.134.0",
|
|
62
|
+
"@stamhoofd/backend-middleware": "2.134.0",
|
|
63
|
+
"@stamhoofd/crons": "2.134.0",
|
|
64
|
+
"@stamhoofd/email": "2.134.0",
|
|
65
|
+
"@stamhoofd/excel-writer": "2.134.0",
|
|
66
|
+
"@stamhoofd/logging": "2.134.0",
|
|
67
|
+
"@stamhoofd/models": "2.134.0",
|
|
68
|
+
"@stamhoofd/object-differ": "2.134.0",
|
|
69
|
+
"@stamhoofd/queues": "2.134.0",
|
|
70
|
+
"@stamhoofd/sql": "2.134.0",
|
|
71
|
+
"@stamhoofd/structures": "2.134.0",
|
|
72
|
+
"@stamhoofd/types": "2.134.0",
|
|
73
|
+
"@stamhoofd/utility": "2.134.0",
|
|
74
74
|
"archiver": "7.0.1",
|
|
75
75
|
"axios": "1.16.0",
|
|
76
76
|
"base-x": "3.0.11",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"stripe": "16.12.0"
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stamhoofd/test-utils": "2.
|
|
94
|
+
"@stamhoofd/test-utils": "2.134.0",
|
|
95
95
|
"@types/cookie": "0.6.0",
|
|
96
96
|
"@types/luxon": "3.7.1",
|
|
97
97
|
"@types/mailparser": "3.4.6",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"publishConfig": {
|
|
108
108
|
"access": "public"
|
|
109
109
|
},
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "42955365bcf0bcfd0815998c1039e23d6ee1d6c6"
|
|
111
111
|
}
|
package/src/crons/invoices.ts
CHANGED
|
@@ -52,10 +52,12 @@ async function createInvoicesFor(organization: Organization) {
|
|
|
52
52
|
const today = Formatter.luxon();
|
|
53
53
|
const startDate = today.day <= 15 ? today.minus({ month: 2 }).startOf('month') : today.minus({ month: 1 }).startOf('month');
|
|
54
54
|
|
|
55
|
-
// Don't invoice below
|
|
56
|
-
const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 :
|
|
55
|
+
// Don't invoice below 4 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin) OR + 15 day offset
|
|
56
|
+
const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 4_0000;
|
|
57
57
|
function getPaymentTimeoutDate(p: Payment) {
|
|
58
|
-
|
|
58
|
+
const a = Formatter.luxon(p.paidAt ?? p.createdAt).plus({ month: 1 }).set({ day: 10 }).startOf('day').toJSDate();
|
|
59
|
+
const b = Formatter.luxon(p.paidAt ?? p.createdAt).plus({ days: 15 }).startOf('day').toJSDate();
|
|
60
|
+
return new Date(Math.min(a.getTime(), b.getTime()));
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
console.log('Fetching all payments between ' + Formatter.dateTime(startDate.toJSDate()) + ' and now for ' + organization.name);
|
|
@@ -133,7 +133,7 @@ export class GetGroupsEndpoint extends Endpoint<Params, Query, Body, ResponseBod
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
const maxLimit = Context.
|
|
136
|
+
const maxLimit = Context.optionalAuth?.hasSomePlatformAccess() ? 1000 : 100;
|
|
137
137
|
|
|
138
138
|
if (request.query.limit > maxLimit) {
|
|
139
139
|
throw new SimpleError({
|
|
@@ -3,10 +3,10 @@ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
|
3
3
|
import { PatchableArray, PatchMap } from '@simonbackx/simple-encoding';
|
|
4
4
|
import type { Endpoint } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
6
|
-
import { GroupFactory, Member, MemberFactory, OrganizationFactory, OrganizationTagFactory, Platform, RegistrationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
6
|
+
import { GroupFactory, Member, MemberFactory, MemberPlatformMembership, OrganizationFactory, OrganizationTagFactory, Platform, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
7
7
|
import { SQL } from '@stamhoofd/sql';
|
|
8
8
|
import type { PatchAnswers } from '@stamhoofd/structures';
|
|
9
|
-
import { Address, EmergencyContact, MemberDetails, MemberWithRegistrationsBlob, OrganizationMetaData, OrganizationRecordsConfiguration, Parent, PermissionLevel, Permissions, PermissionsResourceType, RecordCategory, RecordSettings, RecordTextAnswer, ResourcePermissions, ReviewTime, ReviewTimes, TranslatedString, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus, Version } from '@stamhoofd/structures';
|
|
9
|
+
import { Address, EmergencyContact, MemberDetails, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, OrganizationMetaData, OrganizationRecordsConfiguration, Parent, PermissionLevel, Permissions, PermissionsResourceType, PlatformMembershipType, PlatformMembershipTypeConfig, RecordCategory, RecordSettings, RecordTextAnswer, ResourcePermissions, ReviewTime, ReviewTimes, TranslatedString, UitpasNumberDetails, UitpasSocialTariff, UitpasSocialTariffStatus, Version } from '@stamhoofd/structures';
|
|
10
10
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
11
11
|
import { Country } from '@stamhoofd/types/Country';
|
|
12
12
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
@@ -4034,4 +4034,192 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
|
|
|
4034
4034
|
});
|
|
4035
4035
|
});
|
|
4036
4036
|
});
|
|
4037
|
+
|
|
4038
|
+
describe('Overlapping and incompatible platform memberships', () => {
|
|
4039
|
+
// Two non-overlapping halves of the same period (werkjaar).
|
|
4040
|
+
const firstHalf = { startDate: new Date(2024, 0, 1, 0, 0, 0, 0), endDate: new Date(2024, 5, 30, 23, 59, 59, 0) };
|
|
4041
|
+
const secondHalf = { startDate: new Date(2024, 6, 1, 0, 0, 0, 0), endDate: new Date(2024, 11, 31, 23, 59, 59, 0) };
|
|
4042
|
+
|
|
4043
|
+
async function setup({ incompatibleWithB = false, existingRange, putRange, existingLocked = false, existingGenerated = false }: {
|
|
4044
|
+
incompatibleWithB?: boolean;
|
|
4045
|
+
existingRange?: { startDate: Date; endDate: Date };
|
|
4046
|
+
putRange?: { startDate: Date; endDate: Date };
|
|
4047
|
+
existingLocked?: boolean;
|
|
4048
|
+
existingGenerated?: boolean;
|
|
4049
|
+
}) {
|
|
4050
|
+
const period = await new RegistrationPeriodFactory({
|
|
4051
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
4052
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 0),
|
|
4053
|
+
}).create();
|
|
4054
|
+
|
|
4055
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
4056
|
+
|
|
4057
|
+
const typeB = PlatformMembershipType.create({
|
|
4058
|
+
name: 'Type B',
|
|
4059
|
+
periods: new Map([
|
|
4060
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
4061
|
+
startDate: period.startDate,
|
|
4062
|
+
endDate: period.endDate,
|
|
4063
|
+
})],
|
|
4064
|
+
]),
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
const typeA = PlatformMembershipType.create({
|
|
4068
|
+
name: 'Type A',
|
|
4069
|
+
incompatibleMembershipTypeIds: incompatibleWithB ? [typeB.id] : [],
|
|
4070
|
+
periods: new Map([
|
|
4071
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
4072
|
+
startDate: period.startDate,
|
|
4073
|
+
endDate: period.endDate,
|
|
4074
|
+
})],
|
|
4075
|
+
]),
|
|
4076
|
+
});
|
|
4077
|
+
|
|
4078
|
+
const platform = await Platform.getForEditing();
|
|
4079
|
+
platform.periodId = period.id;
|
|
4080
|
+
platform.config.membershipTypes = [typeA, typeB];
|
|
4081
|
+
await platform.save();
|
|
4082
|
+
|
|
4083
|
+
const user = await new UserFactory({
|
|
4084
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
4085
|
+
organization,
|
|
4086
|
+
}).create();
|
|
4087
|
+
const token = await Token.createToken(user);
|
|
4088
|
+
|
|
4089
|
+
const member = await new MemberFactory({ organization }).create();
|
|
4090
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
4091
|
+
await new RegistrationFactory({ member, group }).create();
|
|
4092
|
+
|
|
4093
|
+
// The member already has a membership of type B in this period
|
|
4094
|
+
const existing = new MemberPlatformMembership();
|
|
4095
|
+
existing.memberId = member.id;
|
|
4096
|
+
existing.membershipTypeId = typeB.id;
|
|
4097
|
+
existing.organizationId = organization.id;
|
|
4098
|
+
existing.periodId = period.id;
|
|
4099
|
+
existing.startDate = existingRange?.startDate ?? period.startDate;
|
|
4100
|
+
existing.endDate = existingRange?.endDate ?? period.endDate;
|
|
4101
|
+
existing.locked = existingLocked;
|
|
4102
|
+
existing.generated = existingGenerated;
|
|
4103
|
+
await existing.save();
|
|
4104
|
+
|
|
4105
|
+
const arr: Body = new PatchableArray();
|
|
4106
|
+
const memberPatch = MemberWithRegistrationsBlob.patch({ id: member.id });
|
|
4107
|
+
memberPatch.platformMemberships.addPut(MemberPlatformMembershipStruct.create({
|
|
4108
|
+
memberId: member.id,
|
|
4109
|
+
membershipTypeId: typeA.id,
|
|
4110
|
+
organizationId: organization.id,
|
|
4111
|
+
periodId: period.id,
|
|
4112
|
+
startDate: putRange?.startDate ?? period.startDate,
|
|
4113
|
+
endDate: putRange?.endDate ?? period.endDate,
|
|
4114
|
+
}));
|
|
4115
|
+
arr.addPatch(memberPatch);
|
|
4116
|
+
|
|
4117
|
+
const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
|
|
4118
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
4119
|
+
|
|
4120
|
+
return { period, organization, typeA, typeB, member, request };
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
async function getTypeAMemberships(memberId: string, membershipTypeId: string) {
|
|
4124
|
+
return await MemberPlatformMembership.select()
|
|
4125
|
+
.where('memberId', memberId)
|
|
4126
|
+
.where('membershipTypeId', membershipTypeId)
|
|
4127
|
+
.where('deletedAt', null)
|
|
4128
|
+
.fetch();
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
describe('Overlapping memberships of a different type are never allowed', () => {
|
|
4132
|
+
test('Blocks an overlapping membership of an incompatible type', async () => {
|
|
4133
|
+
const { typeA, member, request } = await setup({ incompatibleWithB: true });
|
|
4134
|
+
|
|
4135
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4136
|
+
code: 'invalid_field',
|
|
4137
|
+
field: 'membershipTypeId',
|
|
4138
|
+
message: 'Overlapping membership type',
|
|
4139
|
+
}));
|
|
4140
|
+
|
|
4141
|
+
// The overlapping membership was not created
|
|
4142
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4143
|
+
});
|
|
4144
|
+
|
|
4145
|
+
test('Blocks an overlapping membership even when the type is not marked incompatible', async () => {
|
|
4146
|
+
const { typeA, member, request } = await setup({ incompatibleWithB: false });
|
|
4147
|
+
|
|
4148
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4149
|
+
code: 'invalid_field',
|
|
4150
|
+
field: 'membershipTypeId',
|
|
4151
|
+
message: 'Overlapping membership type',
|
|
4152
|
+
}));
|
|
4153
|
+
|
|
4154
|
+
// The overlapping membership was not created
|
|
4155
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4156
|
+
});
|
|
4157
|
+
});
|
|
4158
|
+
|
|
4159
|
+
describe('Non-overlapping memberships in the same period', () => {
|
|
4160
|
+
test('Blocks an incompatible type even when the dates do not overlap', async () => {
|
|
4161
|
+
const { typeA, member, request } = await setup({
|
|
4162
|
+
incompatibleWithB: true,
|
|
4163
|
+
existingRange: firstHalf,
|
|
4164
|
+
putRange: secondHalf,
|
|
4165
|
+
});
|
|
4166
|
+
|
|
4167
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4168
|
+
code: 'invalid_field',
|
|
4169
|
+
field: 'membershipTypeId',
|
|
4170
|
+
message: 'Incompatible membership type',
|
|
4171
|
+
}));
|
|
4172
|
+
|
|
4173
|
+
// The incompatible membership was not created
|
|
4174
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4175
|
+
});
|
|
4176
|
+
|
|
4177
|
+
test('Allows a compatible type when the dates do not overlap', async () => {
|
|
4178
|
+
const { typeA, member, request } = await setup({
|
|
4179
|
+
incompatibleWithB: false,
|
|
4180
|
+
existingRange: firstHalf,
|
|
4181
|
+
putRange: secondHalf,
|
|
4182
|
+
});
|
|
4183
|
+
|
|
4184
|
+
const result = await testServer.test(endpoint, request);
|
|
4185
|
+
expect(result.status).toBe(200);
|
|
4186
|
+
|
|
4187
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(1);
|
|
4188
|
+
});
|
|
4189
|
+
|
|
4190
|
+
test('Reports a locked incompatible membership with a specific message', async () => {
|
|
4191
|
+
const { typeA, member, request } = await setup({
|
|
4192
|
+
incompatibleWithB: true,
|
|
4193
|
+
existingRange: firstHalf,
|
|
4194
|
+
putRange: secondHalf,
|
|
4195
|
+
existingLocked: true,
|
|
4196
|
+
});
|
|
4197
|
+
|
|
4198
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4199
|
+
code: 'invalid_field',
|
|
4200
|
+
field: 'membershipTypeId',
|
|
4201
|
+
message: 'Incompatible locked membership type',
|
|
4202
|
+
}));
|
|
4203
|
+
|
|
4204
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4205
|
+
});
|
|
4206
|
+
|
|
4207
|
+
test('Reports a generated incompatible membership with a specific message', async () => {
|
|
4208
|
+
const { typeA, member, request } = await setup({
|
|
4209
|
+
incompatibleWithB: true,
|
|
4210
|
+
existingRange: firstHalf,
|
|
4211
|
+
putRange: secondHalf,
|
|
4212
|
+
existingGenerated: true,
|
|
4213
|
+
});
|
|
4214
|
+
|
|
4215
|
+
await expect(testServer.test(endpoint, request)).rejects.toThrow(expect.objectContaining({
|
|
4216
|
+
code: 'invalid_field',
|
|
4217
|
+
field: 'membershipTypeId',
|
|
4218
|
+
message: 'Incompatible generated membership type',
|
|
4219
|
+
}));
|
|
4220
|
+
|
|
4221
|
+
expect((await getTypeAMemberships(member.id, typeA.id)).length).toBe(0);
|
|
4222
|
+
});
|
|
4223
|
+
});
|
|
4224
|
+
});
|
|
4037
4225
|
});
|
|
@@ -66,6 +66,25 @@ export const duplicateCheckLimiter = new RateLimiter({
|
|
|
66
66
|
],
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A where clause that matches memberships whose [startDate, endDate] range overlaps
|
|
71
|
+
* with the given [startDate, endDate] range.
|
|
72
|
+
*/
|
|
73
|
+
function membershipDateRangeOverlaps(startDate: Date, endDate: Date) {
|
|
74
|
+
return SQL.where(
|
|
75
|
+
SQL.where('startDate', SQLWhereSign.LessEqual, startDate)
|
|
76
|
+
.and('endDate', SQLWhereSign.GreaterEqual, startDate),
|
|
77
|
+
)
|
|
78
|
+
.or(
|
|
79
|
+
SQL.where('startDate', SQLWhereSign.LessEqual, endDate)
|
|
80
|
+
.and('endDate', SQLWhereSign.GreaterEqual, endDate),
|
|
81
|
+
)
|
|
82
|
+
.or(
|
|
83
|
+
SQL.where('startDate', SQLWhereSign.GreaterEqual, startDate)
|
|
84
|
+
.and('endDate', SQLWhereSign.LessEqual, endDate),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
/**
|
|
70
89
|
* One endpoint to create, patch and delete members and their registrations and payments
|
|
71
90
|
*/
|
|
@@ -568,26 +587,65 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
568
587
|
// Correct price and dates
|
|
569
588
|
await membership.calculatePrice(member);
|
|
570
589
|
|
|
590
|
+
const incompatible = await MemberPlatformMembership.select()
|
|
591
|
+
.where('memberId', member.id)
|
|
592
|
+
.whereNot('membershipTypeId', put.membershipTypeId) // same type is checked later and might be auto-deleted
|
|
593
|
+
.where('periodId', put.periodId)
|
|
594
|
+
.where('deletedAt', null)
|
|
595
|
+
.where(membershipDateRangeOverlaps(put.startDate, put.endDate))
|
|
596
|
+
.first(false);
|
|
597
|
+
|
|
598
|
+
if (incompatible) {
|
|
599
|
+
throw new SimpleError({
|
|
600
|
+
code: 'invalid_field',
|
|
601
|
+
field: 'membershipTypeId',
|
|
602
|
+
message: 'Overlapping membership type',
|
|
603
|
+
human: $t('%Zd9'),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Not overlapping, but in same period.
|
|
608
|
+
if (membershipType.incompatibleMembershipTypeIds.length > 0) {
|
|
609
|
+
const incompatible = await MemberPlatformMembership.select()
|
|
610
|
+
.where('memberId', member.id)
|
|
611
|
+
.where('membershipTypeId', membershipType.incompatibleMembershipTypeIds)
|
|
612
|
+
.where('periodId', put.periodId)
|
|
613
|
+
.where('deletedAt', null)
|
|
614
|
+
.first(false);
|
|
615
|
+
|
|
616
|
+
if (incompatible) {
|
|
617
|
+
if (incompatible.locked) {
|
|
618
|
+
throw new SimpleError({
|
|
619
|
+
code: 'invalid_field',
|
|
620
|
+
field: 'membershipTypeId',
|
|
621
|
+
message: 'Incompatible locked membership type',
|
|
622
|
+
human: $t('%Zd1'),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (incompatible.generated) {
|
|
626
|
+
throw new SimpleError({
|
|
627
|
+
code: 'invalid_field',
|
|
628
|
+
field: 'membershipTypeId',
|
|
629
|
+
message: 'Incompatible generated membership type',
|
|
630
|
+
human: $t('%Zcx'),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
throw new SimpleError({
|
|
634
|
+
code: 'invalid_field',
|
|
635
|
+
field: 'membershipTypeId',
|
|
636
|
+
message: 'Incompatible membership type',
|
|
637
|
+
human: $t('%ZdA'),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
571
642
|
// Check duplicate memberships after correcting the dates
|
|
572
643
|
const existing = await MemberPlatformMembership.select()
|
|
573
644
|
.where('memberId', member.id)
|
|
574
645
|
.where('membershipTypeId', put.membershipTypeId)
|
|
575
646
|
.where('periodId', put.periodId)
|
|
576
647
|
.where('deletedAt', null)
|
|
577
|
-
.where(
|
|
578
|
-
SQL.where(
|
|
579
|
-
SQL.where('startDate', SQLWhereSign.LessEqual, put.startDate)
|
|
580
|
-
.and('endDate', SQLWhereSign.GreaterEqual, put.startDate),
|
|
581
|
-
)
|
|
582
|
-
.or(
|
|
583
|
-
SQL.where('startDate', SQLWhereSign.LessEqual, put.endDate)
|
|
584
|
-
.and('endDate', SQLWhereSign.GreaterEqual, put.endDate),
|
|
585
|
-
)
|
|
586
|
-
.or(
|
|
587
|
-
SQL.where('startDate', SQLWhereSign.GreaterEqual, put.startDate)
|
|
588
|
-
.and('endDate', SQLWhereSign.LessEqual, put.endDate),
|
|
589
|
-
),
|
|
590
|
-
)
|
|
648
|
+
.where(membershipDateRangeOverlaps(put.startDate, put.endDate))
|
|
591
649
|
.first(false);
|
|
592
650
|
|
|
593
651
|
if (existing && (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days || !existing.generated || existing.locked || existing.price < membership.price)) {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization, User } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemFactory, GroupFactory, MemberFactory, OrderFactory, OrganizationFactory, RegistrationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
|
+
import type { BalanceItem as BalanceItemStruct, PaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
|
|
6
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
7
|
+
import { GetBalanceItemsEndpoint } from './GetBalanceItemsEndpoint.js';
|
|
8
|
+
|
|
9
|
+
describe('Endpoint.GetBalanceItemsEndpoint', () => {
|
|
10
|
+
const endpoint = new GetBalanceItemsEndpoint();
|
|
11
|
+
|
|
12
|
+
const getBalanceItems = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
|
|
13
|
+
const token = await Token.createToken(user);
|
|
14
|
+
|
|
15
|
+
const request = Request.get({
|
|
16
|
+
path: '/balance-items',
|
|
17
|
+
host: organization.getApiHost(),
|
|
18
|
+
query: new LimitedFilteredRequest({
|
|
19
|
+
filter,
|
|
20
|
+
limit: 100,
|
|
21
|
+
}),
|
|
22
|
+
headers: {
|
|
23
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return testServer.test<PaginatedResponse<BalanceItemStruct[], LimitedFilteredRequest>>(endpoint, request);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const createFinanceUser = async (organization: Organization) => {
|
|
31
|
+
return await new UserFactory({
|
|
32
|
+
organization,
|
|
33
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
34
|
+
}).create();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('Filtering on the associated webshop', () => {
|
|
38
|
+
test('only returns balance items linked to an order of the given webshop', async () => {
|
|
39
|
+
const organization = await new OrganizationFactory({}).create();
|
|
40
|
+
const user = await createFinanceUser(organization);
|
|
41
|
+
|
|
42
|
+
const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
43
|
+
const otherWebshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
44
|
+
|
|
45
|
+
const order = await new OrderFactory({ webshop }).create();
|
|
46
|
+
const otherOrder = await new OrderFactory({ webshop: otherWebshop }).create();
|
|
47
|
+
|
|
48
|
+
const matchingItem = await new BalanceItemFactory({
|
|
49
|
+
organizationId: organization.id,
|
|
50
|
+
orderId: order.id,
|
|
51
|
+
type: BalanceItemType.Order,
|
|
52
|
+
amount: 1,
|
|
53
|
+
unitPrice: 10_00,
|
|
54
|
+
}).create();
|
|
55
|
+
|
|
56
|
+
// Negative controls: an order of another webshop and an item without an order
|
|
57
|
+
await new BalanceItemFactory({
|
|
58
|
+
organizationId: organization.id,
|
|
59
|
+
orderId: otherOrder.id,
|
|
60
|
+
type: BalanceItemType.Order,
|
|
61
|
+
amount: 1,
|
|
62
|
+
unitPrice: 10_00,
|
|
63
|
+
}).create();
|
|
64
|
+
await new BalanceItemFactory({
|
|
65
|
+
organizationId: organization.id,
|
|
66
|
+
type: BalanceItemType.Other,
|
|
67
|
+
amount: 1,
|
|
68
|
+
unitPrice: 10_00,
|
|
69
|
+
}).create();
|
|
70
|
+
|
|
71
|
+
const response = await getBalanceItems({
|
|
72
|
+
filter: {
|
|
73
|
+
order: {
|
|
74
|
+
webshopId: {
|
|
75
|
+
$in: [webshop.id],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
organization,
|
|
80
|
+
user,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(response.status).toBe(200);
|
|
84
|
+
expect(response.body.results.map(r => r.id)).toEqual([matchingItem.id]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('Filtering on the associated group', () => {
|
|
89
|
+
test('only returns balance items linked to a registration of the given group', async () => {
|
|
90
|
+
const organization = await new OrganizationFactory({}).create();
|
|
91
|
+
const user = await createFinanceUser(organization);
|
|
92
|
+
|
|
93
|
+
const member = await new MemberFactory({}).create();
|
|
94
|
+
const group = await new GroupFactory({ organization }).create();
|
|
95
|
+
const otherGroup = await new GroupFactory({ organization }).create();
|
|
96
|
+
|
|
97
|
+
const registration = await new RegistrationFactory({ member, group }).create();
|
|
98
|
+
const otherRegistration = await new RegistrationFactory({ member, group: otherGroup }).create();
|
|
99
|
+
|
|
100
|
+
const matchingItem = await new BalanceItemFactory({
|
|
101
|
+
organizationId: organization.id,
|
|
102
|
+
registrationId: registration.id,
|
|
103
|
+
type: BalanceItemType.Registration,
|
|
104
|
+
amount: 1,
|
|
105
|
+
unitPrice: 10_00,
|
|
106
|
+
}).create();
|
|
107
|
+
|
|
108
|
+
// Negative control: a registration for another group
|
|
109
|
+
await new BalanceItemFactory({
|
|
110
|
+
organizationId: organization.id,
|
|
111
|
+
registrationId: otherRegistration.id,
|
|
112
|
+
type: BalanceItemType.Registration,
|
|
113
|
+
amount: 1,
|
|
114
|
+
unitPrice: 10_00,
|
|
115
|
+
}).create();
|
|
116
|
+
|
|
117
|
+
const response = await getBalanceItems({
|
|
118
|
+
filter: {
|
|
119
|
+
registration: {
|
|
120
|
+
groupId: {
|
|
121
|
+
$in: [group.id],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
organization,
|
|
126
|
+
user,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(response.status).toBe(200);
|
|
130
|
+
expect(response.body.results.map(r => r.id)).toEqual([matchingItem.id]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('Filtering on the membership type', () => {
|
|
135
|
+
const createMembershipItem = async ({ organization, membershipTypeId }: { organization: Organization; membershipTypeId: string }) => {
|
|
136
|
+
return await new BalanceItemFactory({
|
|
137
|
+
organizationId: organization.id,
|
|
138
|
+
type: BalanceItemType.PlatformMembership,
|
|
139
|
+
amount: 1,
|
|
140
|
+
unitPrice: 10_00,
|
|
141
|
+
relations: new Map([
|
|
142
|
+
[
|
|
143
|
+
BalanceItemRelationType.MembershipType,
|
|
144
|
+
BalanceItemRelation.create({
|
|
145
|
+
id: membershipTypeId,
|
|
146
|
+
name: new TranslatedString('Membership type'),
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
]),
|
|
150
|
+
}).create();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
test('only returns balance items whose membership type relation matches', async () => {
|
|
154
|
+
const organization = await new OrganizationFactory({}).create();
|
|
155
|
+
const user = await createFinanceUser(organization);
|
|
156
|
+
|
|
157
|
+
const membershipTypeId = 'membership-type-a';
|
|
158
|
+
const matchingItem = await createMembershipItem({ organization, membershipTypeId });
|
|
159
|
+
|
|
160
|
+
// Negative controls: a different membership type and an item without any relation
|
|
161
|
+
await createMembershipItem({ organization, membershipTypeId: 'membership-type-b' });
|
|
162
|
+
await new BalanceItemFactory({
|
|
163
|
+
organizationId: organization.id,
|
|
164
|
+
type: BalanceItemType.Other,
|
|
165
|
+
amount: 1,
|
|
166
|
+
unitPrice: 10_00,
|
|
167
|
+
}).create();
|
|
168
|
+
|
|
169
|
+
const response = await getBalanceItems({
|
|
170
|
+
filter: {
|
|
171
|
+
membershipType: {
|
|
172
|
+
$in: [membershipTypeId],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
organization,
|
|
176
|
+
user,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(response.status).toBe(200);
|
|
180
|
+
expect(response.body.results.map(r => r.id)).toEqual([matchingItem.id]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|