@stamhoofd/backend 2.43.3 → 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 (25) 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/registration/{GetUserDetailedBillingStatusEndpoint.ts → GetUserDetailedPayableBalanceEndpoint.ts} +10 -8
  8. package/src/endpoints/global/registration/{GetUserBillingStatusEndpoint.ts → GetUserPayableBalanceEndpoint.ts} +13 -11
  9. package/src/endpoints/organization/dashboard/billing/{GetOrganizationDetailedBillingStatusEndpoint.ts → GetOrganizationDetailedPayableBalanceEndpoint.ts} +12 -16
  10. package/src/endpoints/organization/dashboard/billing/{GetOrganizationBillingStatusEndpoint.ts → GetOrganizationPayableBalanceEndpoint.ts} +8 -6
  11. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +1 -0
  12. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +98 -0
  13. package/src/endpoints/organization/dashboard/{cached-outstanding-balance/GetCachedOutstandingBalanceCountEndpoint.ts → receivable-balances/GetReceivableBalancesCountEndpoint.ts} +4 -4
  14. package/src/endpoints/organization/dashboard/{cached-outstanding-balance/GetCachedOutstandingBalanceEndpoint.ts → receivable-balances/GetReceivableBalancesEndpoint.ts} +18 -14
  15. package/src/helpers/AdminPermissionChecker.ts +11 -2
  16. package/src/helpers/AuthenticatedStructures.ts +56 -18
  17. package/src/helpers/FlagMomentCleanup.ts +68 -0
  18. package/src/helpers/OrganizationCharger.ts +46 -0
  19. package/src/helpers/StripeHelper.ts +35 -10
  20. package/src/sql-filters/orders.ts +3 -4
  21. package/src/sql-filters/{cached-outstanding-balance.ts → receivable-balances.ts} +1 -1
  22. package/src/sql-sorters/orders.ts +3 -14
  23. package/src/sql-sorters/{cached-outstanding-balance.ts → receivable-balances.ts} +2 -2
  24. /package/src/crons/{clear-excel-cache.ts → clearExcelCache.ts} +0 -0
  25. /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.3",
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.3",
40
- "@stamhoofd/backend-middleware": "2.43.3",
41
- "@stamhoofd/email": "2.43.3",
42
- "@stamhoofd/models": "2.43.3",
43
- "@stamhoofd/queues": "2.43.3",
44
- "@stamhoofd/sql": "2.43.3",
45
- "@stamhoofd/structures": "2.43.3",
46
- "@stamhoofd/utility": "2.43.3",
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": "1258338deacc6d61a53da939dda894dca3415c84"
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
  }
@@ -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
+ }
@@ -3,14 +3,14 @@ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-
3
3
  import { CountFilteredRequest, CountResponse } from '@stamhoofd/structures';
4
4
 
5
5
  import { Context } from '../../../../helpers/Context';
6
- import { GetCachedOutstandingBalanceEndpoint } from './GetCachedOutstandingBalanceEndpoint';
6
+ import { GetReceivableBalancesEndpoint } from './GetReceivableBalancesEndpoint';
7
7
 
8
8
  type Params = Record<string, never>;
9
9
  type Query = CountFilteredRequest;
10
10
  type Body = undefined;
11
11
  type ResponseBody = CountResponse;
12
12
 
13
- export class GetCachedOutstandingBalanceCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
13
+ export class GetReceivableBalancesCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
14
  queryDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
15
15
 
16
16
  protected doesMatch(request: Request): [true, Params] | [false] {
@@ -18,7 +18,7 @@ export class GetCachedOutstandingBalanceCountEndpoint extends Endpoint<Params, Q
18
18
  return [false];
19
19
  }
20
20
 
21
- const params = Endpoint.parseParameters(request.url, '/cached-outstanding-balance/count', {});
21
+ const params = Endpoint.parseParameters(request.url, '/receivable-balances/count', {});
22
22
 
23
23
  if (params) {
24
24
  return [true, params as Params];
@@ -29,7 +29,7 @@ export class GetCachedOutstandingBalanceCountEndpoint extends Endpoint<Params, Q
29
29
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
30
  await Context.setOrganizationScope();
31
31
  await Context.authenticate();
32
- const query = await GetCachedOutstandingBalanceEndpoint.buildQuery(request.query);
32
+ const query = await GetReceivableBalancesEndpoint.buildQuery(request.query);
33
33
 
34
34
  const count = await query
35
35
  .count();
@@ -1,24 +1,24 @@
1
1
  import { Decoder } 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 { CachedOutstandingBalance } from '@stamhoofd/models';
4
+ import { CachedBalance } from '@stamhoofd/models';
5
5
  import { compileToSQLFilter, compileToSQLSorter } from '@stamhoofd/sql';
6
- import { CachedOutstandingBalance as CachedOutstandingBalanceStruct, CountFilteredRequest, LimitedFilteredRequest, PaginatedResponse, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
6
+ import { ReceivableBalance as ReceivableBalanceStruct, CountFilteredRequest, LimitedFilteredRequest, PaginatedResponse, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
7
7
 
8
8
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
9
9
  import { Context } from '../../../../helpers/Context';
10
- import { cachedOutstandingBalanceFilterCompilers } from '../../../../sql-filters/cached-outstanding-balance';
11
- import { cachedOutstandingBalanceSorters } from '../../../../sql-sorters/cached-outstanding-balance';
10
+ import { receivableBalanceFilterCompilers } from '../../../../sql-filters/receivable-balances';
11
+ import { receivableBalanceSorters } from '../../../../sql-sorters/receivable-balances';
12
12
 
13
13
  type Params = Record<string, never>;
14
14
  type Query = LimitedFilteredRequest;
15
15
  type Body = undefined;
16
- type ResponseBody = PaginatedResponse<CachedOutstandingBalanceStruct[], LimitedFilteredRequest>;
16
+ type ResponseBody = PaginatedResponse<ReceivableBalanceStruct[], LimitedFilteredRequest>;
17
17
 
18
- const sorters = cachedOutstandingBalanceSorters;
19
- const filterCompilers = cachedOutstandingBalanceFilterCompilers;
18
+ const sorters = receivableBalanceSorters;
19
+ const filterCompilers = receivableBalanceFilterCompilers;
20
20
 
21
- export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
21
+ export class GetReceivableBalancesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
22
22
  queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
23
23
 
24
24
  protected doesMatch(request: Request): [true, Params] | [false] {
@@ -26,7 +26,7 @@ export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query,
26
26
  return [false];
27
27
  }
28
28
 
29
- const params = Endpoint.parseParameters(request.url, '/cached-outstanding-balance', {});
29
+ const params = Endpoint.parseParameters(request.url, '/receivable-balances', {});
30
30
 
31
31
  if (params) {
32
32
  return [true, params as Params];
@@ -48,9 +48,13 @@ export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query,
48
48
 
49
49
  scopeFilter = {
50
50
  organizationId: organization.id,
51
+ $or: {
52
+ amount: { $neq: 0 },
53
+ amountPending: { $neq: 0 },
54
+ },
51
55
  };
52
56
 
53
- const query = CachedOutstandingBalance
57
+ const query = CachedBalance
54
58
  .select();
55
59
 
56
60
  if (scopeFilter) {
@@ -82,7 +86,7 @@ export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query,
82
86
  }
83
87
 
84
88
  static async buildData(requestQuery: LimitedFilteredRequest) {
85
- const query = await GetCachedOutstandingBalanceEndpoint.buildQuery(requestQuery);
89
+ const query = await GetReceivableBalancesEndpoint.buildQuery(requestQuery);
86
90
  const data = await query.fetch();
87
91
 
88
92
  // todo: Create objects from data
@@ -107,8 +111,8 @@ export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query,
107
111
  }
108
112
  }
109
113
 
110
- return new PaginatedResponse<CachedOutstandingBalanceStruct[], LimitedFilteredRequest>({
111
- results: await AuthenticatedStructures.cachedOutstandingBalances(data),
114
+ return new PaginatedResponse<ReceivableBalanceStruct[], LimitedFilteredRequest>({
115
+ results: await AuthenticatedStructures.receivableBalances(data),
112
116
  next,
113
117
  });
114
118
  }
@@ -136,7 +140,7 @@ export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query,
136
140
  }
137
141
 
138
142
  return new Response(
139
- await GetCachedOutstandingBalanceEndpoint.buildData(request.query),
143
+ await GetReceivableBalancesEndpoint.buildData(request.query),
140
144
  );
141
145
  }
142
146
  }
@@ -1,6 +1,6 @@
1
1
  import { AutoEncoderPatchType, PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { SimpleError } from '@simonbackx/simple-errors';
3
- import { BalanceItem, CachedOutstandingBalance, Document, DocumentTemplate, EmailTemplate, Event, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
3
+ import { BalanceItem, CachedBalance, Document, DocumentTemplate, EmailTemplate, Event, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
4
4
  import { AccessRight, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordCategory } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
@@ -259,7 +259,7 @@ export class AdminPermissionChecker {
259
259
  return true;
260
260
  }
261
261
 
262
- const cachedBalance = await CachedOutstandingBalance.getForObjects([member.id]);
262
+ const cachedBalance = await CachedBalance.getForObjects([member.id]);
263
263
  if (cachedBalance.length === 0 || (cachedBalance[0].amount === 0 && cachedBalance[0].amountPending === 0)) {
264
264
  return true;
265
265
  }
@@ -398,6 +398,15 @@ export class AdminPermissionChecker {
398
398
  orders: Order[];
399
399
  },
400
400
  ): Promise<boolean> {
401
+ // These balance items are out of scope - but we do have access to them
402
+ for (const balanceItem of balanceItems) {
403
+ if (balanceItem.payingOrganizationId && this.checkScope(balanceItem.payingOrganizationId)) {
404
+ if (await this.canManagePayments(balanceItem.payingOrganizationId)) {
405
+ return true;
406
+ }
407
+ }
408
+ }
409
+
401
410
  for (const balanceItem of balanceItems) {
402
411
  if (!this.checkScope(balanceItem.organizationId)) {
403
412
  // Invalid scope
@@ -1,6 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { CachedOutstandingBalance, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
3
- import { AccessRight, CachedOutstandingBalanceObject, CachedOutstandingBalanceObjectContact, CachedOutstandingBalance as CachedOutstandingBalanceStruct, CachedOutstandingBalanceType, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateOrder, PrivateWebshop, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
2
+ import { CachedBalance, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
3
+ import { AccessRight, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateOrder, PrivateWebshop, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { Context } from './Context';
@@ -446,34 +446,72 @@ export class AuthenticatedStructures {
446
446
  return result;
447
447
  }
448
448
 
449
- static async cachedOutstandingBalances(balances: CachedOutstandingBalance[]): Promise<CachedOutstandingBalanceStruct[]> {
449
+ static async receivableBalance(balance: CachedBalance): Promise<ReceivableBalanceStruct> {
450
+ return (await this.receivableBalances([balance]))[0];
451
+ }
452
+
453
+ static async receivableBalances(balances: CachedBalance[]): Promise<ReceivableBalanceStruct[]> {
450
454
  if (balances.length === 0) {
451
455
  return [];
452
456
  }
453
457
 
454
- const organizationIds = Formatter.uniqueArray(balances.filter(b => b.objectType === CachedOutstandingBalanceType.organization).map(b => b.objectId));
458
+ const organizationIds = Formatter.uniqueArray(balances.filter(b => b.objectType === ReceivableBalanceType.organization).map(b => b.objectId));
455
459
  const organizations = organizationIds.length > 0 ? await Organization.getByIDs(...organizationIds) : [];
456
460
  const admins = await User.getAdmins(organizationIds, { verified: true });
457
-
458
461
  const organizationStructs = await this.organizations(organizations);
459
462
 
460
- const result: CachedOutstandingBalanceStruct[] = [];
463
+ const memberIds = Formatter.uniqueArray(balances.filter(b => b.objectType === ReceivableBalanceType.member).map(b => b.objectId));
464
+ const members = memberIds.length > 0 ? await Member.getBlobByIds(...memberIds) : [];
465
+
466
+ const result: ReceivableBalanceStruct[] = [];
461
467
  for (const balance of balances) {
462
- const organization = organizationStructs.find(o => o.id == balance.objectId) ?? null;
463
- let thisAdmins: User[] = [];
464
- if (organization) {
465
- thisAdmins = admins.filter(a => a.permissions && a.permissions.forOrganization(organization)?.hasAccessRight(AccessRight.OrganizationFinanceDirector));
468
+ let object = ReceivableBalanceObject.create({
469
+ id: balance.objectId,
470
+ name: 'Onbekend',
471
+ });
472
+
473
+ if (balance.objectType === ReceivableBalanceType.organization) {
474
+ const organization = organizationStructs.find(o => o.id == balance.objectId) ?? null;
475
+ if (organization) {
476
+ const thisAdmins = admins.filter(a => a.permissions && a.permissions.forOrganization(organization)?.hasAccessRight(AccessRight.OrganizationFinanceDirector));
477
+ object = ReceivableBalanceObject.create({
478
+ id: balance.objectId,
479
+ name: organization.name,
480
+ contacts: thisAdmins.map(a => ReceivableBalanceObjectContact.create({
481
+ firstName: a.firstName ?? '',
482
+ lastName: a.lastName ?? '',
483
+ emails: [a.email],
484
+ })),
485
+ });
486
+ }
487
+ }
488
+ else if (balance.objectType === ReceivableBalanceType.member) {
489
+ const member = members.find(m => m.id === balance.objectId) ?? null;
490
+ if (member) {
491
+ object = ReceivableBalanceObject.create({
492
+ id: balance.objectId,
493
+ name: member.details.name,
494
+ contacts: [
495
+ ReceivableBalanceObjectContact.create({
496
+ firstName: member.details.firstName ?? '',
497
+ lastName: member.details.lastName ?? '',
498
+ emails: member.details.getMemberEmails(),
499
+ }),
500
+ ...member.users.filter(u => !member.details.getMemberEmails().includes(u.email)).map((a) => {
501
+ return ReceivableBalanceObjectContact.create({
502
+ firstName: a.firstName ?? '',
503
+ lastName: a.lastName ?? '',
504
+ emails: [a.email],
505
+ });
506
+ }),
507
+ ],
508
+ });
509
+ }
466
510
  }
467
511
 
468
- const struct = CachedOutstandingBalanceStruct.create({
512
+ const struct = ReceivableBalanceStruct.create({
469
513
  ...balance,
470
- object: CachedOutstandingBalanceObject.create({
471
- name: organization?.name ?? 'Onbekend',
472
- contacts: thisAdmins.map(a => CachedOutstandingBalanceObjectContact.create({
473
- name: a.name ?? '',
474
- emails: [a.email],
475
- })),
476
- }),
514
+ object,
477
515
  });
478
516
 
479
517
  result.push(struct);
@@ -0,0 +1,68 @@
1
+ import { Group, MemberResponsibilityRecord, Platform, Registration } from '@stamhoofd/models';
2
+ import { SQL, SQLWhereExists, SQLWhereSign } from '@stamhoofd/sql';
3
+ import { GroupType } from '@stamhoofd/structures';
4
+
5
+ export class FlagMomentCleanup {
6
+ /**
7
+ * End functions of old members who have no active registration for the current period.
8
+ */
9
+ static async endFunctionsOfUsersWithoutRegistration() {
10
+ console.log('Start cleanup functions');
11
+ const responsibilitiesToEnd = await this.getActiveMemberResponsibilityRecordsForOrganizationWithoutRegistrationInCurrentPeriod();
12
+
13
+ const now = new Date();
14
+
15
+ await Promise.all(responsibilitiesToEnd.map(async (responsibility) => {
16
+ responsibility.endDate = now;
17
+ await responsibility.save();
18
+ console.log(`Ended responsibility with id ${responsibility.id}`);
19
+ }));
20
+ }
21
+
22
+ static async getActiveMemberResponsibilityRecordsForOrganizationWithoutRegistrationInCurrentPeriod() {
23
+ const currentPeriodId = (await Platform.getShared()).periodId;
24
+
25
+ const now = new Date();
26
+
27
+ return await MemberResponsibilityRecord.select()
28
+ .whereNot('organizationId', null)
29
+ .where(
30
+ SQL.where('startDate', SQLWhereSign.LessEqual, now)
31
+ .or('startDate', null),
32
+ )
33
+ .where(
34
+ SQL.where('endDate', SQLWhereSign.GreaterEqual, now)
35
+ .or('endDate', null),
36
+ )
37
+ .whereNot(
38
+ new SQLWhereExists(
39
+ SQL.select()
40
+ .from(Registration.table)
41
+ .join(
42
+ SQL.innerJoin(SQL.table(Group.table))
43
+ .where(
44
+ SQL.column(Group.table, 'id'),
45
+ SQL.column(Registration.table, 'groupId'),
46
+ ),
47
+ )
48
+ .where(
49
+ SQL.column(Registration.table, 'memberId'),
50
+ SQL.column(MemberResponsibilityRecord.table, 'memberId'),
51
+ ).where(
52
+ SQL.column(Registration.table, 'organizationId'),
53
+ SQL.column(MemberResponsibilityRecord.table, 'organizationId'),
54
+ ).where(
55
+ SQL.column(Registration.table, 'periodId'),
56
+ currentPeriodId,
57
+ ).where(
58
+ SQL.column(Registration.table, 'deactivatedAt'),
59
+ null,
60
+ ).where(
61
+ SQL.column(Group.table, 'type'),
62
+ GroupType.Membership,
63
+ ),
64
+ ),
65
+ )
66
+ .fetch();
67
+ }
68
+ }
@@ -0,0 +1,46 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { BalanceItem, Platform } from '@stamhoofd/models';
3
+ import { BalanceItemType, Organization as OrganizationStruct } from '@stamhoofd/structures';
4
+
5
+ export class OrganizationCharger {
6
+ static async chargeFromPlatform(args: { organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string }) {
7
+ const platform = await Platform.getShared();
8
+
9
+ const chargeVia = platform.membershipOrganizationId;
10
+
11
+ if (!chargeVia) {
12
+ throw new SimpleError({
13
+ code: 'missing_membership_organization',
14
+ message: 'Missing membershipOrganizationId',
15
+ human: 'Er is geen lokale groep verantwoordelijk voor de aanrekening van aansluitingen geconfigureerd',
16
+ });
17
+ }
18
+
19
+ await OrganizationCharger.chargeMany({ chargingOrganizationId: chargeVia, ...args });
20
+ }
21
+
22
+ static async chargeMany({ chargingOrganizationId, organizationsToCharge, price, amount, description }: { chargingOrganizationId: string; organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string }) {
23
+ const balanceItems = organizationsToCharge.map(organizationBeingCharged => OrganizationCharger.createBalanceItem({
24
+ price,
25
+ amount,
26
+ description,
27
+ chargingOrganizationId,
28
+ organizationBeingCharged,
29
+ }));
30
+
31
+ await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
32
+ await BalanceItem.updateOutstanding(balanceItems);
33
+ }
34
+
35
+ private static createBalanceItem({ price, amount, description, chargingOrganizationId, organizationBeingCharged }: { price: number; amount?: number; description: string; chargingOrganizationId: string; organizationBeingCharged: OrganizationStruct }): BalanceItem {
36
+ const balanceItem = new BalanceItem();
37
+ balanceItem.unitPrice = price;
38
+ balanceItem.amount = amount ?? 1;
39
+ balanceItem.description = description;
40
+ balanceItem.type = BalanceItemType.Other;
41
+ balanceItem.payingOrganizationId = organizationBeingCharged.id;
42
+ balanceItem.organizationId = chargingOrganizationId;
43
+
44
+ return balanceItem;
45
+ }
46
+ }
@@ -27,19 +27,29 @@ export class StripeHelper {
27
27
 
28
28
  if (charge.payment_method_details?.bancontact) {
29
29
  if (charge.payment_method_details.bancontact.iban_last4) {
30
- payment.iban = 'xxxx ' + charge.payment_method_details.bancontact.iban_last4;
30
+ payment.iban = '•••• ' + charge.payment_method_details.bancontact.iban_last4;
31
31
  }
32
32
  payment.ibanName = charge.payment_method_details.bancontact.verified_name;
33
33
  }
34
34
  if (charge.payment_method_details?.ideal) {
35
35
  if (charge.payment_method_details.ideal.iban_last4) {
36
- payment.iban = 'xxxx ' + charge.payment_method_details.ideal.iban_last4;
36
+ payment.iban = '•••• ' + charge.payment_method_details.ideal.iban_last4;
37
37
  }
38
38
  payment.ibanName = charge.payment_method_details.ideal.verified_name;
39
39
  }
40
40
  if (charge.payment_method_details?.card) {
41
41
  if (charge.payment_method_details.card.last4) {
42
- payment.iban = 'xxxx ' + charge.payment_method_details.card.last4;
42
+ payment.iban = '•••• ' + charge.payment_method_details.card.last4;
43
+ }
44
+ }
45
+ if (charge.payment_method_details?.sepa_debit) {
46
+ if (charge.payment_method_details.sepa_debit.last4) {
47
+ if (charge.payment_method_details.sepa_debit.country === 'BE') {
48
+ payment.iban = charge.payment_method_details.sepa_debit.country + '•• ' + charge.payment_method_details.sepa_debit.bank_code + '• •••• ' + charge.payment_method_details.sepa_debit.last4;
49
+ }
50
+ else {
51
+ payment.iban = '•••• ' + charge.payment_method_details.sepa_debit.last4;
52
+ }
43
53
  }
44
54
  }
45
55
  await payment.save();
@@ -139,7 +149,7 @@ export class StripeHelper {
139
149
 
140
150
  console.log('session', session);
141
151
 
142
- if (session.status === 'complete') {
152
+ if (session.payment_status === 'paid') {
143
153
  // This is a direct charge
144
154
  const payment_intent = session.payment_intent;
145
155
  if (payment_intent !== null && typeof payment_intent !== 'string') {
@@ -158,7 +168,7 @@ export class StripeHelper {
158
168
  // Cancel the session
159
169
  const session = await stripe.checkout.sessions.expire(model.stripeSessionId);
160
170
 
161
- if (session.status === 'complete') {
171
+ if (session.payment_status === 'paid') {
162
172
  return PaymentStatus.Succeeded;
163
173
  }
164
174
  if (session.status === 'expired') {
@@ -166,6 +176,11 @@ export class StripeHelper {
166
176
  }
167
177
  }
168
178
 
179
+ if (session.status === 'complete') {
180
+ // Small difference to detect if the payment is almost done
181
+ return PaymentStatus.Pending;
182
+ }
183
+
169
184
  return PaymentStatus.Created;
170
185
  }
171
186
 
@@ -231,10 +246,18 @@ export class StripeHelper {
231
246
  // Bancontact or iDEAL: use payment intends
232
247
  if (payment.method === PaymentMethod.Bancontact || payment.method === PaymentMethod.iDEAL) {
233
248
  const paymentMethod = await stripe.paymentMethods.create({
234
- type: payment.method.toLowerCase() as 'bancontact',
249
+ type: payment.method.toLowerCase() as 'bancontact' | 'ideal',
235
250
  billing_details: {
236
- name: customer.name && customer.name.length > 2 ? customer.name : 'Onbekend',
237
- email: customer.email,
251
+ name: payment.customer?.dynamicName || (customer.name.length > 2 ? customer.name : 'Onbekend'),
252
+ email: payment.customer?.dynamicEmail || customer.email,
253
+ address: payment.customer?.company?.address
254
+ ? {
255
+ city: payment.customer.company.address.city,
256
+ country: payment.customer.company.address.country,
257
+ line1: payment.customer.company.address.street + ' ' + payment.customer.company.address.number,
258
+ postal_code: payment.customer.company.address.postalCode,
259
+ }
260
+ : undefined,
238
261
  },
239
262
  });
240
263
 
@@ -315,7 +338,7 @@ export class StripeHelper {
315
338
  mode: 'payment',
316
339
  success_url: redirectUrl,
317
340
  cancel_url: cancelUrl,
318
- payment_method_types: ['card'],
341
+ payment_method_types: payment.method === PaymentMethod.DirectDebit ? ['sepa_debit'] : ['card'],
319
342
  line_items: stripeLineItems,
320
343
  currency: 'eur',
321
344
  locale: i18n.language as 'nl',
@@ -329,8 +352,10 @@ export class StripeHelper {
329
352
  : undefined,
330
353
  metadata: fullMetadata,
331
354
  statement_descriptor: Formatter.slug(statementDescriptor).substring(0, 22).toUpperCase(),
355
+
332
356
  },
333
- customer_email: customer.email,
357
+ customer_email: payment.customer?.dynamicEmail || customer.email,
358
+ customer_creation: 'if_required',
334
359
  metadata: fullMetadata,
335
360
  expires_at: Math.floor(Date.now() / 1000) + 30 * 60, // Expire in 30 minutes
336
361
  });
@@ -2,10 +2,9 @@ import { baseSQLFilterCompilers, createSQLColumnFilterCompiler, SQLFilterDefinit
2
2
 
3
3
  export const orderFilterCompilers: SQLFilterDefinitions = {
4
4
  ...baseSQLFilterCompilers,
5
- 'id': createSQLColumnFilterCompiler('id'),
6
- 'name': createSQLColumnFilterCompiler('name'),
7
- 'organizationId': createSQLColumnFilterCompiler('organizationId'),
8
- '#': createSQLColumnFilterCompiler('number'),
5
+ id: createSQLColumnFilterCompiler('id'),
6
+ organizationId: createSQLColumnFilterCompiler('organizationId'),
7
+ number: createSQLColumnFilterCompiler('number'),
9
8
  // 'startDate': createSQLColumnFilterCompiler('startDate'),
10
9
  // 'endDate': createSQLColumnFilterCompiler('endDate'),
11
10
  // 'groupIds': createSQLExpressionFilterCompiler(
@@ -3,7 +3,7 @@ import { SQLFilterDefinitions, baseSQLFilterCompilers, createSQLColumnFilterComp
3
3
  /**
4
4
  * Defines how to filter cached balance items in the database from StamhoofdFilter objects
5
5
  */
6
- export const cachedOutstandingBalanceFilterCompilers: SQLFilterDefinitions = {
6
+ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
7
7
  ...baseSQLFilterCompilers,
8
8
  id: createSQLColumnFilterCompiler('id'),
9
9
  organizationId: createSQLColumnFilterCompiler('organizationId'),
@@ -11,7 +11,7 @@ export const orderSorters: SQLSortDefinitions<Order> = {
11
11
  // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
12
  // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
13
 
14
- '#': {
14
+ number: {
15
15
  getValue(a) {
16
16
  return a.number;
17
17
  },
@@ -22,7 +22,7 @@ export const orderSorters: SQLSortDefinitions<Order> = {
22
22
  });
23
23
  },
24
24
  },
25
- 'id': {
25
+ id: {
26
26
  getValue(a) {
27
27
  return a.id;
28
28
  },
@@ -33,18 +33,7 @@ export const orderSorters: SQLSortDefinitions<Order> = {
33
33
  });
34
34
  },
35
35
  },
36
- 'name': {
37
- getValue(a) {
38
- return a.id;
39
- },
40
- toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
41
- return new SQLOrderBy({
42
- column: SQL.column('name'),
43
- direction,
44
- });
45
- },
46
- },
47
- 'createdAt': {
36
+ createdAt: {
48
37
  getValue(a) {
49
38
  return Formatter.dateTimeIso(a.createdAt);
50
39
  },
@@ -1,7 +1,7 @@
1
- import { CachedOutstandingBalance } from '@stamhoofd/models';
1
+ import { CachedBalance } from '@stamhoofd/models';
2
2
  import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
3
 
4
- export const cachedOutstandingBalanceSorters: SQLSortDefinitions<CachedOutstandingBalance> = {
4
+ export const receivableBalanceSorters: SQLSortDefinitions<CachedBalance> = {
5
5
  // WARNING! TEST NEW SORTERS THOROUGHLY!
6
6
  // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
7
7
  // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.