@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.
Files changed (35) hide show
  1. package/package.json +17 -17
  2. package/src/crons/index.ts +1 -0
  3. package/src/crons/stripe-payout-reports.ts +69 -0
  4. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  6. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  8. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  10. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +43 -2
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  12. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  13. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  14. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  15. package/src/helpers/MembershipCharger.ts +26 -1
  16. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  17. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  18. package/src/helpers/StripePayoutExportData.ts +195 -0
  19. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  20. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  21. package/src/helpers/StripePayoutReporter.ts +585 -0
  22. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  23. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  24. package/src/services/uitpas/UitpasService.ts +1 -1
  25. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  26. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  27. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  28. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  29. package/src/services/uitpas/registerTicketSales.ts +1 -1
  30. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  31. package/tests/e2e/documents.test.ts +3 -1
  32. package/tests/vitest.global.setup.ts +1 -1
  33. package/tests/vitest.setup.ts +4 -2
  34. package/vitest.config.js +10 -0
  35. /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
- async function createBalanceItem({ registration, skipZero, amount, unitPrice, description, type, relations }: { amount?: number; skipZero?: boolean; registration: { id: string; payingOrganizationId: string | null; memberId: string; trialUntil: Date | null }; unitPrice: number; description: string; relations: Map<BalanceItemRelationType, BalanceItemRelation>; type: BalanceItemType }) {
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
+ }