@stamhoofd/backend 2.34.1 → 2.36.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 +12 -12
- package/src/crons/clear-excel-cache.test.ts +152 -0
- package/src/crons/clear-excel-cache.ts +92 -0
- package/src/crons.ts +11 -0
- package/src/endpoints/global/events/PatchEventsEndpoint.ts +18 -0
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +4 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +9 -22
- package/src/endpoints/global/registration/GetUserBillingStatusEndpoint.ts +80 -0
- package/src/endpoints/global/registration/GetUserDetailedBillingStatusEndpoint.ts +83 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +48 -39
- package/src/endpoints/organization/dashboard/billing/{GetBillingStatusEndpoint.ts → GetOrganizationBillingStatusEndpoint.ts} +5 -6
- package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedBillingStatusEndpoint.ts +58 -0
- package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +9 -4
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -0
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +0 -1
- package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +17 -37
- package/src/helpers/AdminPermissionChecker.ts +10 -5
- package/src/seeds/1726055544-balance-item-paid.ts +11 -0
- package/src/seeds/1726055545-balance-item-pending.ts +11 -0
- package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +0 -39
- package/src/endpoints/organization/dashboard/billing/GetDetailedBillingStatusEndpoint.ts +0 -77
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.36.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@types/luxon": "3.4.2",
|
|
26
26
|
"@types/mailparser": "3.4.4",
|
|
27
27
|
"@types/mysql": "^2.15.20",
|
|
28
|
-
"@types/node": "^
|
|
28
|
+
"@types/node": "^20.12",
|
|
29
29
|
"nock": "^13.5.1",
|
|
30
30
|
"qs": "^6.11.2",
|
|
31
31
|
"sinon": "^18.0.0"
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"@simonbackx/simple-encoding": "2.15.1",
|
|
37
37
|
"@simonbackx/simple-endpoints": "1.14.0",
|
|
38
38
|
"@simonbackx/simple-logging": "^1.0.1",
|
|
39
|
-
"@stamhoofd/backend-i18n": "2.
|
|
40
|
-
"@stamhoofd/backend-middleware": "2.
|
|
41
|
-
"@stamhoofd/email": "2.
|
|
42
|
-
"@stamhoofd/models": "2.
|
|
43
|
-
"@stamhoofd/queues": "2.
|
|
44
|
-
"@stamhoofd/sql": "2.
|
|
45
|
-
"@stamhoofd/structures": "2.
|
|
46
|
-
"@stamhoofd/utility": "2.
|
|
39
|
+
"@stamhoofd/backend-i18n": "2.36.0",
|
|
40
|
+
"@stamhoofd/backend-middleware": "2.36.0",
|
|
41
|
+
"@stamhoofd/email": "2.36.0",
|
|
42
|
+
"@stamhoofd/models": "2.36.0",
|
|
43
|
+
"@stamhoofd/queues": "2.36.0",
|
|
44
|
+
"@stamhoofd/sql": "2.36.0",
|
|
45
|
+
"@stamhoofd/structures": "2.36.0",
|
|
46
|
+
"@stamhoofd/utility": "2.36.0",
|
|
47
47
|
"archiver": "^7.0.1",
|
|
48
48
|
"aws-sdk": "^2.885.0",
|
|
49
49
|
"axios": "1.6.8",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"mysql": "^2.18.1",
|
|
58
58
|
"node-rsa": "1.1.1",
|
|
59
59
|
"openid-client": "^5.4.0",
|
|
60
|
-
"postmark": "4.0.
|
|
60
|
+
"postmark": "^4.0.5",
|
|
61
61
|
"stripe": "^16.6.0"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "8d1f0861fee07ca2047d13b0402011e6a9d272c3"
|
|
64
64
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Dirent } from "fs";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { clearExcelCacheHelper } from "./clear-excel-cache";
|
|
4
|
+
|
|
5
|
+
const testPath = '/Users/user/project/backend/app/api/.cache';
|
|
6
|
+
jest.mock("fs/promises");
|
|
7
|
+
const fsMock = jest.mocked(fs, true)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
describe("clearExcelCacheHelper", () => {
|
|
11
|
+
|
|
12
|
+
it('should only run between 3 and 6 AM', async () => {
|
|
13
|
+
//#region arrange
|
|
14
|
+
const shouldFail = [
|
|
15
|
+
new Date(2024,1,1,0,0),
|
|
16
|
+
new Date(2024,1,1,2,50),
|
|
17
|
+
new Date(2024,1,1,2,59, 59, 999),
|
|
18
|
+
new Date(2024,1,1,6,0)
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const shouldPass = [
|
|
22
|
+
new Date(2024,1,1,3,0),
|
|
23
|
+
new Date(2024,1,1,3,55),
|
|
24
|
+
new Date(2024,1,1,5,59, 59, 999)
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
fsMock.readdir.mockReturnValue(Promise.resolve([]));
|
|
28
|
+
//#endregion
|
|
29
|
+
|
|
30
|
+
//#region act and assert
|
|
31
|
+
for(const date of shouldFail) {
|
|
32
|
+
const didClear = await clearExcelCacheHelper({
|
|
33
|
+
lastExcelClear: null,
|
|
34
|
+
now: date,
|
|
35
|
+
cachePath: testPath,
|
|
36
|
+
environment: 'production'
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(didClear).toBeFalse();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for(const date of shouldPass) {
|
|
43
|
+
const didClear = await clearExcelCacheHelper({
|
|
44
|
+
lastExcelClear: null,
|
|
45
|
+
now: date,
|
|
46
|
+
cachePath: testPath,
|
|
47
|
+
environment: 'production'
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(didClear).toBeTrue();
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should only run once each day', async () => {
|
|
56
|
+
//#region arrange
|
|
57
|
+
const firstTry = new Date(2024,1,1,3,0);
|
|
58
|
+
const secondTry = new Date(2024,1,1,3,5);
|
|
59
|
+
const thirdTry = new Date(2024,1,2,3,0);
|
|
60
|
+
const fourthTry = new Date(2024,1,2,3,5);
|
|
61
|
+
|
|
62
|
+
fsMock.readdir.mockReturnValue(Promise.resolve([]));
|
|
63
|
+
//#endregion
|
|
64
|
+
|
|
65
|
+
//#region act and assert
|
|
66
|
+
|
|
67
|
+
// second try, should fail because 5 min earlier the cache was cleared
|
|
68
|
+
const didClearSecondTry = await clearExcelCacheHelper({
|
|
69
|
+
lastExcelClear: firstTry.getTime(),
|
|
70
|
+
now: secondTry,
|
|
71
|
+
cachePath: testPath,
|
|
72
|
+
environment: 'production'
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(didClearSecondTry).toBeFalse();
|
|
76
|
+
|
|
77
|
+
// third try, should pass because the last clear was more than a day ago
|
|
78
|
+
const didClearThirdTry = await clearExcelCacheHelper({
|
|
79
|
+
lastExcelClear: secondTry.getTime(),
|
|
80
|
+
now: thirdTry,
|
|
81
|
+
cachePath: testPath,
|
|
82
|
+
environment: 'production'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(didClearThirdTry).toBeTrue();
|
|
86
|
+
|
|
87
|
+
// fourth try, should fail because 5 min earlier the cache was cleared
|
|
88
|
+
const didClearFourthTry = await clearExcelCacheHelper({
|
|
89
|
+
lastExcelClear: thirdTry.getTime(),
|
|
90
|
+
now: fourthTry,
|
|
91
|
+
cachePath: testPath,
|
|
92
|
+
environment: 'production'
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(didClearFourthTry).toBeFalse();
|
|
96
|
+
//#endregion
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should delete old cache only', async () => {
|
|
100
|
+
//#region arrange
|
|
101
|
+
const now = new Date(2024,0,5,3,5);
|
|
102
|
+
|
|
103
|
+
const dir1 = new Dirent();
|
|
104
|
+
dir1.name = '2024-01-01';
|
|
105
|
+
|
|
106
|
+
const dir2 = new Dirent();
|
|
107
|
+
dir2.name = '2024-01-02';
|
|
108
|
+
|
|
109
|
+
const dir3 = new Dirent();
|
|
110
|
+
dir3.name = '2024-01-03';
|
|
111
|
+
|
|
112
|
+
const dir4 = new Dirent();
|
|
113
|
+
dir4.name = '2024-01-04';
|
|
114
|
+
|
|
115
|
+
const dir5 = new Dirent();
|
|
116
|
+
dir5.name = '2024-01-05';
|
|
117
|
+
|
|
118
|
+
const dir6 = new Dirent();
|
|
119
|
+
dir6.name = 'not-a-date';
|
|
120
|
+
|
|
121
|
+
const dir7 = new Dirent();
|
|
122
|
+
dir7.name = 'noDate';
|
|
123
|
+
|
|
124
|
+
const file1 = new Dirent();
|
|
125
|
+
file1.name = 'some-file';
|
|
126
|
+
file1.parentPath = testPath;
|
|
127
|
+
file1.isDirectory = () => false;
|
|
128
|
+
|
|
129
|
+
const directories = [dir1, dir2, dir3, dir4, dir5, dir6, dir7];
|
|
130
|
+
|
|
131
|
+
directories.forEach(dir => {
|
|
132
|
+
dir.parentPath = testPath;
|
|
133
|
+
dir.isDirectory = () => true;
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
fsMock.readdir.mockReturnValue(Promise.resolve([...directories, file1]));
|
|
137
|
+
//#endregion
|
|
138
|
+
|
|
139
|
+
// act
|
|
140
|
+
await clearExcelCacheHelper({
|
|
141
|
+
lastExcelClear: null,
|
|
142
|
+
now,
|
|
143
|
+
cachePath: testPath,
|
|
144
|
+
environment: 'production'
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// assert
|
|
148
|
+
expect(fsMock.rm).toHaveBeenCalledTimes(2);
|
|
149
|
+
expect(fsMock.rm).toHaveBeenCalledWith("/Users/user/project/backend/app/api/.cache/2024-01-01", { recursive: true, force: true });
|
|
150
|
+
expect(fsMock.rm).toHaveBeenCalledWith("/Users/user/project/backend/app/api/.cache/2024-01-02",{ recursive: true, force: true });
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
const msIn22Hours = 79200000;
|
|
4
|
+
let lastExcelClear: number | null = null;
|
|
5
|
+
|
|
6
|
+
export async function clearExcelCache() {
|
|
7
|
+
const now = new Date();
|
|
8
|
+
|
|
9
|
+
const didClear = await clearExcelCacheHelper({
|
|
10
|
+
lastExcelClear,
|
|
11
|
+
now,
|
|
12
|
+
cachePath: STAMHOOFD.CACHE_PATH,
|
|
13
|
+
environment: STAMHOOFD.environment
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if(didClear) {
|
|
17
|
+
lastExcelClear = now.getTime();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function clearExcelCacheHelper
|
|
22
|
+
({lastExcelClear, now, cachePath, environment}: {lastExcelClear: number | null, now: Date, cachePath: string, environment: "production" | "development" | "staging" | "test"}): Promise<boolean> {
|
|
23
|
+
if (environment === "development") {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hour = now.getHours();
|
|
28
|
+
|
|
29
|
+
// between 3 and 6 AM
|
|
30
|
+
if(hour < 3 || hour > 5) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// only run once each day
|
|
35
|
+
if(lastExcelClear !== null && lastExcelClear + msIn22Hours > now.getTime()) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const currentYear = now.getFullYear();
|
|
40
|
+
const currentMonth = now.getMonth();
|
|
41
|
+
const currentDay = now.getDate();
|
|
42
|
+
|
|
43
|
+
const maxDaysInCache = 2;
|
|
44
|
+
|
|
45
|
+
const dateLimit = new Date(currentYear, currentMonth, currentDay - maxDaysInCache, 0,0,0,0);
|
|
46
|
+
|
|
47
|
+
const files = await fs.readdir(cachePath, {withFileTypes: true});
|
|
48
|
+
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
if (file.isDirectory()) {
|
|
51
|
+
try {
|
|
52
|
+
const date = getDateFromDirectoryName(file.name);
|
|
53
|
+
const shouldDelete = date < dateLimit;
|
|
54
|
+
|
|
55
|
+
if(shouldDelete) {
|
|
56
|
+
const path = file.parentPath + '/' + file.name;
|
|
57
|
+
await fs.rm(path, { recursive: true, force: true })
|
|
58
|
+
console.log("Removed", path)
|
|
59
|
+
}
|
|
60
|
+
} catch(error) {
|
|
61
|
+
console.error(error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getDateFromDirectoryName(file: string): Date {
|
|
70
|
+
const parts = file.split('-');
|
|
71
|
+
|
|
72
|
+
if (parts.length != 3) {
|
|
73
|
+
throw new Error(`Invalid directory ${file} in cache.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const year = parseInt(parts[0]);
|
|
77
|
+
if(isNaN(year)) {
|
|
78
|
+
throw new Error(`Year is NAN for directory ${file} in cache.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const month = parseInt(parts[1]);
|
|
82
|
+
if(isNaN(month)) {
|
|
83
|
+
throw new Error(`Month is NAN for directory ${file} in cache.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const day = parseInt(parts[2]);
|
|
87
|
+
if(isNaN(day)) {
|
|
88
|
+
throw new Error(`Day is NAN for directory ${file} in cache.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Date(year, month - 1, day);
|
|
92
|
+
}
|
package/src/crons.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Formatter, sleep } from '@stamhoofd/utility';
|
|
|
7
7
|
import AWS from 'aws-sdk';
|
|
8
8
|
import { DateTime } from 'luxon';
|
|
9
9
|
|
|
10
|
+
import { clearExcelCache } from './crons/clear-excel-cache';
|
|
10
11
|
import { ExchangePaymentEndpoint } from './endpoints/organization/shared/ExchangePaymentEndpoint';
|
|
11
12
|
import { checkSettlements } from './helpers/CheckSettlements';
|
|
12
13
|
import { ForwardHandler } from './helpers/ForwardHandler';
|
|
@@ -575,6 +576,10 @@ async function checkReservedUntil() {
|
|
|
575
576
|
}
|
|
576
577
|
})
|
|
577
578
|
|
|
579
|
+
for(const registration of registrations) {
|
|
580
|
+
registration.scheduleStockUpdate()
|
|
581
|
+
}
|
|
582
|
+
|
|
578
583
|
// Update occupancy
|
|
579
584
|
for (const group of groups) {
|
|
580
585
|
await group.updateOccupancy()
|
|
@@ -712,6 +717,12 @@ registeredCronJobs.push({
|
|
|
712
717
|
running: false
|
|
713
718
|
});
|
|
714
719
|
|
|
720
|
+
registeredCronJobs.push({
|
|
721
|
+
name: 'clearExcelCache',
|
|
722
|
+
method: clearExcelCache,
|
|
723
|
+
running: false
|
|
724
|
+
})
|
|
725
|
+
|
|
715
726
|
async function run(name: string, handler: () => Promise<void>) {
|
|
716
727
|
try {
|
|
717
728
|
await logger.setContext({
|
|
@@ -256,6 +256,24 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
256
256
|
events.push(event)
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
for (const id of request.body.getDeletes()) {
|
|
260
|
+
const event = await Event.getByID(id);
|
|
261
|
+
if (!event) {
|
|
262
|
+
throw new SimpleError({ code: "not_found", message: "Event not found", statusCode: 404 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!(await Context.auth.canAccessEvent(event, PermissionLevel.Full))) {
|
|
266
|
+
throw Context.auth.error()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if(event.groupId) {
|
|
270
|
+
await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(event.groupId)
|
|
271
|
+
event.groupId = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await event.delete();
|
|
275
|
+
}
|
|
276
|
+
|
|
259
277
|
return new Response(
|
|
260
278
|
await AuthenticatedStructures.events(events)
|
|
261
279
|
);
|
|
@@ -99,7 +99,8 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
99
99
|
token: 'downloadUrl',
|
|
100
100
|
value: url
|
|
101
101
|
}))
|
|
102
|
-
]
|
|
102
|
+
],
|
|
103
|
+
type: 'transactional'
|
|
103
104
|
})
|
|
104
105
|
|
|
105
106
|
}
|
|
@@ -113,7 +114,8 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
113
114
|
},
|
|
114
115
|
recipients: [
|
|
115
116
|
user.createRecipient()
|
|
116
|
-
]
|
|
117
|
+
],
|
|
118
|
+
type: 'transactional'
|
|
117
119
|
})
|
|
118
120
|
}
|
|
119
121
|
throw error
|
|
@@ -71,10 +71,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
71
71
|
}
|
|
72
72
|
return null
|
|
73
73
|
}
|
|
74
|
-
const updateGroups = new Map<string, Group>()
|
|
75
|
-
|
|
76
|
-
const balanceItemMemberIds: string[] = []
|
|
77
|
-
const balanceItemRegistrationIdsPerOrganization: Map<string, string[]> = new Map()
|
|
74
|
+
const updateGroups = new Map<string, Group>();
|
|
75
|
+
const updateRegistrations = new Map<string, Registration>();
|
|
78
76
|
const updateMembershipMemberIds = new Set<string>()
|
|
79
77
|
|
|
80
78
|
// Loop all members one by one
|
|
@@ -144,7 +142,6 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
144
142
|
|
|
145
143
|
await member.save()
|
|
146
144
|
members.push(member)
|
|
147
|
-
balanceItemMemberIds.push(member.id)
|
|
148
145
|
updateMembershipMemberIds.add(member.id)
|
|
149
146
|
|
|
150
147
|
// Auto link users based on data
|
|
@@ -505,10 +502,10 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
505
502
|
await member.delete()
|
|
506
503
|
shouldUpdateSetupSteps = true
|
|
507
504
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
505
|
+
for(const registration of member.registrations) {
|
|
506
|
+
const groupId = registration.groupId;
|
|
507
|
+
const group = await getGroup(groupId);
|
|
508
|
+
updateRegistrations.set(registration.id, registration);
|
|
512
509
|
if (group) {
|
|
513
510
|
// We need to update this group occupancy because we moved one member away from it
|
|
514
511
|
updateGroups.set(group.id, group)
|
|
@@ -516,26 +513,16 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
516
513
|
}
|
|
517
514
|
}
|
|
518
515
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
await Registration.updateOutstandingBalance(Formatter.uniqueArray(balanceItemRegistrationIds), organizationId)
|
|
516
|
+
for(const registration of updateRegistrations.values()) {
|
|
517
|
+
registration.scheduleStockUpdate();
|
|
522
518
|
}
|
|
523
|
-
|
|
519
|
+
|
|
524
520
|
// Loop all groups and update occupancy if needed
|
|
525
521
|
for (const group of updateGroups.values()) {
|
|
526
522
|
await group.updateOccupancy()
|
|
527
523
|
await group.save()
|
|
528
524
|
}
|
|
529
525
|
|
|
530
|
-
// We need to refetch the outstanding amounts of members that have changed
|
|
531
|
-
const updatedMembers = balanceItemMemberIds.length > 0 ? await Member.getBlobByIds(...balanceItemMemberIds) : []
|
|
532
|
-
for (const member of updatedMembers) {
|
|
533
|
-
const index = members.findIndex(m => m.id === member.id)
|
|
534
|
-
if (index !== -1) {
|
|
535
|
-
members[index] = member
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
526
|
for (const member of members) {
|
|
540
527
|
if (updateMembershipMemberIds.has(member.id)) {
|
|
541
528
|
await member.updateMemberships()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
+
import { CachedOutstandingBalance, Member, Organization } from "@stamhoofd/models";
|
|
3
|
+
import { OrganizationBillingStatus, OrganizationBillingStatusItem } from "@stamhoofd/structures";
|
|
4
|
+
|
|
5
|
+
import { Formatter } from "@stamhoofd/utility";
|
|
6
|
+
import { AuthenticatedStructures } from "../../../helpers/AuthenticatedStructures";
|
|
7
|
+
import { Context } from "../../../helpers/Context";
|
|
8
|
+
|
|
9
|
+
type Params = Record<string, never>;
|
|
10
|
+
type Query = undefined
|
|
11
|
+
type Body = undefined
|
|
12
|
+
type ResponseBody = OrganizationBillingStatus
|
|
13
|
+
|
|
14
|
+
export class GetUserBilingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
15
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
16
|
+
if (request.method != "GET") {
|
|
17
|
+
return [false];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const params = Endpoint.parseParameters(request.url, "/user/billing/status", {});
|
|
21
|
+
|
|
22
|
+
if (params) {
|
|
23
|
+
return [true, params as Params];
|
|
24
|
+
}
|
|
25
|
+
return [false];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
29
|
+
const organization = await Context.setUserOrganizationScope();
|
|
30
|
+
const {user} = await Context.authenticate()
|
|
31
|
+
|
|
32
|
+
const memberIds = await Member.getMemberIdsWithRegistrationForUser(user)
|
|
33
|
+
|
|
34
|
+
return new Response(await GetUserBilingStatusEndpoint.getBillingStatusForObjects([user.id, ...memberIds], organization));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static async getBillingStatusForObjects(objectIds: string[], organization?: Organization|null) {
|
|
38
|
+
// Load cached balances
|
|
39
|
+
const cachedOutstandingBalances = await CachedOutstandingBalance.getForObjects(objectIds, organization?.id)
|
|
40
|
+
|
|
41
|
+
const organizationIds = Formatter.uniqueArray(cachedOutstandingBalances.map(b => b.organizationId))
|
|
42
|
+
|
|
43
|
+
let addOrganization = false
|
|
44
|
+
const i = organization ? organizationIds.indexOf(organization.id) : -1
|
|
45
|
+
if (i !== -1) {
|
|
46
|
+
organizationIds.splice(i, 1)
|
|
47
|
+
addOrganization = true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const organizations = await Organization.getByIDs(...organizationIds)
|
|
51
|
+
|
|
52
|
+
if (addOrganization && organization) {
|
|
53
|
+
organizations.push(organization)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const authenticatedOrganizations = await AuthenticatedStructures.organizations(organizations)
|
|
57
|
+
|
|
58
|
+
const billingStatus = OrganizationBillingStatus.create({})
|
|
59
|
+
|
|
60
|
+
for (const organization of authenticatedOrganizations) {
|
|
61
|
+
const items = cachedOutstandingBalances.filter(b => b.organizationId === organization.id)
|
|
62
|
+
|
|
63
|
+
let amount = 0;
|
|
64
|
+
let amountPending = 0;
|
|
65
|
+
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
amount += item.amount
|
|
68
|
+
amountPending += item.amountPending
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
billingStatus.organizations.push(OrganizationBillingStatusItem.create({
|
|
72
|
+
organization,
|
|
73
|
+
amount,
|
|
74
|
+
amountPending
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return billingStatus
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
+
import { BalanceItem, Member, Organization, Payment } from "@stamhoofd/models";
|
|
3
|
+
import { OrganizationDetailedBillingStatus, OrganizationDetailedBillingStatusItem } from "@stamhoofd/structures";
|
|
4
|
+
|
|
5
|
+
import { Formatter } from "@stamhoofd/utility";
|
|
6
|
+
import { AuthenticatedStructures } from "../../../helpers/AuthenticatedStructures";
|
|
7
|
+
import { Context } from "../../../helpers/Context";
|
|
8
|
+
|
|
9
|
+
type Params = Record<string, never>;
|
|
10
|
+
type Query = undefined
|
|
11
|
+
type Body = undefined
|
|
12
|
+
type ResponseBody = OrganizationDetailedBillingStatus
|
|
13
|
+
|
|
14
|
+
export class GetUserDetailedBilingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
15
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
16
|
+
if (request.method != "GET") {
|
|
17
|
+
return [false];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const params = Endpoint.parseParameters(request.url, "/user/billing/status/detailed", {});
|
|
21
|
+
|
|
22
|
+
if (params) {
|
|
23
|
+
return [true, params as Params];
|
|
24
|
+
}
|
|
25
|
+
return [false];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
29
|
+
const organization = await Context.setUserOrganizationScope();
|
|
30
|
+
const {user} = await Context.authenticate()
|
|
31
|
+
|
|
32
|
+
const memberIds = await Member.getMemberIdsWithRegistrationForUser(user)
|
|
33
|
+
|
|
34
|
+
const balanceItemModels = await BalanceItem.balanceItemsForUsersAndMembers(organization?.id ?? null, [user.id], memberIds);
|
|
35
|
+
|
|
36
|
+
// todo: this is a duplicate query
|
|
37
|
+
const {payments, balanceItemPayments} = await BalanceItem.loadPayments(balanceItemModels)
|
|
38
|
+
|
|
39
|
+
return new Response(await GetUserDetailedBilingStatusEndpoint.getDetailedBillingStatus(balanceItemModels, payments));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static async getDetailedBillingStatus(balanceItemModels: BalanceItem[], paymentModels: Payment[]) {
|
|
43
|
+
const organizationIds = Formatter.uniqueArray([
|
|
44
|
+
...balanceItemModels.map(b => b.organizationId),
|
|
45
|
+
...paymentModels.map(p => p.organizationId).filter(p => p !== null)
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
// Group by organization you'll have to pay to
|
|
49
|
+
if (organizationIds.length === 0) {
|
|
50
|
+
return OrganizationDetailedBillingStatus.create({})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Optimization: prevent fetching the organization we already have
|
|
54
|
+
const organization = Context.organization
|
|
55
|
+
|
|
56
|
+
let addOrganization = false
|
|
57
|
+
const i = organization ? organizationIds.indexOf(organization.id) : -1
|
|
58
|
+
if (i !== -1) {
|
|
59
|
+
organizationIds.splice(i, 1)
|
|
60
|
+
addOrganization = true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const organizationModels = await Organization.getByIDs(...organizationIds)
|
|
64
|
+
|
|
65
|
+
if (addOrganization && organization) {
|
|
66
|
+
organizationModels.push(organization)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels)
|
|
70
|
+
const organizations = await AuthenticatedStructures.organizations(organizationModels)
|
|
71
|
+
const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false)
|
|
72
|
+
|
|
73
|
+
return OrganizationDetailedBillingStatus.create({
|
|
74
|
+
organizations: organizations.map(o => {
|
|
75
|
+
return OrganizationDetailedBillingStatusItem.create({
|
|
76
|
+
organization: o,
|
|
77
|
+
balanceItems: balanceItems.filter(b => b.organizationId == o.id),
|
|
78
|
+
payments: payments.filter(p => p.organizationId === o.id)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -3,7 +3,6 @@ import { ManyToOneRelation } from '@simonbackx/simple-database';
|
|
|
3
3
|
import { Decoder } from '@simonbackx/simple-encoding';
|
|
4
4
|
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
-
import { I18n } from '@stamhoofd/backend-i18n';
|
|
7
6
|
import { Email } from '@stamhoofd/email';
|
|
8
7
|
import { BalanceItem, BalanceItemPayment, Group, Member, MemberWithRegistrations, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
|
|
9
8
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BalanceItemWithPayments, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, Version } from "@stamhoofd/structures";
|
|
@@ -100,8 +99,23 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
100
99
|
const deleteRegistrationIds = request.body.cart.deleteRegistrationIds
|
|
101
100
|
const deleteRegistrationModels = (deleteRegistrationIds.length ? (await Registration.getByIDs(...deleteRegistrationIds)) : []).filter(r => r.organizationId === organization.id)
|
|
102
101
|
|
|
102
|
+
// Validate balance items (can only happen serverside)
|
|
103
|
+
const balanceItemIds = request.body.cart.balanceItems.map(i => i.item.id)
|
|
104
|
+
let memberBalanceItemsStructs: BalanceItemWithPayments[] = []
|
|
105
|
+
let balanceItemsModels: BalanceItem[] = []
|
|
106
|
+
if (balanceItemIds.length > 0) {
|
|
107
|
+
balanceItemsModels = await BalanceItem.where({ id: { sign:'IN', value: balanceItemIds }, organizationId: organization.id })
|
|
108
|
+
if (balanceItemsModels.length != balanceItemIds.length) {
|
|
109
|
+
throw new SimpleError({
|
|
110
|
+
code: "invalid_data",
|
|
111
|
+
message: "Oeps, één of meerdere openstaande bedragen in jouw winkelmandje zijn aangepast. Herlaad de pagina en probeer opnieuw."
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
memberBalanceItemsStructs = await BalanceItem.getStructureWithPayments(balanceItemsModels)
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
const memberIds = Formatter.uniqueArray(
|
|
104
|
-
[...request.body.memberIds, ...deleteRegistrationModels.map(i => i.memberId)]
|
|
118
|
+
[...request.body.memberIds, ...deleteRegistrationModels.map(i => i.memberId), ...balanceItemsModels.map(i => i.memberId).filter(m => m !== null)]
|
|
105
119
|
)
|
|
106
120
|
const members = await Member.getBlobByIds(...memberIds)
|
|
107
121
|
const groupIds = request.body.groupIds
|
|
@@ -160,6 +174,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
160
174
|
|
|
161
175
|
const registrations: RegistrationWithMemberAndGroup[] = []
|
|
162
176
|
const payRegistrations: {registration: RegistrationWithMemberAndGroup, item: RegisterItem}[] = []
|
|
177
|
+
const deactivatedRegistrationGroupIds: string[] = [];
|
|
163
178
|
|
|
164
179
|
if (checkout.cart.isEmpty) {
|
|
165
180
|
throw new SimpleError({
|
|
@@ -168,27 +183,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
168
183
|
})
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
// Update occupancies
|
|
172
|
-
// TODO: might not be needed in the future (for performance)
|
|
173
|
-
for (const group of groups) {
|
|
174
|
-
await group.updateOccupancy()
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Validate balance items (can only happen serverside)
|
|
178
|
-
const balanceItemIds = request.body.cart.balanceItems.map(i => i.item.id)
|
|
179
|
-
let memberBalanceItemsStructs: BalanceItemWithPayments[] = []
|
|
180
|
-
let balanceItemsModels: BalanceItem[] = []
|
|
181
|
-
if (balanceItemIds.length > 0) {
|
|
182
|
-
balanceItemsModels = await BalanceItem.where({ id: { sign:'IN', value: balanceItemIds }, organizationId: organization.id })
|
|
183
|
-
if (balanceItemsModels.length != balanceItemIds.length) {
|
|
184
|
-
throw new SimpleError({
|
|
185
|
-
code: "invalid_data",
|
|
186
|
-
message: "Oeps, één of meerdere openstaande bedragen in jouw winkelmandje zijn aangepast. Herlaad de pagina en probeer opnieuw."
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
memberBalanceItemsStructs = await BalanceItem.getStructureWithPayments(balanceItemsModels)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
186
|
// Validate the cart
|
|
193
187
|
checkout.validate({memberBalanceItems: memberBalanceItemsStructs})
|
|
194
188
|
|
|
@@ -309,6 +303,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
309
303
|
console.log('Registering members using whoWillPayNow', whoWillPayNow, checkout.paymentMethod, totalPrice)
|
|
310
304
|
|
|
311
305
|
const createdBalanceItems: BalanceItem[] = []
|
|
306
|
+
const unrelatedCreatedBalanceItems: BalanceItem[] = []
|
|
312
307
|
const shouldMarkValid = whoWillPayNow === 'nobody' || checkout.paymentMethod === PaymentMethod.Transfer || checkout.paymentMethod === PaymentMethod.PointOfSale || checkout.paymentMethod === PaymentMethod.Unknown
|
|
313
308
|
|
|
314
309
|
// Create negative balance items
|
|
@@ -352,6 +347,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
352
347
|
|
|
353
348
|
// Clear the registration
|
|
354
349
|
await existingRegistration.deactivate()
|
|
350
|
+
deactivatedRegistrationGroupIds.push(existingRegistration.groupId);
|
|
355
351
|
|
|
356
352
|
const group = groups.find(g => g.id === existingRegistration.groupId)
|
|
357
353
|
if (!group) {
|
|
@@ -408,6 +404,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
408
404
|
await balanceItem2.save();
|
|
409
405
|
|
|
410
406
|
// do not add to createdBalanceItems array because we don't want to add this to the payment if we create a payment
|
|
407
|
+
unrelatedCreatedBalanceItems.push(balanceItem2)
|
|
411
408
|
} else {
|
|
412
409
|
balanceItem.memberId = registration.memberId;
|
|
413
410
|
balanceItem.userId = user.id
|
|
@@ -590,27 +587,31 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
590
587
|
}
|
|
591
588
|
|
|
592
589
|
// Make sure every price is accurate before creating a payment
|
|
593
|
-
await BalanceItem.updateOutstanding(createdBalanceItems,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
590
|
+
await BalanceItem.updateOutstanding([...createdBalanceItems, ...unrelatedCreatedBalanceItems])
|
|
591
|
+
try {
|
|
592
|
+
const response = await this.createPayment({
|
|
593
|
+
balanceItems: mappedBalanceItems,
|
|
594
|
+
organization,
|
|
595
|
+
user,
|
|
596
|
+
checkout: request.body,
|
|
597
|
+
members
|
|
598
|
+
})
|
|
601
599
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
600
|
+
if (response) {
|
|
601
|
+
paymentUrl = response.paymentUrl
|
|
602
|
+
payment = response.payment
|
|
603
|
+
}
|
|
604
|
+
} finally {
|
|
605
|
+
// Update cached balance items pending amount (only created balance items, because those are involved in the payment)
|
|
606
|
+
await BalanceItem.updateOutstanding(createdBalanceItems)
|
|
605
607
|
}
|
|
606
608
|
} else {
|
|
607
|
-
await BalanceItem.updateOutstanding(createdBalanceItems,
|
|
609
|
+
await BalanceItem.updateOutstanding([...createdBalanceItems, ...unrelatedCreatedBalanceItems])
|
|
608
610
|
}
|
|
609
611
|
|
|
610
|
-
|
|
611
612
|
// Update occupancy
|
|
612
613
|
for (const group of groups) {
|
|
613
|
-
if (registrations.
|
|
614
|
+
if (registrations.some(r => r.groupId === group.id) || deactivatedRegistrationGroupIds.some(id => id === group.id)) {
|
|
614
615
|
await group.updateOccupancy()
|
|
615
616
|
await group.save()
|
|
616
617
|
}
|
|
@@ -636,12 +637,20 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
636
637
|
throw new Error('Unexpected balance item from other organization')
|
|
637
638
|
}
|
|
638
639
|
|
|
639
|
-
if (price
|
|
640
|
+
if (price > 0 && price > Math.max(balanceItem.priceOpen, balanceItem.price - balanceItem.pricePaid)) {
|
|
640
641
|
throw new SimpleError({
|
|
641
642
|
code: "invalid_data",
|
|
642
|
-
message: "Oeps, het bedrag dat je probeert te betalen is ongeldig. Herlaad de pagina en probeer opnieuw."
|
|
643
|
+
message: "Oeps, het bedrag dat je probeert te betalen is ongeldig (het bedrag is hoger dan het bedrag dat je moet betalen). Herlaad de pagina en probeer opnieuw."
|
|
643
644
|
})
|
|
644
645
|
}
|
|
646
|
+
|
|
647
|
+
if (price < 0 && price < Math.min(balanceItem.priceOpen, balanceItem.price - balanceItem.pricePaid)) {
|
|
648
|
+
throw new SimpleError({
|
|
649
|
+
code: "invalid_data",
|
|
650
|
+
message: "Oeps, het bedrag dat je probeert te betalen is ongeldig (het terug te krijgen bedrag is hoger dan het bedrag dat je kan terug krijgen). Herlaad de pagina en probeer opnieuw."
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
645
654
|
totalPrice += price
|
|
646
655
|
|
|
647
656
|
if (price > 0 && balanceItem.memberId) {
|
|
@@ -2,19 +2,20 @@ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-
|
|
|
2
2
|
import { OrganizationBillingStatus } from "@stamhoofd/structures";
|
|
3
3
|
|
|
4
4
|
import { Context } from "../../../../helpers/Context";
|
|
5
|
+
import { GetUserBilingStatusEndpoint } from "../../../global/registration/GetUserBillingStatusEndpoint";
|
|
5
6
|
|
|
6
7
|
type Params = Record<string, never>;
|
|
7
8
|
type Query = undefined;
|
|
8
9
|
type ResponseBody = OrganizationBillingStatus;
|
|
9
10
|
type Body = undefined;
|
|
10
11
|
|
|
11
|
-
export class
|
|
12
|
+
export class GetDetailedBillingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
12
13
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
13
14
|
if (request.method != "GET") {
|
|
14
15
|
return [false];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
const params = Endpoint.parseParameters(request.url, "/billing/status", {});
|
|
18
|
+
const params = Endpoint.parseParameters(request.url, "/organization/billing/status", {});
|
|
18
19
|
|
|
19
20
|
if (params) {
|
|
20
21
|
return [true, params as Params];
|
|
@@ -30,9 +31,7 @@ export class GetBillingStatusEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
30
31
|
if (!await Context.auth.canManageFinances(organization.id)) {
|
|
31
32
|
throw Context.auth.error()
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
return new Response(
|
|
35
|
-
OrganizationBillingStatus.create({})
|
|
36
|
-
)
|
|
34
|
+
|
|
35
|
+
return new Response(await GetUserBilingStatusEndpoint.getBillingStatusForObjects([organization.id], null));
|
|
37
36
|
}
|
|
38
37
|
}
|
package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedBillingStatusEndpoint.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
+
import { OrganizationDetailedBillingStatus, PaymentStatus } from "@stamhoofd/structures";
|
|
3
|
+
|
|
4
|
+
import { BalanceItem, Payment } from "@stamhoofd/models";
|
|
5
|
+
import { SQL } from "@stamhoofd/sql";
|
|
6
|
+
import { Context } from "../../../../helpers/Context";
|
|
7
|
+
import { GetUserDetailedBilingStatusEndpoint } from "../../../global/registration/GetUserDetailedBillingStatusEndpoint";
|
|
8
|
+
|
|
9
|
+
type Params = Record<string, never>;
|
|
10
|
+
type Query = undefined;
|
|
11
|
+
type ResponseBody = OrganizationDetailedBillingStatus;
|
|
12
|
+
type Body = undefined;
|
|
13
|
+
|
|
14
|
+
export class GetOrganizationDetailedBillingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
15
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
16
|
+
if (request.method != "GET") {
|
|
17
|
+
return [false];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (request.getVersion() <= 334) {
|
|
21
|
+
// Deprecated
|
|
22
|
+
const params = Endpoint.parseParameters(request.url, "/billing/status/detailed", {});
|
|
23
|
+
|
|
24
|
+
if (params) {
|
|
25
|
+
return [true, params as Params];
|
|
26
|
+
}
|
|
27
|
+
return [false];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const params = Endpoint.parseParameters(request.url, "/organization/billing/status/detailed", {});
|
|
31
|
+
|
|
32
|
+
if (params) {
|
|
33
|
+
return [true, params as Params];
|
|
34
|
+
}
|
|
35
|
+
return [false];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
39
|
+
const organization = await Context.setOrganizationScope();
|
|
40
|
+
await Context.authenticate()
|
|
41
|
+
|
|
42
|
+
// If the user has permission, we'll also search if he has access to the organization's key
|
|
43
|
+
if (!await Context.auth.canManageFinances(organization.id)) {
|
|
44
|
+
throw Context.auth.error()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const balanceItemModels = await BalanceItem.balanceItemsForOrganization(organization.id);
|
|
48
|
+
|
|
49
|
+
const paymentModels = await Payment.select()
|
|
50
|
+
.where('payingOrganizationId', organization.id)
|
|
51
|
+
.andWhere(
|
|
52
|
+
SQL.whereNot('status', PaymentStatus.Failed)
|
|
53
|
+
)
|
|
54
|
+
.fetch()
|
|
55
|
+
|
|
56
|
+
return new Response(await GetUserDetailedBilingStatusEndpoint.getDetailedBillingStatus(balanceItemModels, paymentModels));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
|
|
2
2
|
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
3
3
|
import { SimpleError } from "@simonbackx/simple-errors";
|
|
4
|
-
import { BalanceItem, Member, Order,
|
|
4
|
+
import { BalanceItem, Member, Order, User } from '@stamhoofd/models';
|
|
5
5
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
6
6
|
import { BalanceItemStatus, BalanceItemType, BalanceItemWithPayments, PermissionLevel } from "@stamhoofd/structures";
|
|
7
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
8
7
|
|
|
9
8
|
import { Context } from '../../../../helpers/Context';
|
|
10
9
|
|
|
@@ -43,6 +42,7 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
const returnedModels: BalanceItem[] = []
|
|
45
|
+
const updateOutstandingBalance: BalanceItem[] = []
|
|
46
46
|
|
|
47
47
|
// Keep track of updates
|
|
48
48
|
const memberIds: string[] = []
|
|
@@ -80,6 +80,8 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
80
80
|
|
|
81
81
|
await model.save();
|
|
82
82
|
returnedModels.push(model);
|
|
83
|
+
|
|
84
|
+
updateOutstandingBalance.push(model)
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
for (const patch of request.body.getPatches()) {
|
|
@@ -145,11 +147,14 @@ export class PatchBalanceItemsEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
145
147
|
|
|
146
148
|
await model.save();
|
|
147
149
|
returnedModels.push(model);
|
|
150
|
+
|
|
151
|
+
if (patch.unitPrice || patch.amount || patch.status) {
|
|
152
|
+
updateOutstandingBalance.push(model)
|
|
153
|
+
}
|
|
148
154
|
}
|
|
149
155
|
});
|
|
150
156
|
|
|
151
|
-
await
|
|
152
|
-
await Registration.updateOutstandingBalance(Formatter.uniqueArray(registrationIds), organization.id)
|
|
157
|
+
await BalanceItem.updateOutstanding(updateOutstandingBalance)
|
|
153
158
|
|
|
154
159
|
return new Response(
|
|
155
160
|
await BalanceItem.getStructureWithPayments(returnedModels)
|
|
@@ -153,6 +153,8 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
153
153
|
for (const balanceItem of balanceItems) {
|
|
154
154
|
await balanceItem.markUpdated(payment, organization)
|
|
155
155
|
}
|
|
156
|
+
|
|
157
|
+
await BalanceItem.updateOutstanding(balanceItems)
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
changedPayments.push(payment)
|
|
@@ -72,8 +72,8 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
72
72
|
static async handlePaymentStatusUpdate(payment: Payment, organization: Organization, status: PaymentStatus) {
|
|
73
73
|
if (payment.status === status) {
|
|
74
74
|
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
75
|
+
}
|
|
76
|
+
|
|
77
77
|
if (status === PaymentStatus.Succeeded) {
|
|
78
78
|
payment.status = PaymentStatus.Succeeded
|
|
79
79
|
payment.paidAt = new Date()
|
|
@@ -90,39 +90,20 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
90
90
|
await balanceItemPayment.markPaid(organization);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem)
|
|
93
|
+
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem))
|
|
94
94
|
})
|
|
95
|
-
|
|
96
|
-
//if (!wasPaid && payment.provider === PaymentProvider.Buckaroo && payment.method) {
|
|
97
|
-
// // Charge transaction fees
|
|
98
|
-
// let fee = 0
|
|
99
|
-
//
|
|
100
|
-
// if (payment.method === PaymentMethod.iDEAL) {
|
|
101
|
-
// fee = calculateFee(payment.price, 21, 20); // € 0,21 + 0,2%
|
|
102
|
-
// } else if (payment.method === PaymentMethod.Bancontact || payment.method === PaymentMethod.Payconiq) {
|
|
103
|
-
// fee = calculateFee(payment.price, 24, 20); // € 0,24 + 0,2%
|
|
104
|
-
// } else {
|
|
105
|
-
// fee = calculateFee(payment.price, 25, 150); // € 0,25 + 1,5%
|
|
106
|
-
// }
|
|
107
|
-
//
|
|
108
|
-
// const name = "Transactiekosten voor "+PaymentMethodHelper.getName(payment.method)
|
|
109
|
-
// const item = STInvoiceItem.create({
|
|
110
|
-
// name,
|
|
111
|
-
// description: "Via Buckaroo",
|
|
112
|
-
// amount: 1,
|
|
113
|
-
// unitPrice: fee,
|
|
114
|
-
// canUseCredits: false
|
|
115
|
-
// })
|
|
116
|
-
// console.log("Scheduling transaction fee charge for ", payment.id, item)
|
|
117
|
-
// await QueueHandler.schedule("billing/invoices-"+organization.id, async () => {
|
|
118
|
-
// await STPendingInvoice.addItems(organization, [item])
|
|
119
|
-
// });
|
|
120
|
-
//}
|
|
121
95
|
return;
|
|
122
96
|
}
|
|
123
97
|
|
|
98
|
+
const oldStatus = payment.status
|
|
99
|
+
|
|
100
|
+
// Save before updating balance items
|
|
101
|
+
payment.status = status
|
|
102
|
+
payment.paidAt = null
|
|
103
|
+
await payment.save();
|
|
104
|
+
|
|
124
105
|
// If OLD status was succeeded, we need to revert the actions
|
|
125
|
-
if (
|
|
106
|
+
if (oldStatus === PaymentStatus.Succeeded) {
|
|
126
107
|
// No longer succeeded
|
|
127
108
|
await QueueHandler.schedule("balance-item-update/"+organization.id, async () => {
|
|
128
109
|
const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
|
|
@@ -133,10 +114,11 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
133
114
|
await balanceItemPayment.undoPaid(organization);
|
|
134
115
|
}
|
|
135
116
|
|
|
136
|
-
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem)
|
|
117
|
+
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem))
|
|
137
118
|
})
|
|
138
119
|
}
|
|
139
120
|
|
|
121
|
+
// Moved to failed
|
|
140
122
|
if (status == PaymentStatus.Failed) {
|
|
141
123
|
await QueueHandler.schedule("balance-item-update/"+organization.id, async () => {
|
|
142
124
|
const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
|
|
@@ -147,12 +129,12 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
147
129
|
await balanceItemPayment.markFailed(organization);
|
|
148
130
|
}
|
|
149
131
|
|
|
150
|
-
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem)
|
|
132
|
+
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem))
|
|
151
133
|
})
|
|
152
134
|
}
|
|
153
135
|
|
|
154
136
|
// If OLD status was FAILED, we need to revert the actions
|
|
155
|
-
if (
|
|
137
|
+
if (oldStatus === PaymentStatus.Failed) { // OLD FAILED!! -> NOW PENDING
|
|
156
138
|
await QueueHandler.schedule("balance-item-update/"+organization.id, async () => {
|
|
157
139
|
const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
|
|
158
140
|
(await BalanceItemPayment.where({paymentId: payment.id})).map(r => r.setRelation(BalanceItemPayment.payment, payment))
|
|
@@ -161,12 +143,10 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
161
143
|
for (const balanceItemPayment of balanceItemPayments) {
|
|
162
144
|
await balanceItemPayment.undoFailed(organization);
|
|
163
145
|
}
|
|
146
|
+
|
|
147
|
+
await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem))
|
|
164
148
|
})
|
|
165
149
|
}
|
|
166
|
-
|
|
167
|
-
payment.status = status
|
|
168
|
-
payment.paidAt = null
|
|
169
|
-
await payment.save();
|
|
170
150
|
}
|
|
171
151
|
|
|
172
152
|
/**
|
|
@@ -992,16 +992,21 @@ export class AdminPermissionChecker {
|
|
|
992
992
|
})
|
|
993
993
|
}
|
|
994
994
|
|
|
995
|
-
if (data.details.securityCode !== undefined) {
|
|
996
|
-
// Unset silently
|
|
997
|
-
data.details.securityCode = undefined
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
995
|
const hasRecordAnswers = !!data.details.recordAnswers;
|
|
1001
996
|
const hasNotes = data.details.notes !== undefined;
|
|
1002
997
|
const isSetFinancialSupportTrue = data.details.shouldApplyReducedPrice;
|
|
1003
998
|
const isUserManager = this.isUserManager(member);
|
|
1004
999
|
|
|
1000
|
+
if (data.details.securityCode !== undefined) {
|
|
1001
|
+
const hasFullAccess = await this.canAccessMember(member, PermissionLevel.Full);
|
|
1002
|
+
|
|
1003
|
+
// can only be set to null, and only if can access member with full access
|
|
1004
|
+
if(!hasFullAccess || data.details.securityCode !== null) {
|
|
1005
|
+
// Unset silently
|
|
1006
|
+
data.details.securityCode = undefined
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1005
1010
|
if (hasRecordAnswers) {
|
|
1006
1011
|
if (!(data.details.recordAnswers instanceof PatchMap)) {
|
|
1007
1012
|
throw new SimpleError({
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { BalanceItem } from '@stamhoofd/models';
|
|
3
|
+
|
|
4
|
+
export default new Migration(async () => {
|
|
5
|
+
if (STAMHOOFD.environment == "test") {
|
|
6
|
+
console.log("skipped in tests")
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
await BalanceItem.updatePricePaid('all')
|
|
11
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { BalanceItem } from '@stamhoofd/models';
|
|
3
|
+
|
|
4
|
+
export default new Migration(async () => {
|
|
5
|
+
if (STAMHOOFD.environment == "test") {
|
|
6
|
+
console.log("skipped in tests")
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
await BalanceItem.updatePricePending('all')
|
|
11
|
+
})
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
-
import { BalanceItem, Member } from "@stamhoofd/models";
|
|
3
|
-
import { BalanceItemWithPayments } from "@stamhoofd/structures";
|
|
4
|
-
|
|
5
|
-
import { Context } from "../../../helpers/Context";
|
|
6
|
-
|
|
7
|
-
type Params = Record<string, never>;
|
|
8
|
-
type Query = undefined
|
|
9
|
-
type Body = undefined
|
|
10
|
-
type ResponseBody = BalanceItemWithPayments[]
|
|
11
|
-
|
|
12
|
-
export class GetUserBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
13
|
-
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
14
|
-
if (request.method != "GET") {
|
|
15
|
-
return [false];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const params = Endpoint.parseParameters(request.url, "/balance", {});
|
|
19
|
-
|
|
20
|
-
if (params) {
|
|
21
|
-
return [true, params as Params];
|
|
22
|
-
}
|
|
23
|
-
return [false];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
27
|
-
const organization = await Context.setUserOrganizationScope();
|
|
28
|
-
const {user} = await Context.authenticate()
|
|
29
|
-
|
|
30
|
-
const members = await Member.getMembersWithRegistrationForUser(user)
|
|
31
|
-
|
|
32
|
-
// Get all balance items for this member or users
|
|
33
|
-
const balanceItems = await BalanceItem.balanceItemsForUsersAndMembers(organization?.id ?? null, [user.id], members.map(m => m.id))
|
|
34
|
-
|
|
35
|
-
return new Response(
|
|
36
|
-
await BalanceItem.getStructureWithPayments(balanceItems)
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
|
|
2
|
-
import { OrganizationDetailedBillingStatus, OrganizationDetailedBillingStatusItem, PaymentMethod, PaymentStatus } from "@stamhoofd/structures";
|
|
3
|
-
|
|
4
|
-
import { BalanceItem, Organization, Payment } from "@stamhoofd/models";
|
|
5
|
-
import { SQL } from "@stamhoofd/sql";
|
|
6
|
-
import { Formatter } from "@stamhoofd/utility";
|
|
7
|
-
import { AuthenticatedStructures } from "../../../../helpers/AuthenticatedStructures";
|
|
8
|
-
import { Context } from "../../../../helpers/Context";
|
|
9
|
-
|
|
10
|
-
type Params = Record<string, never>;
|
|
11
|
-
type Query = undefined;
|
|
12
|
-
type ResponseBody = OrganizationDetailedBillingStatus;
|
|
13
|
-
type Body = undefined;
|
|
14
|
-
|
|
15
|
-
export class GetDetailedBillingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
16
|
-
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
17
|
-
if (request.method != "GET") {
|
|
18
|
-
return [false];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const params = Endpoint.parseParameters(request.url, "/billing/status/detailed", {});
|
|
22
|
-
|
|
23
|
-
if (params) {
|
|
24
|
-
return [true, params as Params];
|
|
25
|
-
}
|
|
26
|
-
return [false];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
30
|
-
const organization = await Context.setOrganizationScope();
|
|
31
|
-
await Context.authenticate()
|
|
32
|
-
|
|
33
|
-
// If the user has permission, we'll also search if he has access to the organization's key
|
|
34
|
-
if (!await Context.auth.canManageFinances(organization.id)) {
|
|
35
|
-
throw Context.auth.error()
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const balanceItemModels = await BalanceItem.balanceItemsForOrganization(organization.id);
|
|
39
|
-
|
|
40
|
-
// Hide pending online payments
|
|
41
|
-
const paymentModels = await Payment.select()
|
|
42
|
-
.where('payingOrganizationId', organization.id)
|
|
43
|
-
.andWhere(
|
|
44
|
-
SQL.whereNot('status', PaymentStatus.Failed)
|
|
45
|
-
)
|
|
46
|
-
.fetch()
|
|
47
|
-
|
|
48
|
-
const organizationIds = Formatter.uniqueArray([
|
|
49
|
-
...balanceItemModels.map(b => b.organizationId),
|
|
50
|
-
...paymentModels.map(p => p.organizationId).filter(p => p !== null)
|
|
51
|
-
])
|
|
52
|
-
|
|
53
|
-
// Group by organization you'll have to pay to
|
|
54
|
-
if (organizationIds.length === 0) {
|
|
55
|
-
return new Response(
|
|
56
|
-
OrganizationDetailedBillingStatus.create({})
|
|
57
|
-
)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels)
|
|
61
|
-
const organizationModels = await Organization.getByIDs(...organizationIds)
|
|
62
|
-
const organizations = await AuthenticatedStructures.organizations(organizationModels)
|
|
63
|
-
const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false)
|
|
64
|
-
|
|
65
|
-
return new Response(
|
|
66
|
-
OrganizationDetailedBillingStatus.create({
|
|
67
|
-
organizations: organizations.map(o => {
|
|
68
|
-
return OrganizationDetailedBillingStatusItem.create({
|
|
69
|
-
organization: o,
|
|
70
|
-
balanceItems: balanceItems.filter(b => b.organizationId == o.id),
|
|
71
|
-
payments: payments.filter(p => p.organizationId === o.id)
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
}
|