@stamhoofd/backend 2.43.2 → 2.44.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 (29) hide show
  1. package/package.json +10 -10
  2. package/src/crons/{clear-excel-cache.test.ts → clearExcelCache.test.ts} +1 -1
  3. package/src/crons/endFunctionsOfUsersWithoutRegistration.ts +18 -0
  4. package/src/crons.ts +8 -2
  5. package/src/endpoints/admin/organizations/ChargeOrganizationsEndpoint.ts +116 -0
  6. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +24 -20
  7. package/src/endpoints/global/events/PatchEventsEndpoint.ts +10 -0
  8. package/src/endpoints/global/registration/{GetUserDetailedBillingStatusEndpoint.ts → GetUserDetailedPayableBalanceEndpoint.ts} +10 -8
  9. package/src/endpoints/global/registration/{GetUserBillingStatusEndpoint.ts → GetUserPayableBalanceEndpoint.ts} +13 -11
  10. package/src/endpoints/organization/dashboard/billing/{GetOrganizationDetailedBillingStatusEndpoint.ts → GetOrganizationDetailedPayableBalanceEndpoint.ts} +12 -16
  11. package/src/endpoints/organization/dashboard/billing/{GetOrganizationBillingStatusEndpoint.ts → GetOrganizationPayableBalanceEndpoint.ts} +8 -6
  12. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +1 -0
  13. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +98 -0
  14. package/src/endpoints/organization/dashboard/{cached-outstanding-balance/GetCachedOutstandingBalanceCountEndpoint.ts → receivable-balances/GetReceivableBalancesCountEndpoint.ts} +4 -4
  15. package/src/endpoints/organization/dashboard/{cached-outstanding-balance/GetCachedOutstandingBalanceEndpoint.ts → receivable-balances/GetReceivableBalancesEndpoint.ts} +18 -14
  16. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersCountEndpoint.ts +57 -0
  17. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +123 -13
  18. package/src/helpers/AdminPermissionChecker.ts +11 -2
  19. package/src/helpers/AuthenticatedStructures.ts +79 -18
  20. package/src/helpers/FlagMomentCleanup.ts +68 -0
  21. package/src/helpers/LimitedFilteredRequestHelper.ts +24 -0
  22. package/src/helpers/OrganizationCharger.ts +46 -0
  23. package/src/helpers/StripeHelper.ts +35 -10
  24. package/src/sql-filters/orders.ts +26 -0
  25. package/src/sql-filters/{cached-outstanding-balance.ts → receivable-balances.ts} +1 -1
  26. package/src/sql-sorters/orders.ts +47 -0
  27. package/src/sql-sorters/{cached-outstanding-balance.ts → receivable-balances.ts} +2 -2
  28. /package/src/crons/{clear-excel-cache.ts → clearExcelCache.ts} +0 -0
  29. /package/src/crons/{setup-steps.ts → updateSetupSteps.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.43.2",
3
+ "version": "2.44.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -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.43.2",
40
- "@stamhoofd/backend-middleware": "2.43.2",
41
- "@stamhoofd/email": "2.43.2",
42
- "@stamhoofd/models": "2.43.2",
43
- "@stamhoofd/queues": "2.43.2",
44
- "@stamhoofd/sql": "2.43.2",
45
- "@stamhoofd/structures": "2.43.2",
46
- "@stamhoofd/utility": "2.43.2",
39
+ "@stamhoofd/backend-i18n": "2.44.0",
40
+ "@stamhoofd/backend-middleware": "2.44.0",
41
+ "@stamhoofd/email": "2.44.0",
42
+ "@stamhoofd/models": "2.44.0",
43
+ "@stamhoofd/queues": "2.44.0",
44
+ "@stamhoofd/sql": "2.44.0",
45
+ "@stamhoofd/structures": "2.44.0",
46
+ "@stamhoofd/utility": "2.44.0",
47
47
  "archiver": "^7.0.1",
48
48
  "aws-sdk": "^2.885.0",
49
49
  "axios": "1.6.8",
@@ -60,5 +60,5 @@
60
60
  "postmark": "^4.0.5",
61
61
  "stripe": "^16.6.0"
62
62
  },
63
- "gitHead": "06abe49aaa20e7baa4ee44b48f4fae33ae30ae3e"
63
+ "gitHead": "1168aea7895e1aba48cd3123b09fc0b09a2b336f"
64
64
  }
@@ -1,6 +1,6 @@
1
1
  import { Dirent } from 'fs';
2
2
  import fs from 'fs/promises';
3
- import { clearExcelCacheHelper } from './clear-excel-cache';
3
+ import { clearExcelCacheHelper } from './clearExcelCache';
4
4
 
5
5
  const testPath = '/Users/user/project/backend/app/api/.cache';
6
6
  jest.mock('fs/promises');
@@ -0,0 +1,18 @@
1
+ import { FlagMomentCleanup } from '../helpers/FlagMomentCleanup';
2
+
3
+ let lastCleanupYear: number = -1;
4
+ let lastCleanupMonth: number = -1;
5
+
6
+ export async function endFunctionsOfUsersWithoutRegistration() {
7
+ const now = new Date();
8
+ const currentYear = now.getFullYear();
9
+ const currentMonth = now.getMonth();
10
+
11
+ if (lastCleanupMonth === currentMonth && currentYear === lastCleanupYear) {
12
+ return;
13
+ }
14
+
15
+ await FlagMomentCleanup.endFunctionsOfUsersWithoutRegistration();
16
+ lastCleanupYear = currentYear;
17
+ lastCleanupMonth = currentMonth;
18
+ }
package/src/crons.ts CHANGED
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-redundant-type-constituents */
2
1
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
3
2
  import { Database } from '@simonbackx/simple-database';
4
3
  import { logger, StyledText } from '@simonbackx/simple-logging';
@@ -9,7 +8,8 @@ import { Formatter, sleep } from '@stamhoofd/utility';
9
8
  import AWS from 'aws-sdk';
10
9
  import { DateTime } from 'luxon';
11
10
 
12
- import { clearExcelCache } from './crons/clear-excel-cache';
11
+ import { clearExcelCache } from './crons/clearExcelCache';
12
+ import { endFunctionsOfUsersWithoutRegistration } from './crons/endFunctionsOfUsersWithoutRegistration';
13
13
  import { ExchangePaymentEndpoint } from './endpoints/organization/shared/ExchangePaymentEndpoint';
14
14
  import { checkSettlements } from './helpers/CheckSettlements';
15
15
  import { ForwardHandler } from './helpers/ForwardHandler';
@@ -740,6 +740,12 @@ registeredCronJobs.push({
740
740
  running: false,
741
741
  });
742
742
 
743
+ registeredCronJobs.push({
744
+ name: 'endFunctionsOfUsersWithoutRegistration',
745
+ method: endFunctionsOfUsersWithoutRegistration,
746
+ running: false,
747
+ });
748
+
743
749
  async function run(name: string, handler: () => Promise<void>) {
744
750
  try {
745
751
  await logger.setContext({
@@ -0,0 +1,116 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { ChargeOrganizationsRequest, LimitedFilteredRequest, Organization as OrganizationStruct } from '@stamhoofd/structures';
5
+
6
+ import { QueueHandler } from '@stamhoofd/queues';
7
+ import { Context } from '../../../helpers/Context';
8
+ import { fetchToAsyncIterator } from '../../../helpers/fetchToAsyncIterator';
9
+ import { OrganizationCharger } from '../../../helpers/OrganizationCharger';
10
+ import { GetOrganizationsEndpoint } from './GetOrganizationsEndpoint';
11
+
12
+ type Params = Record<string, never>;
13
+ type Query = LimitedFilteredRequest;
14
+ type Body = ChargeOrganizationsRequest;
15
+ type ResponseBody = undefined;
16
+
17
+ export class ChargeOrganizationsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
18
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
19
+ bodyDecoder = ChargeOrganizationsRequest as Decoder<ChargeOrganizationsRequest>;
20
+
21
+ protected doesMatch(request: Request): [true, Params] | [false] {
22
+ if (request.method !== 'POST') {
23
+ return [false];
24
+ }
25
+
26
+ const params = Endpoint.parseParameters(request.url, '/admin/charge-organizations', {});
27
+
28
+ if (params) {
29
+ return [true, params as Params];
30
+ }
31
+ return [false];
32
+ }
33
+
34
+ private static throwIfInvalidBody(body: Body) {
35
+ if (!body.description?.trim()?.length) {
36
+ throw new SimpleError({
37
+ code: 'invalid_field',
38
+ message: 'Invalid description',
39
+ human: 'Beschrijving is verplicht',
40
+ field: 'description',
41
+ });
42
+ }
43
+
44
+ if (!body.price) {
45
+ throw new SimpleError({
46
+ code: 'invalid_field',
47
+ message: 'Invalid price',
48
+ human: 'Bedrag kan niet 0 zijn',
49
+ field: 'price',
50
+ });
51
+ }
52
+
53
+ if (body.amount === 0) {
54
+ throw new SimpleError({
55
+ code: 'invalid_field',
56
+ message: 'Invalid amount',
57
+ human: 'Aantal kan niet 0 zijn',
58
+ field: 'amount',
59
+ });
60
+ }
61
+
62
+ if (body.organizationId === undefined) {
63
+ throw new SimpleError({
64
+ code: 'invalid_field',
65
+ message: 'Invalid organization id',
66
+ human: 'Organisatie is verplicht',
67
+ field: 'organizationId',
68
+ });
69
+ }
70
+ }
71
+
72
+ async handle(request: DecodedRequest<Params, Query, Body>) {
73
+ await Context.authenticate();
74
+
75
+ if (!Context.auth.hasPlatformFullAccess()) {
76
+ throw Context.auth.error();
77
+ }
78
+
79
+ const body = request.body;
80
+ ChargeOrganizationsEndpoint.throwIfInvalidBody(body);
81
+
82
+ const queueId = 'charge-organizations';
83
+
84
+ if (QueueHandler.isRunning(queueId)) {
85
+ throw new SimpleError({
86
+ code: 'charge_pending',
87
+ message: 'Charge organizations already pending',
88
+ human: 'Er is al een aanrekening bezig, even geduld.',
89
+ });
90
+ }
91
+
92
+ await QueueHandler.schedule(queueId, async () => {
93
+ const dataGenerator = fetchToAsyncIterator(request.query, {
94
+ fetch: GetOrganizationsEndpoint.buildData,
95
+ });
96
+
97
+ const organizationId = body.organizationId;
98
+ const chargeOrganizations = organizationId === null
99
+ ? OrganizationCharger.chargeFromPlatform
100
+ : (args: { organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string }) => OrganizationCharger.chargeMany({
101
+ chargingOrganizationId: organizationId,
102
+ ...args });
103
+
104
+ for await (const data of dataGenerator) {
105
+ await chargeOrganizations({
106
+ organizationsToCharge: data,
107
+ price: body.price,
108
+ amount: body.amount ?? 1,
109
+ description: body.description,
110
+ });
111
+ }
112
+ });
113
+
114
+ return new Response(undefined);
115
+ }
116
+ }
@@ -95,12 +95,10 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
95
95
  return query;
96
96
  }
97
97
 
98
- async handle(request: DecodedRequest<Params, Query, Body>) {
99
- await Context.authenticate();
100
-
98
+ static async buildData(requestQuery: LimitedFilteredRequest): Promise<PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>> {
101
99
  const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
102
100
 
103
- if (request.query.limit > maxLimit) {
101
+ if (requestQuery.limit > maxLimit) {
104
102
  throw new SimpleError({
105
103
  code: 'invalid_field',
106
104
  field: 'limit',
@@ -108,7 +106,7 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
108
106
  });
109
107
  }
110
108
 
111
- if (request.query.limit < 1) {
109
+ if (requestQuery.limit < 1) {
112
110
  throw new SimpleError({
113
111
  code: 'invalid_field',
114
112
  field: 'limit',
@@ -116,34 +114,40 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
116
114
  });
117
115
  }
118
116
 
119
- const data = await GetOrganizationsEndpoint.buildQuery(request.query).fetch();
117
+ const data = await GetOrganizationsEndpoint.buildQuery(requestQuery).fetch();
120
118
  const organizations = Organization.fromRows(data, 'organizations');
121
119
 
122
120
  let next: LimitedFilteredRequest | undefined;
123
121
 
124
- if (organizations.length >= request.query.limit) {
122
+ if (organizations.length >= requestQuery.limit) {
125
123
  const lastObject = organizations[organizations.length - 1];
126
- const nextFilter = getSortFilter(lastObject, sorters, request.query.sort);
124
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
127
125
 
128
126
  next = new LimitedFilteredRequest({
129
- filter: request.query.filter,
127
+ filter: requestQuery.filter,
130
128
  pageFilter: nextFilter,
131
- sort: request.query.sort,
132
- limit: request.query.limit,
133
- search: request.query.search,
129
+ sort: requestQuery.sort,
130
+ limit: requestQuery.limit,
131
+ search: requestQuery.search,
134
132
  });
135
133
 
136
- if (JSON.stringify(nextFilter) === JSON.stringify(request.query.pageFilter)) {
137
- console.error('Found infinite loading loop for', request.query);
134
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
135
+ console.error('Found infinite loading loop for', requestQuery);
138
136
  next = undefined;
139
137
  }
140
138
  }
141
139
 
142
- return new Response(
143
- new PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>({
144
- results: await AuthenticatedStructures.organizations(organizations),
145
- next,
146
- }),
147
- );
140
+ return new PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>({
141
+ results: await AuthenticatedStructures.organizations(organizations),
142
+ next,
143
+ });
144
+ }
145
+
146
+ async handle(request: DecodedRequest<Params, Query, Body>) {
147
+ await Context.authenticate();
148
+
149
+ const paginatedResponse = await GetOrganizationsEndpoint.buildData(request.query);
150
+
151
+ return new Response(paginatedResponse);
148
152
  }
149
153
  }
@@ -53,6 +53,11 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
53
53
  event.organizationId = put.organizationId;
54
54
  event.meta = put.meta;
55
55
 
56
+ if (event.organizationId === null && event.meta.groups !== null) {
57
+ event.meta.groups = null;
58
+ console.error('Removed groups because organizationId is null for new event');
59
+ }
60
+
56
61
  if (event.meta.groups && event.meta.groups.length === 0) {
57
62
  throw new SimpleError({
58
63
  code: 'invalid_field',
@@ -131,6 +136,11 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
131
136
  event.organizationId = patch.organizationId;
132
137
  }
133
138
 
139
+ if (event.organizationId === null && event.meta.groups !== null) {
140
+ event.meta.groups = null;
141
+ console.error('Removed groups because organizationId is null for event', event.id);
142
+ }
143
+
134
144
  if (event.meta.groups && event.meta.groups.length === 0) {
135
145
  throw new SimpleError({
136
146
  code: 'invalid_field',
@@ -1,6 +1,6 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
2
  import { BalanceItem, Member, Organization, Payment } from '@stamhoofd/models';
3
- import { OrganizationDetailedBillingStatus, OrganizationDetailedBillingStatusItem } from '@stamhoofd/structures';
3
+ import { DetailedPayableBalanceCollection, DetailedPayableBalance } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -9,15 +9,17 @@ import { Context } from '../../../helpers/Context';
9
9
  type Params = Record<string, never>;
10
10
  type Query = undefined;
11
11
  type Body = undefined;
12
- type ResponseBody = OrganizationDetailedBillingStatus;
12
+ type ResponseBody = DetailedPayableBalanceCollection;
13
13
 
14
- export class GetUserDetailedBilingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ export class GetUserDetailedPayableBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
15
15
  protected doesMatch(request: Request): [true, Params] | [false] {
16
16
  if (request.method !== 'GET') {
17
17
  return [false];
18
18
  }
19
19
 
20
- const params = Endpoint.parseParameters(request.url, '/user/billing/status/detailed', {});
20
+ const params = request.getVersion() >= 339
21
+ ? Endpoint.parseParameters(request.url, '/user/payable-balance/detailed', {})
22
+ : Endpoint.parseParameters(request.url, '/user/billing/status/detailed', {});
21
23
 
22
24
  if (params) {
23
25
  return [true, params as Params];
@@ -36,7 +38,7 @@ export class GetUserDetailedBilingStatusEndpoint extends Endpoint<Params, Query,
36
38
  // todo: this is a duplicate query
37
39
  const { payments, balanceItemPayments } = await BalanceItem.loadPayments(balanceItemModels);
38
40
 
39
- return new Response(await GetUserDetailedBilingStatusEndpoint.getDetailedBillingStatus(balanceItemModels, payments));
41
+ return new Response(await GetUserDetailedPayableBalanceEndpoint.getDetailedBillingStatus(balanceItemModels, payments));
40
42
  }
41
43
 
42
44
  static async getDetailedBillingStatus(balanceItemModels: BalanceItem[], paymentModels: Payment[]) {
@@ -47,7 +49,7 @@ export class GetUserDetailedBilingStatusEndpoint extends Endpoint<Params, Query,
47
49
 
48
50
  // Group by organization you'll have to pay to
49
51
  if (organizationIds.length === 0) {
50
- return OrganizationDetailedBillingStatus.create({});
52
+ return DetailedPayableBalanceCollection.create({});
51
53
  }
52
54
 
53
55
  // Optimization: prevent fetching the organization we already have
@@ -70,9 +72,9 @@ export class GetUserDetailedBilingStatusEndpoint extends Endpoint<Params, Query,
70
72
  const organizations = await AuthenticatedStructures.organizations(organizationModels);
71
73
  const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false);
72
74
 
73
- return OrganizationDetailedBillingStatus.create({
75
+ return DetailedPayableBalanceCollection.create({
74
76
  organizations: organizations.map((o) => {
75
- return OrganizationDetailedBillingStatusItem.create({
77
+ return DetailedPayableBalance.create({
76
78
  organization: o,
77
79
  balanceItems: balanceItems.filter(b => b.organizationId == o.id),
78
80
  payments: payments.filter(p => p.organizationId === o.id),
@@ -1,6 +1,6 @@
1
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';
2
+ import { CachedBalance, Member, Organization } from '@stamhoofd/models';
3
+ import { PayableBalanceCollection, PayableBalance } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -9,15 +9,17 @@ import { Context } from '../../../helpers/Context';
9
9
  type Params = Record<string, never>;
10
10
  type Query = undefined;
11
11
  type Body = undefined;
12
- type ResponseBody = OrganizationBillingStatus;
12
+ type ResponseBody = PayableBalanceCollection;
13
13
 
14
- export class GetUserBilingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ export class GetUserPayableBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
15
15
  protected doesMatch(request: Request): [true, Params] | [false] {
16
16
  if (request.method !== 'GET') {
17
17
  return [false];
18
18
  }
19
19
 
20
- const params = Endpoint.parseParameters(request.url, '/user/billing/status', {});
20
+ const params = request.getVersion() >= 339
21
+ ? Endpoint.parseParameters(request.url, '/user/payable-balance', {})
22
+ : Endpoint.parseParameters(request.url, '/user/billing/status', {});
21
23
 
22
24
  if (params) {
23
25
  return [true, params as Params];
@@ -31,14 +33,14 @@ export class GetUserBilingStatusEndpoint extends Endpoint<Params, Query, Body, R
31
33
 
32
34
  const memberIds = await Member.getMemberIdsWithRegistrationForUser(user);
33
35
 
34
- return new Response(await GetUserBilingStatusEndpoint.getBillingStatusForObjects([user.id, ...memberIds], organization));
36
+ return new Response(await GetUserPayableBalanceEndpoint.getBillingStatusForObjects([user.id, ...memberIds], organization));
35
37
  }
36
38
 
37
39
  static async getBillingStatusForObjects(objectIds: string[], organization?: Organization | null) {
38
40
  // Load cached balances
39
- const cachedOutstandingBalances = await CachedOutstandingBalance.getForObjects(objectIds, organization?.id);
41
+ const receivableBalances = await CachedBalance.getForObjects(objectIds, organization?.id);
40
42
 
41
- const organizationIds = Formatter.uniqueArray(cachedOutstandingBalances.map(b => b.organizationId));
43
+ const organizationIds = Formatter.uniqueArray(receivableBalances.map(b => b.organizationId));
42
44
 
43
45
  let addOrganization = false;
44
46
  const i = organization ? organizationIds.indexOf(organization.id) : -1;
@@ -55,10 +57,10 @@ export class GetUserBilingStatusEndpoint extends Endpoint<Params, Query, Body, R
55
57
 
56
58
  const authenticatedOrganizations = await AuthenticatedStructures.organizations(organizations);
57
59
 
58
- const billingStatus = OrganizationBillingStatus.create({});
60
+ const billingStatus = PayableBalanceCollection.create({});
59
61
 
60
62
  for (const organization of authenticatedOrganizations) {
61
- const items = cachedOutstandingBalances.filter(b => b.organizationId === organization.id);
63
+ const items = receivableBalances.filter(b => b.organizationId === organization.id);
62
64
 
63
65
  let amount = 0;
64
66
  let amountPending = 0;
@@ -68,7 +70,7 @@ export class GetUserBilingStatusEndpoint extends Endpoint<Params, Query, Body, R
68
70
  amountPending += item.amountPending;
69
71
  }
70
72
 
71
- billingStatus.organizations.push(OrganizationBillingStatusItem.create({
73
+ billingStatus.organizations.push(PayableBalance.create({
72
74
  organization,
73
75
  amount,
74
76
  amountPending,
@@ -1,33 +1,29 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { OrganizationDetailedBillingStatus, PaymentStatus } from '@stamhoofd/structures';
2
+ import { DetailedPayableBalanceCollection, PaymentStatus } from '@stamhoofd/structures';
3
3
 
4
4
  import { BalanceItem, Payment } from '@stamhoofd/models';
5
5
  import { SQL } from '@stamhoofd/sql';
6
6
  import { Context } from '../../../../helpers/Context';
7
- import { GetUserDetailedBilingStatusEndpoint } from '../../../global/registration/GetUserDetailedBillingStatusEndpoint';
7
+ import { GetUserDetailedPayableBalanceEndpoint } from '../../../global/registration/GetUserDetailedPayableBalanceEndpoint';
8
8
 
9
9
  type Params = Record<string, never>;
10
10
  type Query = undefined;
11
- type ResponseBody = OrganizationDetailedBillingStatus;
11
+ type ResponseBody = DetailedPayableBalanceCollection;
12
12
  type Body = undefined;
13
13
 
14
- export class GetOrganizationDetailedBillingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ export class GetOrganizationDetailedPayableBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
15
15
  protected doesMatch(request: Request): [true, Params] | [false] {
16
16
  if (request.method !== 'GET') {
17
17
  return [false];
18
18
  }
19
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', {});
20
+ const params = request.getVersion() >= 339
21
+ ? Endpoint.parseParameters(request.url, '/organization/payable-balance/detailed', {})
22
+ : (
23
+ request.getVersion() <= 334
24
+ ? Endpoint.parseParameters(request.url, '/organization/billing/status', {})
25
+ : Endpoint.parseParameters(request.url, '/organization/billing/status/detailed', {})
26
+ );
31
27
 
32
28
  if (params) {
33
29
  return [true, params as Params];
@@ -53,6 +49,6 @@ export class GetOrganizationDetailedBillingStatusEndpoint extends Endpoint<Param
53
49
  )
54
50
  .fetch();
55
51
 
56
- return new Response(await GetUserDetailedBilingStatusEndpoint.getDetailedBillingStatus(balanceItemModels, paymentModels));
52
+ return new Response(await GetUserDetailedPayableBalanceEndpoint.getDetailedBillingStatus(balanceItemModels, paymentModels));
57
53
  }
58
54
  }
@@ -1,21 +1,23 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { OrganizationBillingStatus } from '@stamhoofd/structures';
2
+ import { PayableBalanceCollection } from '@stamhoofd/structures';
3
3
 
4
4
  import { Context } from '../../../../helpers/Context';
5
- import { GetUserBilingStatusEndpoint } from '../../../global/registration/GetUserBillingStatusEndpoint';
5
+ import { GetUserPayableBalanceEndpoint } from '../../../global/registration/GetUserPayableBalanceEndpoint';
6
6
 
7
7
  type Params = Record<string, never>;
8
8
  type Query = undefined;
9
- type ResponseBody = OrganizationBillingStatus;
9
+ type ResponseBody = PayableBalanceCollection;
10
10
  type Body = undefined;
11
11
 
12
- export class GetDetailedBillingStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
12
+ export class GetOrganizationPayableBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
13
13
  protected doesMatch(request: Request): [true, Params] | [false] {
14
14
  if (request.method !== 'GET') {
15
15
  return [false];
16
16
  }
17
17
 
18
- const params = Endpoint.parseParameters(request.url, '/organization/billing/status', {});
18
+ const params = request.getVersion() >= 339
19
+ ? Endpoint.parseParameters(request.url, '/organization/payable-balance', {})
20
+ : Endpoint.parseParameters(request.url, '/organization/billing/status', {});
19
21
 
20
22
  if (params) {
21
23
  return [true, params as Params];
@@ -32,6 +34,6 @@ export class GetDetailedBillingStatusEndpoint extends Endpoint<Params, Query, Bo
32
34
  throw Context.auth.error();
33
35
  }
34
36
 
35
- return new Response(await GetUserBilingStatusEndpoint.getBillingStatusForObjects([organization.id], null));
37
+ return new Response(await GetUserPayableBalanceEndpoint.getBillingStatusForObjects([organization.id], null));
36
38
  }
37
39
  }
@@ -9,6 +9,7 @@ type Query = undefined;
9
9
  type Body = undefined;
10
10
  type ResponseBody = BalanceItemWithPayments[];
11
11
 
12
+ // Rename to ReceiveableBalance
12
13
  export class GetMemberBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
13
14
  protected doesMatch(request: Request): [true, Params] | [false] {
14
15
  if (request.method !== 'GET') {
@@ -0,0 +1,98 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { DetailedReceivableBalance, PaymentStatus, ReceivableBalanceType } from '@stamhoofd/structures';
3
+
4
+ import { BalanceItem, BalanceItemPayment, CachedBalance, Payment } from '@stamhoofd/models';
5
+ import { Context } from '../../../../helpers/Context';
6
+ import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
7
+ import { SQL } from '@stamhoofd/sql';
8
+
9
+ type Params = { id: string; type: ReceivableBalanceType };
10
+ type Query = undefined;
11
+ type ResponseBody = DetailedReceivableBalance;
12
+ type Body = undefined;
13
+
14
+ export class GetReceivableBalanceEndpoint 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, '/receivable-balances/@type/@id', {
21
+ type: String,
22
+ id: String,
23
+ });
24
+
25
+ if (params && Object.values(ReceivableBalanceType).includes(params.type as unknown as ReceivableBalanceType)) {
26
+ return [true, params as Params];
27
+ }
28
+ return [false];
29
+ }
30
+
31
+ async handle(request: DecodedRequest<Params, Query, Body>) {
32
+ const organization = await Context.setOrganizationScope();
33
+ await Context.authenticate();
34
+
35
+ // If the user has permission, we'll also search if he has access to the organization's key
36
+ if (!await Context.auth.canManageFinances(organization.id)) {
37
+ throw Context.auth.error();
38
+ }
39
+
40
+ const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type);
41
+ let paymentModels: Payment[] = [];
42
+
43
+ switch (request.params.type) {
44
+ case ReceivableBalanceType.organization: {
45
+ paymentModels = await Payment.select()
46
+ .where('organizationId', organization.id)
47
+ .where('payingOrganizationId', request.params.id)
48
+ .andWhere(
49
+ SQL.whereNot('status', PaymentStatus.Failed),
50
+ )
51
+ .fetch();
52
+ break;
53
+ }
54
+
55
+ case ReceivableBalanceType.member: {
56
+ paymentModels = await Payment.select()
57
+ .where('organizationId', organization.id)
58
+ .join(
59
+ SQL.join(BalanceItemPayment.table)
60
+ .where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
61
+ )
62
+ .join(
63
+ SQL.join(BalanceItem.table)
64
+ .where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
65
+ )
66
+ .where(SQL.column(BalanceItem.table, 'memberId'), request.params.id)
67
+ .andWhere(
68
+ SQL.whereNot('status', PaymentStatus.Failed),
69
+ )
70
+ .groupBy(SQL.column(Payment.table, 'id'))
71
+ .fetch();
72
+ break;
73
+ }
74
+ }
75
+
76
+ const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels);
77
+ const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false);
78
+
79
+ const balances = await CachedBalance.getForObjects([request.params.id], organization.id);
80
+
81
+ const created = new CachedBalance();
82
+ created.amount = 0;
83
+ created.amountPending = 0;
84
+ created.organizationId = organization.id;
85
+ created.objectId = request.params.id;
86
+ created.objectType = request.params.type;
87
+
88
+ const base = await AuthenticatedStructures.receivableBalance(balances.length === 1 ? balances[0] : created);
89
+
90
+ return new Response(
91
+ DetailedReceivableBalance.create({
92
+ ...base,
93
+ balanceItems,
94
+ payments,
95
+ }),
96
+ );
97
+ }
98
+ }