@stamhoofd/backend 2.133.0 → 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.133.0",
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.133.0",
61
- "@stamhoofd/backend-i18n": "2.133.0",
62
- "@stamhoofd/backend-middleware": "2.133.0",
63
- "@stamhoofd/crons": "2.133.0",
64
- "@stamhoofd/email": "2.133.0",
65
- "@stamhoofd/excel-writer": "2.133.0",
66
- "@stamhoofd/logging": "2.133.0",
67
- "@stamhoofd/models": "2.133.0",
68
- "@stamhoofd/object-differ": "2.133.0",
69
- "@stamhoofd/queues": "2.133.0",
70
- "@stamhoofd/sql": "2.133.0",
71
- "@stamhoofd/structures": "2.133.0",
72
- "@stamhoofd/types": "2.133.0",
73
- "@stamhoofd/utility": "2.133.0",
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.133.0",
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": "65d5f0a31f87b8e3b7a23349f08cf7943ef15bda"
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);
@@ -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
+ });
@@ -0,0 +1,158 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import type { PaginatedResponse, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, LimitedFilteredRequest, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, TranslatedString } from '@stamhoofd/structures';
6
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
7
+ import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
8
+
9
+ // These tests exercise the balance-item filters reused inside the payments query (balanceItemPayments ->
10
+ // balanceItem -> ...), which is the path where balance_items is joined into another query.
11
+ describe('Endpoint.GetPaymentsEndpoint', () => {
12
+ const endpoint = new GetPaymentsEndpoint();
13
+
14
+ const getPayments = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
15
+ const token = await Token.createToken(user);
16
+
17
+ const request = Request.get({
18
+ path: '/payments',
19
+ host: organization.getApiHost(),
20
+ query: new LimitedFilteredRequest({
21
+ filter,
22
+ limit: 100,
23
+ }),
24
+ headers: {
25
+ authorization: 'Bearer ' + token.accessToken,
26
+ },
27
+ });
28
+
29
+ return testServer.test<PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>>(endpoint, request);
30
+ };
31
+
32
+ const createFinanceUser = async (organization: Organization) => {
33
+ return await new UserFactory({
34
+ organization,
35
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ }).create();
37
+ };
38
+
39
+ const createPaymentForBalanceItem = async (organization: Organization, balanceItem: BalanceItem) => {
40
+ const payment = new Payment();
41
+ payment.method = PaymentMethod.Transfer;
42
+ payment.status = PaymentStatus.Succeeded;
43
+ payment.organizationId = organization.id;
44
+ payment.price = 10_00;
45
+ await payment.save();
46
+
47
+ const balanceItemPayment = new BalanceItemPayment();
48
+ balanceItemPayment.balanceItemId = balanceItem.id;
49
+ balanceItemPayment.paymentId = payment.id;
50
+ balanceItemPayment.price = 10_00;
51
+ balanceItemPayment.organizationId = organization.id;
52
+ await balanceItemPayment.save();
53
+
54
+ return payment;
55
+ };
56
+
57
+ describe('Filtering on the balance item of a payment', () => {
58
+ test('only returns payments whose balance item is linked to an order of the given webshop', async () => {
59
+ const organization = await new OrganizationFactory({}).create();
60
+ const user = await createFinanceUser(organization);
61
+
62
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
63
+ const otherWebshop = await new WebshopFactory({ organizationId: organization.id }).create();
64
+
65
+ const order = await new OrderFactory({ webshop }).create();
66
+ const otherOrder = await new OrderFactory({ webshop: otherWebshop }).create();
67
+
68
+ const matchingItem = await new BalanceItemFactory({
69
+ organizationId: organization.id,
70
+ orderId: order.id,
71
+ type: BalanceItemType.Order,
72
+ amount: 1,
73
+ unitPrice: 10_00,
74
+ }).create();
75
+ const matchingPayment = await createPaymentForBalanceItem(organization, matchingItem);
76
+
77
+ // Negative control: a payment for a balance item of another webshop's order
78
+ const otherItem = await new BalanceItemFactory({
79
+ organizationId: organization.id,
80
+ orderId: otherOrder.id,
81
+ type: BalanceItemType.Order,
82
+ amount: 1,
83
+ unitPrice: 10_00,
84
+ }).create();
85
+ await createPaymentForBalanceItem(organization, otherItem);
86
+
87
+ const response = await getPayments({
88
+ filter: {
89
+ balanceItemPayments: {
90
+ $elemMatch: {
91
+ balanceItem: {
92
+ order: {
93
+ webshopId: {
94
+ $in: [webshop.id],
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ },
101
+ organization,
102
+ user,
103
+ });
104
+
105
+ expect(response.status).toBe(200);
106
+ expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
107
+ });
108
+
109
+ test('only returns payments whose balance item matches the given membership type', async () => {
110
+ const organization = await new OrganizationFactory({}).create();
111
+ const user = await createFinanceUser(organization);
112
+
113
+ const createMembershipItem = async (membershipTypeId: string) => {
114
+ return await new BalanceItemFactory({
115
+ organizationId: organization.id,
116
+ type: BalanceItemType.PlatformMembership,
117
+ amount: 1,
118
+ unitPrice: 10_00,
119
+ relations: new Map([
120
+ [
121
+ BalanceItemRelationType.MembershipType,
122
+ BalanceItemRelation.create({
123
+ id: membershipTypeId,
124
+ name: new TranslatedString('Membership type'),
125
+ }),
126
+ ],
127
+ ]),
128
+ }).create();
129
+ };
130
+
131
+ const matchingItem = await createMembershipItem('membership-type-a');
132
+ const matchingPayment = await createPaymentForBalanceItem(organization, matchingItem);
133
+
134
+ // Negative control: a payment for a balance item with a different membership type
135
+ const otherItem = await createMembershipItem('membership-type-b');
136
+ await createPaymentForBalanceItem(organization, otherItem);
137
+
138
+ const response = await getPayments({
139
+ filter: {
140
+ balanceItemPayments: {
141
+ $elemMatch: {
142
+ balanceItem: {
143
+ membershipType: {
144
+ $in: ['membership-type-a'],
145
+ },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ organization,
151
+ user,
152
+ });
153
+
154
+ expect(response.status).toBe(200);
155
+ expect(response.body.results.map(r => r.id)).toEqual([matchingPayment.id]);
156
+ });
157
+ });
158
+ });
@@ -235,7 +235,7 @@ export class StripeReportInvoicer {
235
235
  sellingOrganization,
236
236
  type: 'services',
237
237
  });
238
- item.VATIncluded = true;
238
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
239
239
  item.quantity = 1;
240
240
  item.unitPrice = applicationFee.serviceFee;
241
241
  item.createdAt = new Date();
@@ -265,7 +265,7 @@ export class StripeReportInvoicer {
265
265
  sellingOrganization,
266
266
  type: 'services',
267
267
  });
268
- item.VATIncluded = true;
268
+ item.VATIncluded = !item.VATExcempt; // Makes sure price with VAT always matches the charged amount
269
269
  item.quantity = 1;
270
270
  item.unitPrice = applicationFee.transferFee;
271
271
  item.createdAt = new Date();
@@ -276,6 +276,17 @@ export class StripeReportInvoicer {
276
276
  balanceItems.push(item);
277
277
  }
278
278
 
279
+ let total = 0;
280
+ for (const balanceItem of balanceItems) {
281
+ total += balanceItem.priceWithVAT;
282
+ }
283
+ if (total !== applicationFee.amount) {
284
+ throw new SimpleError({
285
+ code: 'price_mismatched',
286
+ message: 'The charged amount does not match the total application fee for the payment',
287
+ });
288
+ }
289
+
279
290
  const systemUser = await User.getSystem();
280
291
 
281
292
  // Done validation
@@ -74,7 +74,7 @@ export async function checkPermissionsFor(access_token: string, organizationId:
74
74
  });
75
75
  });
76
76
  assertIsPermissionsResponse(json);
77
- const neededPermissions = organizationId
77
+ const neededPermissions = !organizationId
78
78
  ? [{
79
79
  permission: 'PASSES_READ',
80
80
  human: 'Basis UiTPAS informatie ophalen met UiTPAS nummer',
@@ -1,5 +1,6 @@
1
1
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
- import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+ import { balanceItemFilterCompilers } from './balance-items.js';
3
4
 
4
5
  export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
5
6
  ...baseSQLFilterCompilers,
@@ -13,59 +14,6 @@ export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
13
14
  type: SQLValueType.Number,
14
15
  nullable: false,
15
16
  }),
16
- balanceItem: {
17
- ...baseSQLFilterCompilers,
18
- id: createColumnFilter({
19
- expression: SQL.column('balance_items', 'id'),
20
- type: SQLValueType.String,
21
- nullable: false,
22
- }),
23
- description: createColumnFilter({
24
- expression: SQL.column('balance_items', 'description'),
25
- type: SQLValueType.String,
26
- nullable: false,
27
- }),
28
- payingOrganizationId: createColumnFilter({
29
- expression: SQL.column('balance_items', 'payingOrganizationId'),
30
- type: SQLValueType.String,
31
- nullable: true,
32
- }),
33
- type: createColumnFilter({
34
- expression: SQL.column('balance_items', 'type'),
35
- type: SQLValueType.String,
36
- nullable: false,
37
- }),
38
- registration: createExistsFilter(
39
- SQL.select()
40
- .from(SQL.table('registrations'))
41
- .where(
42
- SQL.column('registrations', 'id'),
43
- SQL.column('balance_items', 'registrationId'),
44
- ),
45
- {
46
- ...baseSQLFilterCompilers,
47
- groupId: createColumnFilter({
48
- expression: SQL.column('registrations', 'groupId'),
49
- type: SQLValueType.String,
50
- nullable: false,
51
- }),
52
- },
53
- ),
54
- order: createExistsFilter(
55
- SQL.select()
56
- .from(SQL.table('webshop_orders'))
57
- .where(
58
- SQL.column('webshop_orders', 'id'),
59
- SQL.column('balance_items', 'orderId'),
60
- ),
61
- {
62
- ...baseSQLFilterCompilers,
63
- webshopId: createColumnFilter({
64
- expression: SQL.column('webshop_orders', 'webshopId'),
65
- type: SQLValueType.String,
66
- nullable: false,
67
- }),
68
- },
69
- ),
70
- },
17
+ // Reuse the shared balance item filters (type, webshop, group, membership type, ...)
18
+ balanceItem: balanceItemFilterCompilers,
71
19
  };
@@ -1,56 +1,115 @@
1
1
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
- import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+ import { BalanceItemRelationType } from '@stamhoofd/structures';
4
+
5
+ /**
6
+ * Filters on the relations of a balance item (registration group, webshop order and membership type).
7
+ *
8
+ * Grouped separately from the plain column filters because these are JOIN/JSON based rather than simple
9
+ * columns. They are spread into balanceItemFilterCompilers below, which is the single set consumed both
10
+ * by the direct balance items endpoint and by the balance items nested inside payments (see
11
+ * balance-item-payments.ts) — so both automatically expose these relation filters.
12
+ *
13
+ * All expressions are qualified with the balance_items table so they keep working when balance_items
14
+ * is joined into another query (e.g. via balance_item_payments).
15
+ */
16
+ export const balanceItemRelationFilterCompilers: SQLFilterDefinitions = {
17
+ registration: createExistsFilter(
18
+ SQL.select()
19
+ .from(SQL.table('registrations'))
20
+ .where(
21
+ SQL.column('registrations', 'id'),
22
+ SQL.column('balance_items', 'registrationId'),
23
+ ),
24
+ {
25
+ ...baseSQLFilterCompilers,
26
+ groupId: createColumnFilter({
27
+ expression: SQL.column('registrations', 'groupId'),
28
+ type: SQLValueType.String,
29
+ nullable: false,
30
+ }),
31
+ },
32
+ ),
33
+ order: createExistsFilter(
34
+ SQL.select()
35
+ .from(SQL.table('webshop_orders'))
36
+ .where(
37
+ SQL.column('webshop_orders', 'id'),
38
+ SQL.column('balance_items', 'orderId'),
39
+ ),
40
+ {
41
+ ...baseSQLFilterCompilers,
42
+ webshopId: createColumnFilter({
43
+ expression: SQL.column('webshop_orders', 'webshopId'),
44
+ type: SQLValueType.String,
45
+ nullable: false,
46
+ }),
47
+ },
48
+ ),
49
+ // The membership type is only stored inside the relations JSON, so we read its id from there.
50
+ membershipType: createColumnFilter({
51
+ expression: SQL.jsonExtract(SQL.column('balance_items', 'relations'), `$.value.${BalanceItemRelationType.MembershipType}.id`),
52
+ type: SQLValueType.JSONString,
53
+ nullable: true,
54
+ }),
55
+ };
3
56
 
4
57
  /**
5
58
  * Defines how to filter balance items in the database from StamhoofdFilter objects
6
59
  */
7
60
  export const balanceItemFilterCompilers: SQLFilterDefinitions = {
8
61
  ...baseSQLFilterCompilers,
62
+ ...balanceItemRelationFilterCompilers,
9
63
  id: createColumnFilter({
10
- expression: SQL.column('id'),
64
+ expression: SQL.column('balance_items', 'id'),
11
65
  type: SQLValueType.String,
12
66
  nullable: false,
13
67
  }),
14
68
  organizationId: createColumnFilter({
15
- expression: SQL.column('organizationId'),
69
+ expression: SQL.column('balance_items', 'organizationId'),
16
70
  type: SQLValueType.String,
17
71
  nullable: false,
18
72
  }),
73
+ payingOrganizationId: createColumnFilter({
74
+ expression: SQL.column('balance_items', 'payingOrganizationId'),
75
+ type: SQLValueType.String,
76
+ nullable: true,
77
+ }),
19
78
  type: createColumnFilter({
20
- expression: SQL.column('type'),
79
+ expression: SQL.column('balance_items', 'type'),
21
80
  type: SQLValueType.String,
22
81
  nullable: false,
23
82
  }),
24
83
  status: createColumnFilter({
25
- expression: SQL.column('status'),
84
+ expression: SQL.column('balance_items', 'status'),
26
85
  type: SQLValueType.String,
27
86
  nullable: false,
28
87
  }),
29
88
  createdAt: createColumnFilter({
30
- expression: SQL.column('createdAt'),
89
+ expression: SQL.column('balance_items', 'createdAt'),
31
90
  type: SQLValueType.Datetime,
32
91
  nullable: false,
33
92
  }),
34
93
  updatedAt: createColumnFilter({
35
- expression: SQL.column('updatedAt'),
94
+ expression: SQL.column('balance_items', 'updatedAt'),
36
95
  type: SQLValueType.Datetime,
37
96
  nullable: false,
38
97
  }),
39
98
 
40
99
  description: createColumnFilter({
41
- expression: SQL.column('description'),
100
+ expression: SQL.column('balance_items', 'description'),
42
101
  type: SQLValueType.String,
43
102
  nullable: false,
44
103
  }),
45
104
 
46
105
  priceWithVAT: createColumnFilter({
47
- expression: SQL.column('priceTotal'),
106
+ expression: SQL.column('balance_items', 'priceTotal'),
48
107
  type: SQLValueType.Number,
49
108
  nullable: false,
50
109
  }),
51
110
 
52
111
  priceOpen: createColumnFilter({
53
- expression: SQL.column('priceOpen'),
112
+ expression: SQL.column('balance_items', 'priceOpen'),
54
113
  type: SQLValueType.Number,
55
114
  nullable: false,
56
115
  }),