@stamhoofd/backend 2.120.6 → 2.121.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 (61) hide show
  1. package/package.json +12 -12
  2. package/src/audit-logs/RegistrationInvitationLogger.ts +46 -0
  3. package/src/audit-logs/init.ts +2 -0
  4. package/src/crons/index.ts +2 -0
  5. package/src/crons/invoices.ts +166 -0
  6. package/src/crons/mollie-chargebacks.ts +87 -0
  7. package/src/crons.ts +47 -10
  8. package/src/email-recipient-loaders/payments.ts +84 -41
  9. package/src/endpoints/global/groups/GetGroupsCountEndpoint.ts +51 -0
  10. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +22 -3
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -0
  12. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsCountEndpoint.ts +45 -0
  13. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +495 -0
  14. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.ts +216 -0
  15. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +405 -0
  16. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +168 -0
  17. package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +15 -0
  18. package/src/endpoints/{global → organization/dashboard}/billing/DeactivatePackageEndpoint.ts +3 -4
  19. package/src/endpoints/organization/dashboard/billing/DeleteOrganizationMandateEndpoint.ts +62 -0
  20. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceCollectionEndpoint.ts +56 -0
  21. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceEndpoint.ts +42 -19
  22. package/src/endpoints/organization/dashboard/billing/GetOrganizationMandatesEndpoint.ts +64 -0
  23. package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.ts +11 -3
  24. package/src/endpoints/organization/dashboard/billing/OrganizationCheckoutEndpoint.ts +308 -0
  25. package/src/endpoints/organization/dashboard/billing/PatchOrganizationMandatesEndpoint.ts +94 -0
  26. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +7 -0
  27. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +5 -4
  28. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +7 -2
  29. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +17 -8
  30. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +3 -3
  31. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +127 -0
  32. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +13 -4
  33. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +7 -1
  34. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  35. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +13 -11
  36. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +14 -19
  37. package/src/helpers/AdminPermissionChecker.ts +11 -3
  38. package/src/helpers/AuthenticatedStructures.ts +94 -6
  39. package/src/helpers/FinancialSupportHelper.ts +21 -0
  40. package/src/helpers/RecordAnswerHelper.test.ts +746 -0
  41. package/src/helpers/RecordAnswerHelper.ts +116 -0
  42. package/src/helpers/StripeHelper.ts +2 -3
  43. package/src/helpers/ViesHelper.ts +7 -3
  44. package/src/seeds/1750090030-records-configuration.ts +68 -3
  45. package/src/seeds/1752848561-groups-registration-periods.ts +26 -2
  46. package/src/seeds/1779121239-default-invoice-email-template.sql +3 -0
  47. package/src/services/BalanceItemService.ts +12 -16
  48. package/src/services/InvoiceService.ts +372 -72
  49. package/src/services/MollieService.ts +537 -0
  50. package/src/services/PaymentMandateService.ts +214 -0
  51. package/src/services/PaymentService.ts +578 -222
  52. package/src/services/PlatformMembershipService.ts +1 -1
  53. package/src/services/RegistrationService.ts +66 -5
  54. package/src/services/STPackageService.ts +0 -7
  55. package/src/services/data/invoice.hbs.html +686 -0
  56. package/src/sql-filters/groups.ts +11 -1
  57. package/src/sql-filters/payments.ts +5 -0
  58. package/src/sql-filters/registration-invitations.ts +90 -0
  59. package/src/sql-sorters/registration-invitations.ts +36 -0
  60. package/vitest.config.js +1 -0
  61. package/src/endpoints/global/billing/ActivatePackagesEndpoint.ts +0 -216
@@ -1,4 +1,4 @@
1
- import type { SQLFilterDefinitions} from '@stamhoofd/sql';
1
+ import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
2
  import { baseSQLFilterCompilers, createColumnFilter, createWildcardColumnFilter, SQL, SQLJsonExtract, SQLValueType } from '@stamhoofd/sql';
3
3
  import { SQLTranslatedString } from '../helpers/SQLTranslatedString.js';
4
4
 
@@ -34,6 +34,16 @@ export const groupFilterCompilers: SQLFilterDefinitions = {
34
34
  type: SQLValueType.String,
35
35
  nullable: true,
36
36
  }),
37
+ type: createColumnFilter({
38
+ expression: SQL.column('type'),
39
+ type: SQLValueType.String,
40
+ nullable: false,
41
+ }),
42
+ waitingListId: createColumnFilter({
43
+ expression: SQL.column('waitingListId'),
44
+ type: SQLValueType.String,
45
+ nullable: true,
46
+ }),
37
47
  bundleDiscounts: createWildcardColumnFilter(
38
48
  (key: string) => ({
39
49
  expression: SQL.jsonExtract(SQL.column('settings'), `$.value.prices[*].bundleDiscounts.${SQLJsonExtract.escapePathComponent(key)}`, true),
@@ -38,6 +38,11 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
38
38
  type: SQLValueType.String,
39
39
  nullable: true,
40
40
  }),
41
+ invoiceId: createColumnFilter({
42
+ expression: SQL.column('invoiceId'),
43
+ type: SQLValueType.String,
44
+ nullable: true,
45
+ }),
41
46
  createdAt: createColumnFilter({
42
47
  expression: SQL.column('createdAt'),
43
48
  type: SQLValueType.Datetime,
@@ -0,0 +1,90 @@
1
+ import { Group, Member, Organization, RegistrationInvitation } from '@stamhoofd/models';
2
+ import type { SQLFilterDefinitions } from '@stamhoofd/sql';
3
+ import { baseSQLFilterCompilers, createColumnFilter, createJoinedRelationFilter, SQL, SQLValueType } from '@stamhoofd/sql';
4
+ import { SQLTranslatedString } from '../helpers/SQLTranslatedString.js';
5
+ import { memberFilterCompilers } from './members.js';
6
+
7
+ export const memberJoin = SQL.join(Member.table).where(SQL.column(Member.table, 'id'), SQL.column(RegistrationInvitation.table, 'memberId'));
8
+
9
+ export const groupJoin = SQL.join(Group.table).where(SQL.column(Group.table, 'id'), SQL.column(RegistrationInvitation.table, 'groupId'));
10
+
11
+ export const organizationJoin = SQL.join(Organization.table).where(SQL.column(Organization.table, 'id'), SQL.column(RegistrationInvitation.table, 'organizationId'));
12
+
13
+ export const registrationInvitationFilterCompilers: SQLFilterDefinitions = {
14
+ ...baseSQLFilterCompilers,
15
+ id: createColumnFilter({
16
+ expression: SQL.column(RegistrationInvitation.table, 'id'),
17
+ type: SQLValueType.String,
18
+ nullable: false,
19
+ }),
20
+ organizationId: createColumnFilter({
21
+ expression: SQL.column(RegistrationInvitation.table, 'organizationId'),
22
+ type: SQLValueType.String,
23
+ nullable: false,
24
+ }),
25
+ groupId: createColumnFilter({
26
+ expression: SQL.column(RegistrationInvitation.table, 'groupId'),
27
+ type: SQLValueType.String,
28
+ nullable: false,
29
+ // todo?
30
+ // async checkPermission(filter) {
31
+ // await checkGroupIdFilterAccess(filter, PermissionLevel.Read);
32
+ // },
33
+ }),
34
+ memberId: createColumnFilter({
35
+ expression: SQL.column(RegistrationInvitation.table, 'memberId'),
36
+ type: SQLValueType.String,
37
+ nullable: false,
38
+ }),
39
+ createdAt: createColumnFilter({
40
+ expression: SQL.column(RegistrationInvitation.table, 'createdAt'),
41
+ type: SQLValueType.Datetime,
42
+ nullable: false,
43
+ }),
44
+ member: createJoinedRelationFilter(
45
+ memberJoin,
46
+ memberFilterCompilers,
47
+ ),
48
+ group: createJoinedRelationFilter(
49
+ groupJoin,
50
+ {
51
+ ...baseSQLFilterCompilers,
52
+ id: createColumnFilter({
53
+ expression: SQL.column('groupId'),
54
+ type: SQLValueType.String,
55
+ nullable: false,
56
+ }),
57
+ periodId: createColumnFilter({
58
+ expression: SQL.column('periodId'),
59
+ type: SQLValueType.String,
60
+ nullable: false,
61
+ }),
62
+ type: createColumnFilter({
63
+ expression: SQL.column('groups', 'type'),
64
+ type: SQLValueType.String,
65
+ nullable: false,
66
+ }),
67
+ name: createColumnFilter({
68
+ expression: new SQLTranslatedString(SQL.column('groups', 'settings'), '$.value.name'),
69
+ type: SQLValueType.String,
70
+ nullable: true,
71
+ }),
72
+ status: createColumnFilter({
73
+ expression: SQL.column('groups', 'status'),
74
+ type: SQLValueType.String,
75
+ nullable: false,
76
+ }),
77
+ defaultAgeGroupId: createColumnFilter({
78
+ expression: SQL.column('groups', 'defaultAgeGroupId'),
79
+ type: SQLValueType.String,
80
+ nullable: true,
81
+ }),
82
+ // temporary keep filter for testing
83
+ deletedAt: createColumnFilter({
84
+ expression: SQL.column('groups', 'deletedAt'),
85
+ type: SQLValueType.Datetime,
86
+ nullable: true,
87
+ }),
88
+ },
89
+ ),
90
+ };
@@ -0,0 +1,36 @@
1
+ import type { RegistrationInvitation } from '@stamhoofd/models';
2
+ import type { SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { SQL, SQLOrderBy } from '@stamhoofd/sql';
4
+
5
+ export const registrationInvitationSorters: SQLSortDefinitions<RegistrationInvitation> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
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
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+
14
+ 'id': {
15
+ getValue(invitation) {
16
+ return invitation.id;
17
+ },
18
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
19
+ return new SQLOrderBy({
20
+ column: SQL.column('id'),
21
+ direction,
22
+ });
23
+ },
24
+ },
25
+ 'createdAt': {
26
+ getValue(invitation) {
27
+ return invitation.createdAt;
28
+ },
29
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
30
+ return new SQLOrderBy({
31
+ column: SQL.column('createdAt'),
32
+ direction,
33
+ });
34
+ },
35
+ },
36
+ };
package/vitest.config.js CHANGED
@@ -8,6 +8,7 @@ export default defineConfig({
8
8
  globals: true,
9
9
  root: import.meta.dirname,
10
10
  isolate: true,
11
+ testTimeout: 10_000, // required for slow CI
11
12
  maxWorkers: 1, // For now we can't run parallel because all test files use the same database
12
13
  },
13
14
  });
@@ -1,216 +0,0 @@
1
- import type { Decoder } from '@simonbackx/simple-encoding';
2
- import type { DecodedRequest, Request} from '@simonbackx/simple-endpoints';
3
- import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
- import { SimpleError } from '@simonbackx/simple-errors';
5
- import type { BalanceItem} from '@stamhoofd/models';
6
- import { Organization, Platform, STPackage } from '@stamhoofd/models';
7
- import { CheckoutResponse, PackageCheckout, Payment as PaymentStruct, STPackageBundleHelper, STPackageStruct } from '@stamhoofd/structures';
8
- import { Context } from '../../../helpers/Context.js';
9
- import { PaymentService } from '../../../services/PaymentService.js';
10
- import { STPackageService } from '../../../services/STPackageService.js';
11
-
12
- type Params = Record<string, never>;
13
- type Query = undefined;
14
- type Body = PackageCheckout;
15
- type ResponseBody = CheckoutResponse;
16
-
17
- export class ActivatePackagesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
18
- bodyDecoder = PackageCheckout as Decoder<Body>;
19
-
20
- protected doesMatch(request: Request): [true, Params] | [false] {
21
- if (request.method !== 'POST') {
22
- return [false];
23
- }
24
-
25
- const params = Endpoint.parseParameters(request.url, '/billing/activate-packages', {});
26
-
27
- if (params) {
28
- return [true, params as Params];
29
- }
30
- return [false];
31
- }
32
-
33
- async handle(request: DecodedRequest<Params, Query, Body>) {
34
- const organization = await Context.setOrganizationScope();
35
- const { user } = await Context.authenticate();
36
-
37
- // If the user has permission, we'll also search if he has access to the organization's key
38
- if (!await Context.auth.canActivatePackages(organization.id)) {
39
- throw Context.auth.error();
40
- }
41
- const checkout = request.body;
42
-
43
- // Validate company
44
- if (checkout.customer && checkout.customer.company) {
45
- // Search company id
46
- // this avoids needing to check the VAT number every time
47
- const id = checkout.customer.company.id;
48
- const foundCompany = organization.meta.companies.find(c => c.id === id);
49
-
50
- if (!foundCompany) {
51
- throw new SimpleError({
52
- code: 'invalid_data',
53
- message: $t(`%w1`),
54
- });
55
- }
56
- }
57
-
58
- const currentPackages = await STPackageService.getActivePackages(organization.id);
59
-
60
- const packages: STPackageStruct[] = [];
61
- const balanceItems: Map<BalanceItem, number> = new Map();
62
- const models: STPackage[] = [];
63
- let totalPrice = 0;
64
-
65
- if (STAMHOOFD.userMode === 'organization') {
66
- // Only allowed in organization mode
67
- for (const bundle of request.body.purchases.packageBundles) {
68
- // Renew after currently running packages
69
- let date = new Date();
70
-
71
- let skip = false;
72
-
73
- // Do we have a collision? Make sure our package only start after the expiry date of existing packages
74
- for (const currentPack of currentPackages) {
75
- if (!STPackageBundleHelper.isCombineable(bundle, STPackageStruct.create(currentPack))) {
76
- if (!STPackageBundleHelper.isStackable(bundle, STPackageStruct.create(currentPack))) {
77
- // WE skip silently
78
- console.error('Tried to activate non combineable, non stackable packages...');
79
- skip = true;
80
- continue;
81
- }
82
- if (currentPack.validUntil !== null) {
83
- const end = currentPack.validUntil;
84
- if (end > date) {
85
- date = end;
86
- }
87
- }
88
- }
89
- }
90
-
91
- if (skip) {
92
- continue;
93
- }
94
- packages.push(STPackageBundleHelper.getCurrentPackage(bundle, date));
95
- }
96
-
97
- // Add renewals
98
- if (checkout.purchases.renewPackageIds.length > 0) {
99
- for (const id of checkout.purchases.renewPackageIds) {
100
- const pack = currentPackages.find(c => c.id === id);
101
- if (!pack) {
102
- throw new SimpleError({
103
- code: 'not_found',
104
- message: 'Package not found',
105
- human: $t('%1L1'),
106
- });
107
- }
108
-
109
- // Renew
110
- const model = pack.createRenewed();
111
-
112
- const balanceItem = await STPackageService.chargePackage(model, undefined, checkout.customer ?? undefined);
113
- if (balanceItem) {
114
- totalPrice += balanceItem.priceWithVAT;
115
- balanceItems.set(balanceItem, balanceItem.priceWithVAT);
116
- }
117
-
118
- if (!request.body.proForma) {
119
- await model.save();
120
- await balanceItem?.save();
121
- }
122
- models.push(model);
123
- }
124
- }
125
- }
126
-
127
- // Create the real models for each package
128
- // calculate the price for these packages and create a hidden balance item
129
- for (const pack of packages) {
130
- const model = new STPackage();
131
- model.id = pack.id;
132
- model.meta = pack.meta;
133
- model.validUntil = pack.validUntil;
134
- model.removeAt = pack.removeAt;
135
-
136
- // Not yet valid / active (ignored until valid)
137
- model.validAt = null;
138
- model.organizationId = organization.id;
139
-
140
- const balanceItem = await STPackageService.chargePackage(model, undefined, checkout.customer ?? undefined);
141
- if (balanceItem) {
142
- totalPrice += balanceItem.priceWithVAT;
143
- balanceItems.set(balanceItem, balanceItem.priceWithVAT);
144
- }
145
-
146
- if (!request.body.proForma) {
147
- await model.save();
148
- await balanceItem?.save();
149
- }
150
-
151
- models.push(model);
152
- }
153
-
154
- // todo: Add pending items (balance items in request)
155
-
156
- const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
157
- if (!membershipOrganizationId) {
158
- throw new SimpleError({
159
- code: 'unavailable',
160
- message: 'No membership organization id set on the platform',
161
- human: 'Package purchases are currently unavailable',
162
- });
163
- }
164
-
165
- const membershipOrganization = await Organization.getByID(membershipOrganizationId);
166
- if (!membershipOrganization) {
167
- throw new Error('Unexpected missing membershipOrganization');
168
- }
169
-
170
- const result = await PaymentService.createPayment({
171
- balanceItems,
172
- checkout,
173
- user,
174
- organization: membershipOrganization,
175
- payingOrganization: organization,
176
- serviceFeeType: 'system',
177
- });
178
-
179
- console.log('Created payment', result);
180
-
181
- if (!result) {
182
- // No payment needed
183
- throw new SimpleError({
184
- code: 'missing_data',
185
- message: 'Checkout was empty',
186
- human: $t('%1L2'),
187
- });
188
- }
189
-
190
- if (!checkout.proForma) {
191
- for (const pack of models) {
192
- await pack.save();
193
- }
194
- }
195
- else {
196
- // Delete payment again
197
- if (result) {
198
- await result.payment.delete();
199
- result.paymentUrl = null;
200
- result.paymentQRCode = null;
201
- }
202
-
203
- for (const [item] of balanceItems) {
204
- await item.delete();
205
- }
206
- }
207
-
208
- const { payment, paymentUrl, paymentQRCode } = result;
209
-
210
- return new Response(CheckoutResponse.create({
211
- payment: payment ? PaymentStruct.create(payment) : null,
212
- paymentUrl,
213
- paymentQRCode,
214
- }));
215
- }
216
- }