@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.
Files changed (21) hide show
  1. package/package.json +12 -12
  2. package/src/crons/clear-excel-cache.test.ts +152 -0
  3. package/src/crons/clear-excel-cache.ts +92 -0
  4. package/src/crons.ts +11 -0
  5. package/src/endpoints/global/events/PatchEventsEndpoint.ts +18 -0
  6. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +4 -2
  7. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +9 -22
  8. package/src/endpoints/global/registration/GetUserBillingStatusEndpoint.ts +80 -0
  9. package/src/endpoints/global/registration/GetUserDetailedBillingStatusEndpoint.ts +83 -0
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +48 -39
  11. package/src/endpoints/organization/dashboard/billing/{GetBillingStatusEndpoint.ts → GetOrganizationBillingStatusEndpoint.ts} +5 -6
  12. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedBillingStatusEndpoint.ts +58 -0
  13. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +9 -4
  14. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -0
  15. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +0 -1
  16. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +17 -37
  17. package/src/helpers/AdminPermissionChecker.ts +10 -5
  18. package/src/seeds/1726055544-balance-item-paid.ts +11 -0
  19. package/src/seeds/1726055545-balance-item-pending.ts +11 -0
  20. package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +0 -39
  21. 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.34.1",
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": "^18.11.17",
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.34.1",
40
- "@stamhoofd/backend-middleware": "2.34.1",
41
- "@stamhoofd/email": "2.34.1",
42
- "@stamhoofd/models": "2.34.1",
43
- "@stamhoofd/queues": "2.34.1",
44
- "@stamhoofd/sql": "2.34.1",
45
- "@stamhoofd/structures": "2.34.1",
46
- "@stamhoofd/utility": "2.34.1",
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.2",
60
+ "postmark": "^4.0.5",
61
61
  "stripe": "^16.6.0"
62
62
  },
63
- "gitHead": "4faa18ab00da5608caedb6f7e83143f41a046d4f"
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
- // Update occupancy of this member because we removed registrations
509
- const groupIds = member.registrations.flatMap(r => r.groupId)
510
- for (const id of groupIds) {
511
- const group = await getGroup(id)
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
- await Member.updateOutstandingBalance(Formatter.uniqueArray(balanceItemMemberIds))
520
- for (const [organizationId, balanceItemRegistrationIds] of balanceItemRegistrationIdsPerOrganization) {
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, organization.id)
594
- const response = await this.createPayment({
595
- balanceItems: mappedBalanceItems,
596
- organization,
597
- user,
598
- checkout: request.body,
599
- members
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
- if (response) {
603
- paymentUrl = response.paymentUrl
604
- payment = response.payment
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, organization.id)
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.find(p => p.groupId === group.id) || checkout.cart.deleteRegistrations.find(p => p.groupId === group.id)) {
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 < 0 || (price > 0 && price > balanceItem.price - balanceItem.pricePaid)) {
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 GetBillingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
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
  }
@@ -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, Registration, User } from '@stamhoofd/models';
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 Member.updateOutstandingBalance(Formatter.uniqueArray(memberIds))
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)
@@ -455,7 +455,6 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
455
455
  }
456
456
  }
457
457
 
458
- await model.updateOccupancy()
459
458
  await model.save();
460
459
  return model;
461
460
  }
@@ -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
- // const wasPaid = payment.paidAt !== null
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), organization.id)
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 (payment.status === PaymentStatus.Succeeded) {
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), organization.id)
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), organization.id)
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 (payment.status === PaymentStatus.Failed) { // OLD FAILED!! -> NOW PENDING
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
- }