@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.132.1",
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.10",
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.132.1",
61
- "@stamhoofd/backend-i18n": "2.132.1",
62
- "@stamhoofd/backend-middleware": "2.132.1",
63
- "@stamhoofd/crons": "2.132.1",
64
- "@stamhoofd/email": "2.132.1",
65
- "@stamhoofd/excel-writer": "2.132.1",
66
- "@stamhoofd/logging": "2.132.1",
67
- "@stamhoofd/models": "2.132.1",
68
- "@stamhoofd/object-differ": "2.132.1",
69
- "@stamhoofd/queues": "2.132.1",
70
- "@stamhoofd/sql": "2.132.1",
71
- "@stamhoofd/structures": "2.132.1",
72
- "@stamhoofd/types": "2.132.1",
73
- "@stamhoofd/utility": "2.132.1",
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.132.1",
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": "99e4195c2638521cf24f1e5ebef5ae9bf5009d1d"
110
+ "gitHead": "42955365bcf0bcfd0815998c1039e23d6ee1d6c6"
111
111
  }
@@ -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 10 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin)
56
- const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 10_0000;
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
- return Formatter.luxon(p.paidAt ?? p.createdAt).plus({ month: 1 }).set({ day: 10 }).startOf('day').toJSDate();
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.auth.hasSomePlatformAccess() ? 1000 : 100;
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
+ });