@stamhoofd/backend 2.141.0 → 2.142.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/crons/settlement-sync.ts +1 -1
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.test.ts +165 -0
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +18 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +154 -4
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts +15 -8
- package/src/helpers/ApplicationFeeInvoicer.test.ts +64 -3
- package/src/helpers/ApplicationFeeInvoicer.ts +23 -18
- package/src/helpers/SettlementExporter.test.ts +22 -0
- package/src/helpers/SettlementExporter.ts +13 -3
- package/src/helpers/StripeSettlementSync.test.ts +169 -0
- package/src/helpers/StripeSettlementSync.ts +158 -52
- package/src/helpers/StripeSettlementSyncRunner.ts +8 -0
- package/src/services/ApplicationFeeService.ts +27 -9
- package/src/services/SettlementService.test.ts +82 -9
- package/src/services/SettlementService.ts +46 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.142.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -65,20 +65,20 @@
|
|
|
65
65
|
"@simonbackx/simple-errors": "1.5.0",
|
|
66
66
|
"@simonbackx/simple-logging": "1.0.1",
|
|
67
67
|
"@simplewebauthn/server": "13.3.2",
|
|
68
|
-
"@stamhoofd/backend-env": "2.
|
|
69
|
-
"@stamhoofd/backend-i18n": "2.
|
|
70
|
-
"@stamhoofd/backend-middleware": "2.
|
|
71
|
-
"@stamhoofd/crons": "2.
|
|
72
|
-
"@stamhoofd/email": "2.
|
|
73
|
-
"@stamhoofd/excel-writer": "2.
|
|
74
|
-
"@stamhoofd/logging": "2.
|
|
75
|
-
"@stamhoofd/models": "2.
|
|
76
|
-
"@stamhoofd/object-differ": "2.
|
|
77
|
-
"@stamhoofd/queues": "2.
|
|
78
|
-
"@stamhoofd/sql": "2.
|
|
79
|
-
"@stamhoofd/structures": "2.
|
|
80
|
-
"@stamhoofd/types": "2.
|
|
81
|
-
"@stamhoofd/utility": "2.
|
|
68
|
+
"@stamhoofd/backend-env": "2.142.0",
|
|
69
|
+
"@stamhoofd/backend-i18n": "2.142.0",
|
|
70
|
+
"@stamhoofd/backend-middleware": "2.142.0",
|
|
71
|
+
"@stamhoofd/crons": "2.142.0",
|
|
72
|
+
"@stamhoofd/email": "2.142.0",
|
|
73
|
+
"@stamhoofd/excel-writer": "2.142.0",
|
|
74
|
+
"@stamhoofd/logging": "2.142.0",
|
|
75
|
+
"@stamhoofd/models": "2.142.0",
|
|
76
|
+
"@stamhoofd/object-differ": "2.142.0",
|
|
77
|
+
"@stamhoofd/queues": "2.142.0",
|
|
78
|
+
"@stamhoofd/sql": "2.142.0",
|
|
79
|
+
"@stamhoofd/structures": "2.142.0",
|
|
80
|
+
"@stamhoofd/types": "2.142.0",
|
|
81
|
+
"@stamhoofd/utility": "2.142.0",
|
|
82
82
|
"archiver": "7.0.1",
|
|
83
83
|
"argon2": "0.44.0",
|
|
84
84
|
"axios": "1.18.1",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"uuid": "14.0.1"
|
|
103
103
|
},
|
|
104
104
|
"devDependencies": {
|
|
105
|
-
"@stamhoofd/test-utils": "2.
|
|
105
|
+
"@stamhoofd/test-utils": "2.142.0",
|
|
106
106
|
"@types/cookie": "0.6.0",
|
|
107
107
|
"@types/luxon": "3.7.2",
|
|
108
108
|
"@types/mailparser": "3.4.6",
|
|
@@ -117,5 +117,5 @@
|
|
|
117
117
|
"publishConfig": {
|
|
118
118
|
"access": "public"
|
|
119
119
|
},
|
|
120
|
-
"gitHead": "
|
|
120
|
+
"gitHead": "2903821fe8196e3bb194da193aaf1cda2c3bf076"
|
|
121
121
|
}
|
|
@@ -96,7 +96,7 @@ export async function reportProblemSettlements() {
|
|
|
96
96
|
const total = new Set([...unexplained, ...unsynced, ...stalePendingFees].map(s => s.id)).size;
|
|
97
97
|
|
|
98
98
|
const describe = (settlement: Settlement) => {
|
|
99
|
-
return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
|
|
99
|
+
return settlement.provider + ' ' + settlement.externalId + ' (' + Formatter.dateIso(settlement.settledAt) + ', ' + Formatter.price(settlement.unexplainedAmount) + ' onverklaard, ' + Formatter.price(settlement.pendingFees) + ' niet-gefactureerde kosten, ' + Formatter.price(settlement.uncollectibleFees) + ' niet-aanrekenbare kosten, ' + settlement.syncFailureCount + ' mislukte pogingen)';
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
Email.sendWebmaster({
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { BalanceItemFactory, Invoice, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
4
|
+
import { SettlementCharge } from '@stamhoofd/models/models/SettlementCharge.js';
|
|
5
|
+
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
6
|
+
import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
7
|
+
import type { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
8
|
+
import { PaymentMethod, PaymentStatus } from '@stamhoofd/structures';
|
|
9
|
+
import { ApplicationFeeType } from '@stamhoofd/structures/settlements/ApplicationFeeType.js';
|
|
10
|
+
import { SettlementChargeType } from '@stamhoofd/structures/settlements/SettlementChargeType.js';
|
|
11
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
12
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
13
|
+
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
14
|
+
import { initPlatformAdmin } from '../../../../tests/init/index.js';
|
|
15
|
+
import { initMembershipOrganization } from '../../../../tests/init/initMembershipOrganization.js';
|
|
16
|
+
import { PatchOrganizationsEndpoint } from './PatchOrganizationsEndpoint.js';
|
|
17
|
+
|
|
18
|
+
const baseUrl = `/admin/organizations`;
|
|
19
|
+
const endpoint = new PatchOrganizationsEndpoint();
|
|
20
|
+
|
|
21
|
+
describe('Endpoint.PatchOrganizationsEndpoint', () => {
|
|
22
|
+
let membershipOrganization: Organization;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
membershipOrganization = await initMembershipOrganization();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const deleteOrganization = async (organization: Organization) => {
|
|
33
|
+
const { adminToken } = await initPlatformAdmin();
|
|
34
|
+
|
|
35
|
+
const patch: PatchableArrayAutoEncoder<OrganizationStruct> = new PatchableArray();
|
|
36
|
+
patch.addDelete(organization.id);
|
|
37
|
+
|
|
38
|
+
// Platform admins are not scoped to an organization, so no host is required
|
|
39
|
+
const request = Request.patch({
|
|
40
|
+
path: baseUrl,
|
|
41
|
+
host: '',
|
|
42
|
+
body: patch,
|
|
43
|
+
headers: {
|
|
44
|
+
authorization: 'Bearer ' + adminToken.accessToken,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return await testServer.test(endpoint, request);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const createPayment = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
|
|
52
|
+
const payment = new Payment();
|
|
53
|
+
payment.organizationId = membershipOrganization.id;
|
|
54
|
+
payment.payingOrganizationId = payingOrganizationId;
|
|
55
|
+
payment.method = PaymentMethod.Transfer;
|
|
56
|
+
payment.status = PaymentStatus.Succeeded;
|
|
57
|
+
payment.price = 10_00;
|
|
58
|
+
await payment.save();
|
|
59
|
+
return payment;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const createApplicationFee = async ({ payingOrganizationId }: { payingOrganizationId: string }) => {
|
|
63
|
+
const charge = new SettlementCharge();
|
|
64
|
+
charge.type = SettlementChargeType.ApplicationFeeService;
|
|
65
|
+
charge.externalId = uuidv4();
|
|
66
|
+
charge.amount = -30_00;
|
|
67
|
+
charge.organizationId = payingOrganizationId;
|
|
68
|
+
charge.occurredAt = new Date();
|
|
69
|
+
await charge.save();
|
|
70
|
+
|
|
71
|
+
const fee = new ApplicationFee();
|
|
72
|
+
fee.externalId = uuidv4();
|
|
73
|
+
fee.type = ApplicationFeeType.Service;
|
|
74
|
+
fee.amount = 30_00;
|
|
75
|
+
fee.organizationId = membershipOrganization.id;
|
|
76
|
+
fee.payingOrganizationId = payingOrganizationId;
|
|
77
|
+
fee.settlementChargeId = charge.id;
|
|
78
|
+
fee.occurredAt = new Date();
|
|
79
|
+
await fee.save();
|
|
80
|
+
return fee;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createInvoice = async ({ payingOrganizationId }: { payingOrganizationId: string | null }) => {
|
|
84
|
+
const invoice = new Invoice();
|
|
85
|
+
invoice.organizationId = membershipOrganization.id;
|
|
86
|
+
invoice.payingOrganizationId = payingOrganizationId;
|
|
87
|
+
await invoice.save();
|
|
88
|
+
return invoice;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test('An organization without financial records can be deleted', async () => {
|
|
92
|
+
const organization = await new OrganizationFactory({}).create();
|
|
93
|
+
|
|
94
|
+
const response = await deleteOrganization(organization);
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(200);
|
|
97
|
+
expect(await Organization.getByID(organization.id)).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const payerRecords = [
|
|
101
|
+
{
|
|
102
|
+
name: 'a balance item it owes',
|
|
103
|
+
create: async (organization: Organization) => {
|
|
104
|
+
await new BalanceItemFactory({
|
|
105
|
+
organizationId: membershipOrganization.id,
|
|
106
|
+
payingOrganizationId: organization.id,
|
|
107
|
+
amount: 1,
|
|
108
|
+
unitPrice: 10_00,
|
|
109
|
+
}).create();
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'a payment it made',
|
|
114
|
+
create: async (organization: Organization) => {
|
|
115
|
+
await createPayment({ payingOrganizationId: organization.id });
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'an application fee it was charged',
|
|
120
|
+
create: async (organization: Organization) => {
|
|
121
|
+
await createApplicationFee({ payingOrganizationId: organization.id });
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'an invoice it has to pay',
|
|
126
|
+
create: async (organization: Organization) => {
|
|
127
|
+
await createInvoice({ payingOrganizationId: organization.id });
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
test.each(payerRecords)('An organization with $name cannot be deleted', async ({ create }) => {
|
|
133
|
+
const organization = await new OrganizationFactory({}).create();
|
|
134
|
+
await create(organization);
|
|
135
|
+
|
|
136
|
+
await expect(deleteOrganization(organization)).rejects.toThrow(STExpect.errorWithCode('organization_has_financial_records'));
|
|
137
|
+
expect(await Organization.getByID(organization.id)).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('Financial records the organization received do not block the delete', async () => {
|
|
141
|
+
const organization = await new OrganizationFactory({}).create();
|
|
142
|
+
|
|
143
|
+
await new BalanceItemFactory({
|
|
144
|
+
organizationId: organization.id,
|
|
145
|
+
amount: 1,
|
|
146
|
+
unitPrice: 10_00,
|
|
147
|
+
}).create();
|
|
148
|
+
|
|
149
|
+
const payment = new Payment();
|
|
150
|
+
payment.organizationId = organization.id;
|
|
151
|
+
payment.method = PaymentMethod.Transfer;
|
|
152
|
+
payment.status = PaymentStatus.Succeeded;
|
|
153
|
+
payment.price = 10_00;
|
|
154
|
+
await payment.save();
|
|
155
|
+
|
|
156
|
+
const invoice = new Invoice();
|
|
157
|
+
invoice.organizationId = organization.id;
|
|
158
|
+
await invoice.save();
|
|
159
|
+
|
|
160
|
+
const response = await deleteOrganization(organization);
|
|
161
|
+
|
|
162
|
+
expect(response.status).toBe(200);
|
|
163
|
+
expect(await Organization.getByID(organization.id)).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -3,7 +3,8 @@ import { PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encodin
|
|
|
3
3
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
-
import { Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
6
|
+
import { BalanceItem, Invoice, Organization, OrganizationRegistrationPeriod, Payment, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
7
|
+
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
7
8
|
import { Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
8
9
|
|
|
9
10
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -61,6 +62,22 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// Financial records may not disappear together with the organization that had to pay them
|
|
66
|
+
const payerRecordCounts = await Promise.all([
|
|
67
|
+
BalanceItem.select().where('payingOrganizationId', id).count(),
|
|
68
|
+
Payment.select().where('payingOrganizationId', id).count(),
|
|
69
|
+
ApplicationFee.select().where('payingOrganizationId', id).count(),
|
|
70
|
+
Invoice.select().where('payingOrganizationId', id).count(),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
if (payerRecordCounts.some(count => count > 0)) {
|
|
74
|
+
throw new SimpleError({
|
|
75
|
+
code: 'organization_has_financial_records',
|
|
76
|
+
message: 'Organization is still referenced as paying organization',
|
|
77
|
+
human: $t('Er zijn nog openstaande bedragen, betalingen, transactiekosten of facturen waarvoor {organization} moet betalen of betaald heeft. Die gegevens mogen niet verloren gaan.', { organization: organization.name }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
await organization.delete();
|
|
65
82
|
}
|
|
66
83
|
|
package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts
CHANGED
|
@@ -2,17 +2,18 @@ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
|
2
2
|
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
3
3
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import type { Organization, User } from '@stamhoofd/models';
|
|
5
|
-
import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationInvitation, RegistrationInvitationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
6
6
|
import type { RegistrationInvitation as RegistrationInvitationStruct } from '@stamhoofd/structures';
|
|
7
|
-
import { PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
|
|
7
|
+
import { GroupType, PermissionLevel, Permissions, PermissionsResourceType, RegistrationInvitationRequest, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
|
|
8
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
8
9
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
9
10
|
import { PatchRegistrationInvitationsEndpoint } from './PatchRegistrationInvitationsEndpoint.js';
|
|
10
11
|
|
|
11
12
|
describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
|
|
12
13
|
const endpoint = new PatchRegistrationInvitationsEndpoint();
|
|
13
14
|
|
|
14
|
-
const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization; user: User | null }) => {
|
|
15
|
-
const request = Request.buildJson('PATCH', '/registration-invitations', organization
|
|
15
|
+
const patchInvitations = async ({ patch, organization, user }: { patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest>; organization: Organization | null; user: User | null }) => {
|
|
16
|
+
const request = Request.buildJson('PATCH', '/registration-invitations', organization?.getApiHost(), patch);
|
|
16
17
|
if (user) {
|
|
17
18
|
const token = await Token.createToken(user);
|
|
18
19
|
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
@@ -401,4 +402,153 @@ describe('Endpoint.PatchRegistrationInvitationsEndpoint', () => {
|
|
|
401
402
|
})).rejects.toThrow('Je hebt geen toegangsrechten om deze uitnodiging te verwijderen.');
|
|
402
403
|
});
|
|
403
404
|
});
|
|
405
|
+
|
|
406
|
+
describe('Without organization scope (platform admins)', () => {
|
|
407
|
+
beforeEach(() => {
|
|
408
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('A platform admin can invite for an event group', async () => {
|
|
412
|
+
const organization = await new OrganizationFactory({}).create();
|
|
413
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
414
|
+
|
|
415
|
+
const admin = await new UserFactory({
|
|
416
|
+
globalPermissions: Permissions.create({
|
|
417
|
+
level: PermissionLevel.Full,
|
|
418
|
+
}),
|
|
419
|
+
}).create();
|
|
420
|
+
|
|
421
|
+
const member = await new MemberFactory({
|
|
422
|
+
organization,
|
|
423
|
+
firstName: 'John',
|
|
424
|
+
lastName: 'Doe',
|
|
425
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
426
|
+
}).create();
|
|
427
|
+
|
|
428
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
429
|
+
|
|
430
|
+
patch.addPut(RegistrationInvitationRequest.create({
|
|
431
|
+
groupId: group.id,
|
|
432
|
+
memberId: member.id,
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
const response = await patchInvitations({
|
|
436
|
+
patch,
|
|
437
|
+
organization: null,
|
|
438
|
+
user: admin,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(response.body).toHaveLength(1);
|
|
442
|
+
expect(response.body[0].group.id).toBe(group.id);
|
|
443
|
+
expect(response.body[0].member.id).toBe(member.id);
|
|
444
|
+
|
|
445
|
+
// The invitation belongs to the organization of the group
|
|
446
|
+
const invitation = await RegistrationInvitation.getByID(response.body[0].id);
|
|
447
|
+
expect(invitation?.organizationId).toBe(organization.id);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('Should fail for a user without platform access', async () => {
|
|
451
|
+
const organization = await new OrganizationFactory({}).create();
|
|
452
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
453
|
+
|
|
454
|
+
const user = await new UserFactory({
|
|
455
|
+
organization,
|
|
456
|
+
permissions: Permissions.create({
|
|
457
|
+
level: PermissionLevel.Full,
|
|
458
|
+
}),
|
|
459
|
+
}).create();
|
|
460
|
+
|
|
461
|
+
const member = await new MemberFactory({
|
|
462
|
+
organization,
|
|
463
|
+
firstName: 'John',
|
|
464
|
+
lastName: 'Doe',
|
|
465
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
466
|
+
}).create();
|
|
467
|
+
|
|
468
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
469
|
+
|
|
470
|
+
patch.addPut(RegistrationInvitationRequest.create({
|
|
471
|
+
groupId: group.id,
|
|
472
|
+
memberId: member.id,
|
|
473
|
+
}));
|
|
474
|
+
|
|
475
|
+
await expect(patchInvitations({
|
|
476
|
+
patch,
|
|
477
|
+
organization: null,
|
|
478
|
+
user,
|
|
479
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('Should fail for a platform admin without access to the group', async () => {
|
|
483
|
+
const organization = await new OrganizationFactory({}).create();
|
|
484
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
485
|
+
|
|
486
|
+
// Some platform access (passes the fast throw), but no rights on this group or its organization
|
|
487
|
+
const admin = await new UserFactory({
|
|
488
|
+
globalPermissions: Permissions.create({
|
|
489
|
+
level: PermissionLevel.None,
|
|
490
|
+
resources: new Map([
|
|
491
|
+
[PermissionsResourceType.OrganizationTags, new Map([
|
|
492
|
+
['unrelated-tag-id', ResourcePermissions.create({
|
|
493
|
+
level: PermissionLevel.Read,
|
|
494
|
+
})],
|
|
495
|
+
])],
|
|
496
|
+
]),
|
|
497
|
+
}),
|
|
498
|
+
}).create();
|
|
499
|
+
|
|
500
|
+
const member = await new MemberFactory({
|
|
501
|
+
organization,
|
|
502
|
+
firstName: 'John',
|
|
503
|
+
lastName: 'Doe',
|
|
504
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
505
|
+
}).create();
|
|
506
|
+
|
|
507
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
508
|
+
|
|
509
|
+
patch.addPut(RegistrationInvitationRequest.create({
|
|
510
|
+
groupId: group.id,
|
|
511
|
+
memberId: member.id,
|
|
512
|
+
}));
|
|
513
|
+
|
|
514
|
+
await expect(patchInvitations({
|
|
515
|
+
patch,
|
|
516
|
+
organization: null,
|
|
517
|
+
user: admin,
|
|
518
|
+
})).rejects.toThrow(STExpect.simpleError({ code: 'permission_denied' }));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('A platform admin can delete an invitation', async () => {
|
|
522
|
+
const organization = await new OrganizationFactory({}).create();
|
|
523
|
+
const group = await new GroupFactory({ organization, type: GroupType.EventRegistration, name: new TranslatedString('test event') }).create();
|
|
524
|
+
|
|
525
|
+
const admin = await new UserFactory({
|
|
526
|
+
globalPermissions: Permissions.create({
|
|
527
|
+
level: PermissionLevel.Full,
|
|
528
|
+
}),
|
|
529
|
+
}).create();
|
|
530
|
+
|
|
531
|
+
const member = await new MemberFactory({
|
|
532
|
+
organization,
|
|
533
|
+
firstName: 'John',
|
|
534
|
+
lastName: 'Doe',
|
|
535
|
+
birthDay: { year: 1994, month: 6, day: 24 },
|
|
536
|
+
}).create();
|
|
537
|
+
|
|
538
|
+
const invitation = await new RegistrationInvitationFactory({ member, group, organization }).create();
|
|
539
|
+
|
|
540
|
+
const patch: PatchableArrayAutoEncoder<RegistrationInvitationRequest> = new PatchableArray();
|
|
541
|
+
|
|
542
|
+
patch.addDelete(invitation.id);
|
|
543
|
+
|
|
544
|
+
const response = await patchInvitations({
|
|
545
|
+
patch,
|
|
546
|
+
organization: null,
|
|
547
|
+
user: admin,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(response.body).toHaveLength(0);
|
|
551
|
+
expect(await RegistrationInvitation.getByID(invitation.id)).toBeUndefined();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
404
554
|
});
|
package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.ts
CHANGED
|
@@ -35,11 +35,15 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
38
|
-
const organization = await Context.
|
|
38
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
39
39
|
await Context.authenticate();
|
|
40
40
|
|
|
41
41
|
// Fast throw first (more in depth checking for patches later)
|
|
42
|
-
if (
|
|
42
|
+
if (organization) {
|
|
43
|
+
if (!await Context.auth.hasSomeAccess(organization.id)) {
|
|
44
|
+
throw Context.auth.error();
|
|
45
|
+
}
|
|
46
|
+
} else if (!Context.auth.hasSomePlatformAccess()) {
|
|
43
47
|
throw Context.auth.error();
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -48,11 +52,11 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
48
52
|
|
|
49
53
|
const puts = request.body.getPuts();
|
|
50
54
|
for (const { put } of puts) {
|
|
51
|
-
await this.checkCanCreateRegistrationInvitation(put, organization
|
|
55
|
+
const group = await this.checkCanCreateRegistrationInvitation(put, organization?.id ?? null);
|
|
52
56
|
|
|
53
57
|
const invitation = new RegistrationInvitation();
|
|
54
58
|
invitation.id = put.id;
|
|
55
|
-
invitation.organizationId =
|
|
59
|
+
invitation.organizationId = group.organizationId;
|
|
56
60
|
invitation.groupId = put.groupId;
|
|
57
61
|
invitation.memberId = put.memberId;
|
|
58
62
|
|
|
@@ -125,12 +129,13 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
125
129
|
/**
|
|
126
130
|
* Will throw if not allowed to invite.
|
|
127
131
|
* @param invitation
|
|
128
|
-
* @param organizationId
|
|
132
|
+
* @param organizationId organization scope of the request; when set, the group must belong to it (platform admins can invite without a scope)
|
|
133
|
+
* @returns the group to invite for
|
|
129
134
|
*/
|
|
130
|
-
private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string) {
|
|
135
|
+
private async checkCanCreateRegistrationInvitation(invitation: RegistrationInvitationRequest, organizationId: string | null): Promise<Group> {
|
|
131
136
|
const group = await Group.getByID(invitation.groupId);
|
|
132
137
|
|
|
133
|
-
if (!group || group.organizationId !== organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
|
|
138
|
+
if (!group || (organizationId !== null && group.organizationId !== organizationId) || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
|
|
134
139
|
throw Context.auth.error($t(`%1ST`));
|
|
135
140
|
}
|
|
136
141
|
|
|
@@ -147,7 +152,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
147
152
|
|
|
148
153
|
if (!member
|
|
149
154
|
// in userMode 'organization' we can only invite members from the same organization
|
|
150
|
-
|| (STAMHOOFD.userMode === 'organization' && member.organizationId !== organizationId)
|
|
155
|
+
|| (STAMHOOFD.userMode === 'organization' && member.organizationId !== group.organizationId)
|
|
151
156
|
// read access is suficient
|
|
152
157
|
|| !await Context.auth.canAccessMember(member, PermissionLevel.Read)
|
|
153
158
|
) {
|
|
@@ -163,5 +168,7 @@ export class PatchRegistrationInvitationsEndpoint extends Endpoint<Params, Query
|
|
|
163
168
|
human: $t('%1S2'),
|
|
164
169
|
});
|
|
165
170
|
}
|
|
171
|
+
|
|
172
|
+
return group;
|
|
166
173
|
}
|
|
167
174
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
1
2
|
import type { StripeAccount } from '@stamhoofd/models';
|
|
2
3
|
import { BalanceItem, BalanceItemPayment, Organization, OrganizationFactory, Payment } from '@stamhoofd/models';
|
|
3
4
|
import { ApplicationFee } from '@stamhoofd/models/models/ApplicationFee.js';
|
|
@@ -15,6 +16,7 @@ import { initMembershipOrganization } from '../../tests/init/initMembershipOrgan
|
|
|
15
16
|
import { ApplicationFeeService, LEGACY_FEE_PAYMENT_REFERENCE_PREFIX } from '../services/ApplicationFeeService.js';
|
|
16
17
|
import { SettlementService } from '../services/SettlementService.js';
|
|
17
18
|
import { ApplicationFeeInvoicer } from './ApplicationFeeInvoicer.js';
|
|
19
|
+
import { WebmasterReport } from './WebmasterReport.js';
|
|
18
20
|
|
|
19
21
|
describe('ApplicationFeeInvoicer', () => {
|
|
20
22
|
const stripeMocker = new StripeMocker();
|
|
@@ -262,8 +264,18 @@ describe('ApplicationFeeInvoicer', () => {
|
|
|
262
264
|
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
263
265
|
await createFee(organization, broken, { amount: 40_00 });
|
|
264
266
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
+
// A month the legacy invoicer billed may never be billed again: that group throws
|
|
268
|
+
const legacy = new Payment();
|
|
269
|
+
legacy.organizationId = membershipOrganization.id;
|
|
270
|
+
legacy.payingOrganizationId = organization.id;
|
|
271
|
+
legacy.stripeAccountId = broken.id;
|
|
272
|
+
legacy.method = PaymentMethod.AccountDeductions;
|
|
273
|
+
legacy.provider = PaymentProvider.Stripe;
|
|
274
|
+
legacy.status = PaymentStatus.Succeeded;
|
|
275
|
+
legacy.reference = LEGACY_FEE_PAYMENT_REFERENCE_PREFIX + '2024-07-01';
|
|
276
|
+
legacy.price = 40_00;
|
|
277
|
+
legacy.paidAt = occurredAt;
|
|
278
|
+
await legacy.save();
|
|
267
279
|
|
|
268
280
|
await invoiceMonth();
|
|
269
281
|
|
|
@@ -272,14 +284,63 @@ describe('ApplicationFeeInvoicer', () => {
|
|
|
272
284
|
expect(payments[0].price).toBe(30_00);
|
|
273
285
|
});
|
|
274
286
|
|
|
287
|
+
test('fees without the Stripe account they were deducted from are skipped, not retried forever', async () => {
|
|
288
|
+
const { organization, stripeAccount } = await init();
|
|
289
|
+
const removed = await stripeMocker.createStripeAccount(organization.id);
|
|
290
|
+
|
|
291
|
+
await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
292
|
+
const orphaned = await createFee(organization, removed, { amount: 40_00 });
|
|
293
|
+
|
|
294
|
+
// Deleting the account row clears payingStripeAccountId: that fee can no longer be checked
|
|
295
|
+
// against what the legacy invoicer billed per account, so it is not billed at all
|
|
296
|
+
await removed.delete();
|
|
297
|
+
|
|
298
|
+
await WebmasterReport.group('Overslaan applicatiekosten', async () => {
|
|
299
|
+
await invoiceMonth();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const payments = await getFeePayments(organization);
|
|
303
|
+
expect(payments).toHaveLength(1);
|
|
304
|
+
expect(payments[0].price).toBe(30_00);
|
|
305
|
+
expect((await ApplicationFee.getByID(orphaned.id))!.balanceItemId).toBeNull();
|
|
306
|
+
|
|
307
|
+
// Skipping is silent here: the sync that stored the fee already reported it, and this run
|
|
308
|
+
// repeats every night
|
|
309
|
+
const emails = (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.subject.startsWith('Overslaan applicatiekosten'));
|
|
310
|
+
expect(emails).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
275
313
|
test('charges of a billed fee keep pointing at the deduction row', async () => {
|
|
276
314
|
const { organization, stripeAccount } = await init();
|
|
277
315
|
const fee = await createFee(organization, stripeAccount);
|
|
278
316
|
|
|
279
317
|
await invoiceMonth();
|
|
280
318
|
|
|
281
|
-
const charge = await SettlementCharge.getByID(fee.settlementChargeId);
|
|
319
|
+
const charge = await SettlementCharge.getByID(fee.settlementChargeId!);
|
|
282
320
|
expect(charge).toBeDefined();
|
|
283
321
|
expect(charge!.amount).toBe(-30_00);
|
|
284
322
|
});
|
|
323
|
+
|
|
324
|
+
test('fees of a deleted organization are never billed, and do not block the other accounts', async () => {
|
|
325
|
+
const { organization, stripeAccount } = await init();
|
|
326
|
+
const { organization: other, stripeAccount: otherAccount } = await init();
|
|
327
|
+
|
|
328
|
+
const orphaned = await createFee(organization, stripeAccount, { amount: 30_00 });
|
|
329
|
+
await createFee(other, otherAccount, { amount: 40_00 });
|
|
330
|
+
|
|
331
|
+
// Takes the Stripe account and the deduction charge with it, but not our income
|
|
332
|
+
await organization.delete();
|
|
333
|
+
|
|
334
|
+
await invoiceMonth();
|
|
335
|
+
|
|
336
|
+
const payments = await getFeePayments(other);
|
|
337
|
+
expect(payments).toHaveLength(1);
|
|
338
|
+
expect(payments[0].price).toBe(40_00);
|
|
339
|
+
|
|
340
|
+
const stored = await ApplicationFee.getByID(orphaned.id);
|
|
341
|
+
expect(stored).toBeDefined();
|
|
342
|
+
expect(stored!.payingOrganizationId).toBeNull();
|
|
343
|
+
expect(stored!.settlementChargeId).toBeNull();
|
|
344
|
+
expect(stored!.balanceItemId).toBeNull();
|
|
345
|
+
});
|
|
285
346
|
});
|