@stamhoofd/backend 2.130.0 → 2.131.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/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
- package/src/helpers/MembershipCharger.ts +26 -1
- package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
- package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
- package/src/helpers/StripePayoutExportData.ts +195 -0
- package/src/helpers/StripePayoutExportExcel.ts +280 -0
- package/src/helpers/StripePayoutReporter.test.ts +419 -0
- package/src/helpers/StripePayoutReporter.ts +585 -0
- package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
- package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
- package/src/services/uitpas/UitpasService.ts +1 -1
- package/src/services/uitpas/cancelTicketSales.ts +1 -1
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
- package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
- package/src/services/uitpas/registerTicketSales.ts +1 -1
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/tests/e2e/documents.test.ts +3 -1
- package/tests/vitest.global.setup.ts +1 -1
- package/tests/vitest.setup.ts +4 -2
- package/vitest.config.js +10 -0
- /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
|
@@ -5,7 +5,7 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
6
|
import { Email } from '@stamhoofd/email';
|
|
7
7
|
import type { MemberWithUsersRegistrationsAndGroups, Organization, Payment } from '@stamhoofd/models';
|
|
8
|
-
import { BalanceItem, CachedBalance, Group, Member, Platform, RateLimiter, Registration } from '@stamhoofd/models';
|
|
8
|
+
import { BalanceItem, CachedBalance, Group, Member, Platform, RateLimiter, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
9
9
|
import type { BalanceItem as BalanceItemStruct, PlatformMember, RegisterItem } from '@stamhoofd/structures';
|
|
10
10
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, IDRegisterCheckout, Payment as PaymentStruct, PermissionLevel, PlatformFamily, ReceivableBalanceType, RegisterResponse, TranslatedString } from '@stamhoofd/structures';
|
|
11
11
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -516,7 +516,26 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
516
516
|
deactivatedRegistrationGroupIds.push(existingRegistration.groupId);
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
-
|
|
519
|
+
// Cache the registration period relation per periodId, so we only load each period once.
|
|
520
|
+
const periodRelationCache = new Map<string, BalanceItemRelation | null>();
|
|
521
|
+
async function getPeriodRelation(periodId: string): Promise<BalanceItemRelation | null> {
|
|
522
|
+
if (periodRelationCache.has(periodId)) {
|
|
523
|
+
return periodRelationCache.get(periodId)!;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const period = await RegistrationPeriod.getByID(periodId);
|
|
527
|
+
const relation = period
|
|
528
|
+
? BalanceItemRelation.create({
|
|
529
|
+
id: period.id,
|
|
530
|
+
name: new TranslatedString(period.getBaseStructure().name),
|
|
531
|
+
})
|
|
532
|
+
: null;
|
|
533
|
+
|
|
534
|
+
periodRelationCache.set(periodId, relation);
|
|
535
|
+
return relation;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function createBalanceItem({ registration, periodId, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; periodId: string; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
|
|
520
539
|
// NOTE: We also need to save zero-price balance items because for online payments, we need to know which registrations to activate after payment
|
|
521
540
|
if (skipZero === true) {
|
|
522
541
|
if (unitPrice === 0 || amount === 0) {
|
|
@@ -524,6 +543,12 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
524
543
|
}
|
|
525
544
|
}
|
|
526
545
|
|
|
546
|
+
// Add the registration period (working year) relation so it is visible in the item description.
|
|
547
|
+
const periodRelation = await getPeriodRelation(periodId);
|
|
548
|
+
if (periodRelation) {
|
|
549
|
+
relations.set(BalanceItemRelationType.RegistrationPeriod, periodRelation);
|
|
550
|
+
}
|
|
551
|
+
|
|
527
552
|
// Create balance item
|
|
528
553
|
const balanceItem = new BalanceItem();
|
|
529
554
|
balanceItem.registrationId = registration.id;
|
|
@@ -608,6 +633,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
608
633
|
// Base price
|
|
609
634
|
await createBalanceItem({
|
|
610
635
|
registration,
|
|
636
|
+
periodId: item.group.periodId,
|
|
611
637
|
unitPrice: item.groupPrice.price.forMember(item.member),
|
|
612
638
|
type: BalanceItemType.Registration,
|
|
613
639
|
skipZero: false, // Always create at least one balance item for each registration - even when the price is zero
|
|
@@ -621,6 +647,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
621
647
|
for (const option of item.options) {
|
|
622
648
|
await createBalanceItem({
|
|
623
649
|
registration,
|
|
650
|
+
periodId: item.group.periodId,
|
|
624
651
|
amount: option.amount,
|
|
625
652
|
unitPrice: option.option.price.forMember(item.member),
|
|
626
653
|
skipZero: true, // Do not create for zero option prices
|
|
@@ -654,6 +681,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
654
681
|
// Base price
|
|
655
682
|
await createBalanceItem({
|
|
656
683
|
registration,
|
|
684
|
+
periodId: item.group.periodId,
|
|
657
685
|
unitPrice: -discountValue,
|
|
658
686
|
type: BalanceItemType.RegistrationBundleDiscount,
|
|
659
687
|
description: discount.name,
|
|
@@ -722,6 +750,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
722
750
|
|
|
723
751
|
await createBalanceItem({
|
|
724
752
|
registration: registration.registration,
|
|
753
|
+
periodId: registration.group.periodId,
|
|
725
754
|
unitPrice: -difference,
|
|
726
755
|
type: BalanceItemType.RegistrationBundleDiscount,
|
|
727
756
|
description: discount.name,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
|
|
4
|
+
import { PayoutExportStatus, StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
|
|
5
|
+
|
|
6
|
+
type Params = Record<string, never>;
|
|
7
|
+
type Body = undefined;
|
|
8
|
+
type Query = undefined;
|
|
9
|
+
type ResponseBody = PayoutExportStatus[];
|
|
10
|
+
|
|
11
|
+
export class GetStripePayoutsExportStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
12
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
13
|
+
if (request.method !== 'GET') {
|
|
14
|
+
return [false];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const params = Endpoint.parseParameters(request.url, '/stripe/payouts/status', {});
|
|
18
|
+
|
|
19
|
+
if (params) {
|
|
20
|
+
return [true, params as Params];
|
|
21
|
+
}
|
|
22
|
+
return [false];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
26
|
+
await StripePayoutsExportEndpoint.authenticate();
|
|
27
|
+
|
|
28
|
+
return new Response(StripePayoutsExportEndpoint.queue.map((item) => {
|
|
29
|
+
return PayoutExportStatus.create(item);
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
3
|
+
import type { Organization, Token, User } from '@stamhoofd/models';
|
|
4
|
+
import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { Token as TokenModel } from '@stamhoofd/models';
|
|
6
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
7
|
+
import { Company } from '@stamhoofd/structures';
|
|
8
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
9
|
+
import nock from 'nock';
|
|
10
|
+
|
|
11
|
+
import { resetNock } from '../../../../../tests/helpers/resetNock.js';
|
|
12
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
13
|
+
import { initMembershipOrganization } from '../../../../../tests/init/initMembershipOrganization.js';
|
|
14
|
+
import { initPlatformAdmin } from '../../../../../tests/init/initPlatformAdmin.js';
|
|
15
|
+
import { GetStripePayoutsExportStatusEndpoint } from './GetStripePayoutsExportStatusEndpoint.js';
|
|
16
|
+
import { StripePayoutsExportEndpoint } from './StripePayoutsExportEndpoint.js';
|
|
17
|
+
|
|
18
|
+
describe('Endpoint.StripePayoutsExport', () => {
|
|
19
|
+
const endpoint = new StripePayoutsExportEndpoint();
|
|
20
|
+
const statusEndpoint = new GetStripePayoutsExportStatusEndpoint();
|
|
21
|
+
|
|
22
|
+
let membershipOrganization: Organization;
|
|
23
|
+
let admin: User;
|
|
24
|
+
let adminToken: Token;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
membershipOrganization = await initMembershipOrganization();
|
|
28
|
+
membershipOrganization.meta.companies = [
|
|
29
|
+
Company.create({
|
|
30
|
+
name: 'Platform BV',
|
|
31
|
+
companyNumber: '0700000000',
|
|
32
|
+
VATNumber: 'BE0700000000',
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
await membershipOrganization.save();
|
|
36
|
+
|
|
37
|
+
({ admin, adminToken } = await initPlatformAdmin());
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
resetNock();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const post = async (organization: Organization, token: Token) => {
|
|
45
|
+
const request = Request.buildJson('POST', '/stripe/payouts', organization.getApiHost(), {
|
|
46
|
+
start: new Date(2026, 2, 1).getTime(),
|
|
47
|
+
end: new Date(2026, 3, 1).getTime() - 1000,
|
|
48
|
+
});
|
|
49
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
50
|
+
return await testServer.test(endpoint, request);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getStatus = async (organization: Organization, token: Token) => {
|
|
54
|
+
const request = Request.buildJson('GET', '/stripe/payouts/status', organization.getApiHost());
|
|
55
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
56
|
+
return await testServer.test(statusEndpoint, request);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('A platform admin can export payout reports for the membership organization', async () => {
|
|
60
|
+
nock('https://api.stripe.com')
|
|
61
|
+
.get('/v1/payouts')
|
|
62
|
+
.query(true)
|
|
63
|
+
.reply(200, { object: 'list', data: [], has_more: false, url: '/v1/payouts' });
|
|
64
|
+
|
|
65
|
+
const response = await post(membershipOrganization, adminToken);
|
|
66
|
+
expect(response.status).toBe(200);
|
|
67
|
+
|
|
68
|
+
// Wait for the scheduled report to complete
|
|
69
|
+
await QueueHandler.awaitAll();
|
|
70
|
+
|
|
71
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
72
|
+
const reportEmail = emails.find(e => e.subject.startsWith('Stripe Uitbetalingen'));
|
|
73
|
+
expect(reportEmail).toBeDefined();
|
|
74
|
+
expect(reportEmail!.attachments).toHaveLength(1);
|
|
75
|
+
|
|
76
|
+
// The report is sent to the user that requested it
|
|
77
|
+
expect(reportEmail!.to).toContain(admin.email);
|
|
78
|
+
|
|
79
|
+
// The queue is empty again
|
|
80
|
+
const statusResponse = await getStatus(membershipOrganization, adminToken);
|
|
81
|
+
expect(statusResponse.body).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('A user without platform full access cannot export payout reports', async () => {
|
|
85
|
+
const user = await new UserFactory({ organization: membershipOrganization }).create();
|
|
86
|
+
const token = await TokenModel.createToken(user);
|
|
87
|
+
|
|
88
|
+
await expect(post(membershipOrganization, token)).rejects.toThrow(
|
|
89
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
90
|
+
);
|
|
91
|
+
await expect(getStatus(membershipOrganization, token)).rejects.toThrow(
|
|
92
|
+
STExpect.simpleError({ code: 'permission_denied' }),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('Payout reports are not available for other organizations', async () => {
|
|
97
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
98
|
+
|
|
99
|
+
await expect(post(otherOrganization, adminToken)).rejects.toThrow(
|
|
100
|
+
STExpect.simpleError({ code: 'not_available' }),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { AutoEncoder, DateDecoder, field, IntegerDecoder } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Platform } from '@stamhoofd/models';
|
|
7
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
8
|
+
|
|
9
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
10
|
+
import { StripePayoutReporter } from '../../../../helpers/StripePayoutReporter.js';
|
|
11
|
+
|
|
12
|
+
export class PayoutExportStatus extends AutoEncoder {
|
|
13
|
+
@field({ decoder: DateDecoder })
|
|
14
|
+
start: Date;
|
|
15
|
+
|
|
16
|
+
@field({ decoder: DateDecoder })
|
|
17
|
+
end: Date;
|
|
18
|
+
|
|
19
|
+
@field({ decoder: IntegerDecoder })
|
|
20
|
+
count = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Params = Record<string, never>;
|
|
24
|
+
class Body extends AutoEncoder {
|
|
25
|
+
@field({ decoder: DateDecoder })
|
|
26
|
+
start: Date;
|
|
27
|
+
|
|
28
|
+
@field({ decoder: DateDecoder })
|
|
29
|
+
end: Date;
|
|
30
|
+
}
|
|
31
|
+
type Query = undefined;
|
|
32
|
+
type ResponseBody = undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Manually build a Stripe payout report for a given period and email it (to check whether
|
|
36
|
+
* everything we charged via Stripe application fees was invoiced correctly).
|
|
37
|
+
*/
|
|
38
|
+
export class StripePayoutsExportEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
39
|
+
bodyDecoder = Body as Decoder<Body>;
|
|
40
|
+
|
|
41
|
+
static queue: PayoutExportStatus[] = [];
|
|
42
|
+
|
|
43
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
44
|
+
if (request.method !== 'POST') {
|
|
45
|
+
return [false];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const params = Endpoint.parseParameters(request.url, '/stripe/payouts', {});
|
|
49
|
+
|
|
50
|
+
if (params) {
|
|
51
|
+
return [true, params as Params];
|
|
52
|
+
}
|
|
53
|
+
return [false];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async authenticate() {
|
|
57
|
+
const organization = await Context.setOrganizationScope();
|
|
58
|
+
const { user } = await Context.authenticate();
|
|
59
|
+
|
|
60
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
61
|
+
throw Context.auth.error();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const platform = await Platform.getShared();
|
|
65
|
+
if (!platform.membershipOrganizationId || platform.membershipOrganizationId !== organization.id) {
|
|
66
|
+
throw new SimpleError({
|
|
67
|
+
code: 'not_available',
|
|
68
|
+
message: 'Stripe payout exports are only available for the platform membership organization',
|
|
69
|
+
statusCode: 400,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { organization, user };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
77
|
+
const { organization, user } = await StripePayoutsExportEndpoint.authenticate();
|
|
78
|
+
|
|
79
|
+
if (!STAMHOOFD.STRIPE_SECRET_KEY) {
|
|
80
|
+
throw new SimpleError({
|
|
81
|
+
code: 'not_configured',
|
|
82
|
+
message: 'Stripe is not configured',
|
|
83
|
+
statusCode: 400,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const secretKey = STAMHOOFD.STRIPE_SECRET_KEY;
|
|
87
|
+
|
|
88
|
+
const start = request.body.start;
|
|
89
|
+
const end = request.body.end;
|
|
90
|
+
|
|
91
|
+
const item = PayoutExportStatus.create({
|
|
92
|
+
start,
|
|
93
|
+
end,
|
|
94
|
+
count: 0,
|
|
95
|
+
});
|
|
96
|
+
StripePayoutsExportEndpoint.queue.push(item);
|
|
97
|
+
|
|
98
|
+
// Schedule
|
|
99
|
+
QueueHandler.schedule('stripe-payout-export', async () => {
|
|
100
|
+
try {
|
|
101
|
+
const reporter = new StripePayoutReporter({
|
|
102
|
+
secretKey,
|
|
103
|
+
sellingOrganization: organization,
|
|
104
|
+
});
|
|
105
|
+
let count = 0;
|
|
106
|
+
reporter.callback = () => {
|
|
107
|
+
count++;
|
|
108
|
+
item.count = count;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await reporter.build(start, end);
|
|
112
|
+
|
|
113
|
+
// Send the report to the user that requested it
|
|
114
|
+
await reporter.sendEmail({
|
|
115
|
+
to: [{ email: user.email, name: user.name }],
|
|
116
|
+
});
|
|
117
|
+
} finally {
|
|
118
|
+
// Remove from queue
|
|
119
|
+
StripePayoutsExportEndpoint.queue.splice(StripePayoutsExportEndpoint.queue.indexOf(item), 1);
|
|
120
|
+
}
|
|
121
|
+
}).catch(console.error);
|
|
122
|
+
|
|
123
|
+
return new Response(undefined);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
-
import { BalanceItem, Member, MemberPlatformMembership, Platform } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItem, Member, MemberPlatformMembership, Platform, RegistrationPeriod } from '@stamhoofd/models';
|
|
3
3
|
import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
|
|
4
4
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
5
5
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -25,6 +25,25 @@ export const MembershipCharger = {
|
|
|
25
25
|
return platform.config.membershipTypes.find(t => t.id === id);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Cache the registration period relation per periodId, so we only load each period once.
|
|
29
|
+
const periodRelationCache = new Map<string, BalanceItemRelation | null>();
|
|
30
|
+
async function getPeriodRelation(periodId: string): Promise<BalanceItemRelation | null> {
|
|
31
|
+
if (periodRelationCache.has(periodId)) {
|
|
32
|
+
return periodRelationCache.get(periodId)!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const period = await RegistrationPeriod.getByID(periodId);
|
|
36
|
+
const relation = period
|
|
37
|
+
? BalanceItemRelation.create({
|
|
38
|
+
id: period.id,
|
|
39
|
+
name: new TranslatedString(period.getBaseStructure().name),
|
|
40
|
+
})
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
periodRelationCache.set(periodId, relation);
|
|
44
|
+
return relation;
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
let createdCount = 0;
|
|
29
48
|
let createdPrice = 0;
|
|
30
49
|
const chunkSize = 100;
|
|
@@ -114,6 +133,12 @@ export const MembershipCharger = {
|
|
|
114
133
|
],
|
|
115
134
|
]);
|
|
116
135
|
|
|
136
|
+
// Add the registration period (working year) relation so it is visible in the item description.
|
|
137
|
+
const periodRelation = await getPeriodRelation(membership.periodId);
|
|
138
|
+
if (periodRelation) {
|
|
139
|
+
balanceItem.relations.set(BalanceItemRelationType.RegistrationPeriod, periodRelation);
|
|
140
|
+
}
|
|
141
|
+
|
|
117
142
|
balanceItem.type = BalanceItemType.PlatformMembership;
|
|
118
143
|
balanceItem.organizationId = chargeVia;
|
|
119
144
|
balanceItem.payingOrganizationId = membership.organizationId;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { RegistrationPeriod } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItem, BalanceItemFactory, GroupFactory, MemberFactory, MemberPlatformMembership, OrganizationFactory, OrganizationRegistrationPeriodFactory, RegistrationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import { backfillRegistrationPeriodRelations } from './RegistrationPeriodRelationBackfiller.js';
|
|
7
|
+
|
|
8
|
+
describe('helper.RegistrationPeriodRelationBackfiller', () => {
|
|
9
|
+
let period: RegistrationPeriod;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
period = await new RegistrationPeriodFactory({
|
|
13
|
+
startDate: new Date(2024, 0, 1),
|
|
14
|
+
endDate: new Date(2025, 11, 31),
|
|
15
|
+
}).create();
|
|
16
|
+
|
|
17
|
+
// A deterministic name so we can assert on it.
|
|
18
|
+
period.customName = 'Werkjaar 2024-2025';
|
|
19
|
+
await period.save();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('fills the registration period relation for registration balance items', async () => {
|
|
27
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
28
|
+
await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
|
|
29
|
+
|
|
30
|
+
const member = await new MemberFactory({ organization }).create();
|
|
31
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
32
|
+
const registration = await new RegistrationFactory({ member, group }).create();
|
|
33
|
+
|
|
34
|
+
const balanceItem = await new BalanceItemFactory({
|
|
35
|
+
organizationId: organization.id,
|
|
36
|
+
memberId: member.id,
|
|
37
|
+
registrationId: registration.id,
|
|
38
|
+
type: BalanceItemType.Registration,
|
|
39
|
+
amount: 1,
|
|
40
|
+
unitPrice: 25_00,
|
|
41
|
+
}).create();
|
|
42
|
+
|
|
43
|
+
expect(balanceItem.relations.has(BalanceItemRelationType.RegistrationPeriod)).toBe(false);
|
|
44
|
+
|
|
45
|
+
await backfillRegistrationPeriodRelations();
|
|
46
|
+
|
|
47
|
+
const updated = await BalanceItem.getByID(balanceItem.id);
|
|
48
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)).toMatchObject({ id: period.id });
|
|
49
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)?.name.toString()).toBe('Werkjaar 2024-2025');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('fills the registration period relation for platform membership balance items', async () => {
|
|
53
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
54
|
+
const member = await new MemberFactory({ organization }).create();
|
|
55
|
+
|
|
56
|
+
const balanceItem = await new BalanceItemFactory({
|
|
57
|
+
organizationId: organization.id,
|
|
58
|
+
memberId: member.id,
|
|
59
|
+
type: BalanceItemType.PlatformMembership,
|
|
60
|
+
amount: 1,
|
|
61
|
+
unitPrice: 15_00,
|
|
62
|
+
}).create();
|
|
63
|
+
|
|
64
|
+
const membership = new MemberPlatformMembership();
|
|
65
|
+
membership.memberId = member.id;
|
|
66
|
+
membership.membershipTypeId = uuidv4();
|
|
67
|
+
membership.organizationId = organization.id;
|
|
68
|
+
membership.periodId = period.id;
|
|
69
|
+
membership.startDate = new Date(2024, 0, 1);
|
|
70
|
+
membership.endDate = new Date(2025, 11, 31);
|
|
71
|
+
membership.balanceItemId = balanceItem.id;
|
|
72
|
+
await membership.save();
|
|
73
|
+
|
|
74
|
+
await backfillRegistrationPeriodRelations();
|
|
75
|
+
|
|
76
|
+
const updated = await BalanceItem.getByID(balanceItem.id);
|
|
77
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)).toMatchObject({ id: period.id });
|
|
78
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)?.name.toString()).toBe('Werkjaar 2024-2025');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not overwrite an existing registration period relation', async () => {
|
|
82
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
83
|
+
await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
|
|
84
|
+
|
|
85
|
+
const member = await new MemberFactory({ organization }).create();
|
|
86
|
+
const group = await new GroupFactory({ organization, period }).create();
|
|
87
|
+
const registration = await new RegistrationFactory({ member, group }).create();
|
|
88
|
+
|
|
89
|
+
const existingRelations = new Map([
|
|
90
|
+
[
|
|
91
|
+
BalanceItemRelationType.RegistrationPeriod,
|
|
92
|
+
BalanceItemRelation.create({ id: period.id, name: new TranslatedString('Custom period name') }),
|
|
93
|
+
],
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const balanceItem = await new BalanceItemFactory({
|
|
97
|
+
organizationId: organization.id,
|
|
98
|
+
memberId: member.id,
|
|
99
|
+
registrationId: registration.id,
|
|
100
|
+
type: BalanceItemType.Registration,
|
|
101
|
+
amount: 1,
|
|
102
|
+
unitPrice: 25_00,
|
|
103
|
+
relations: existingRelations,
|
|
104
|
+
}).create();
|
|
105
|
+
|
|
106
|
+
await backfillRegistrationPeriodRelations();
|
|
107
|
+
|
|
108
|
+
const updated = await BalanceItem.getByID(balanceItem.id);
|
|
109
|
+
expect(updated?.relations.get(BalanceItemRelationType.RegistrationPeriod)?.name.toString()).toBe('Custom period name');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { BalanceItem, MemberPlatformMembership, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
import { SeedTools } from './SeedTools.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backfills the `RegistrationPeriod` relation on existing balance items.
|
|
8
|
+
*
|
|
9
|
+
* The relation contains the name of the registration period (working year) connected to a registration
|
|
10
|
+
* (or its options / bundle discount) and to a platform membership. It was added after these balance items
|
|
11
|
+
* were created, so we reconstruct it from the linked registration or membership.
|
|
12
|
+
*
|
|
13
|
+
* Only balance items that don't have the relation yet are touched, so the migration is idempotent and never
|
|
14
|
+
* overwrites relations that were already set by the current flows.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Loads the registration period relation once per periodId.
|
|
19
|
+
*/
|
|
20
|
+
class PeriodRelationCache {
|
|
21
|
+
private cache = new Map<string, BalanceItemRelation | null>();
|
|
22
|
+
|
|
23
|
+
async get(periodId: string): Promise<BalanceItemRelation | null> {
|
|
24
|
+
if (this.cache.has(periodId)) {
|
|
25
|
+
return this.cache.get(periodId)!;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const period = await RegistrationPeriod.getByID(periodId);
|
|
29
|
+
const relation = period
|
|
30
|
+
? BalanceItemRelation.create({
|
|
31
|
+
id: period.id,
|
|
32
|
+
name: new TranslatedString(period.getBaseStructure().name),
|
|
33
|
+
})
|
|
34
|
+
: null;
|
|
35
|
+
|
|
36
|
+
this.cache.set(periodId, relation);
|
|
37
|
+
return relation;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function backfillRegistrationBalanceItems(cache: PeriodRelationCache): Promise<number> {
|
|
42
|
+
let updated = 0;
|
|
43
|
+
|
|
44
|
+
await SeedTools.loopBatched({
|
|
45
|
+
batchSize: 100,
|
|
46
|
+
query: BalanceItem.select().whereNot('registrationId', null),
|
|
47
|
+
batchAction: async (items: BalanceItem[]) => {
|
|
48
|
+
const registrationIds = Formatter.uniqueArray(items.map(i => i.registrationId!).filter(id => !!id));
|
|
49
|
+
const registrations = await Registration.getByIDs(...registrationIds);
|
|
50
|
+
const registrationMap = new Map(registrations.map(r => [r.id, r]));
|
|
51
|
+
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
// Never overwrite a relation that is already set.
|
|
54
|
+
if (item.relations.has(BalanceItemRelationType.RegistrationPeriod)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const registration = registrationMap.get(item.registrationId!);
|
|
59
|
+
if (!registration) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const relation = await cache.get(registration.periodId);
|
|
64
|
+
if (!relation) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
item.relations.set(BalanceItemRelationType.RegistrationPeriod, relation);
|
|
69
|
+
await item.save();
|
|
70
|
+
updated += 1;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return updated;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function backfillPlatformMembershipBalanceItems(cache: PeriodRelationCache): Promise<number> {
|
|
79
|
+
let updated = 0;
|
|
80
|
+
|
|
81
|
+
await SeedTools.loopBatched({
|
|
82
|
+
batchSize: 100,
|
|
83
|
+
query: BalanceItem.select().where('type', BalanceItemType.PlatformMembership),
|
|
84
|
+
batchAction: async (items: BalanceItem[]) => {
|
|
85
|
+
const balanceItemIds = items.map(i => i.id);
|
|
86
|
+
const memberships = await MemberPlatformMembership.select()
|
|
87
|
+
.where('balanceItemId', balanceItemIds)
|
|
88
|
+
.limit(balanceItemIds.length)
|
|
89
|
+
.fetch();
|
|
90
|
+
const membershipByBalanceItemId = new Map(memberships.filter(m => m.balanceItemId).map(m => [m.balanceItemId!, m]));
|
|
91
|
+
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
if (item.relations.has(BalanceItemRelationType.RegistrationPeriod)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const membership = membershipByBalanceItemId.get(item.id);
|
|
98
|
+
if (!membership) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const relation = await cache.get(membership.periodId);
|
|
103
|
+
if (!relation) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
item.relations.set(BalanceItemRelationType.RegistrationPeriod, relation);
|
|
108
|
+
await item.save();
|
|
109
|
+
updated += 1;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function backfillRegistrationPeriodRelations(): Promise<{ registrations: number; memberships: number }> {
|
|
118
|
+
const cache = new PeriodRelationCache();
|
|
119
|
+
const registrations = await backfillRegistrationBalanceItems(cache);
|
|
120
|
+
const memberships = await backfillPlatformMembershipBalanceItems(cache);
|
|
121
|
+
|
|
122
|
+
console.log(`[RegistrationPeriodRelationBackfiller] Updated ${registrations} registration balance items and ${memberships} platform membership balance items`);
|
|
123
|
+
|
|
124
|
+
return { registrations, memberships };
|
|
125
|
+
}
|