@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 +18 -18
- package/src/crons/invoices.ts +5 -3
- 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/payments/GetPaymentsEndpoint.test.ts +158 -0
- package/src/helpers/StripeInvoicer.ts +13 -2
- 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);
|
|
@@ -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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
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
|
}),
|