@stamhoofd/backend 2.122.7 → 2.122.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.122.7",
3
+ "version": "2.122.9",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.122.7",
61
- "@stamhoofd/backend-i18n": "2.122.7",
62
- "@stamhoofd/backend-middleware": "2.122.7",
63
- "@stamhoofd/crons": "2.122.7",
64
- "@stamhoofd/email": "2.122.7",
65
- "@stamhoofd/excel-writer": "2.122.7",
66
- "@stamhoofd/logging": "2.122.7",
67
- "@stamhoofd/models": "2.122.7",
68
- "@stamhoofd/object-differ": "2.122.7",
69
- "@stamhoofd/queues": "2.122.7",
70
- "@stamhoofd/sql": "2.122.7",
71
- "@stamhoofd/structures": "2.122.7",
72
- "@stamhoofd/types": "2.122.7",
73
- "@stamhoofd/utility": "2.122.7",
60
+ "@stamhoofd/backend-env": "2.122.9",
61
+ "@stamhoofd/backend-i18n": "2.122.9",
62
+ "@stamhoofd/backend-middleware": "2.122.9",
63
+ "@stamhoofd/crons": "2.122.9",
64
+ "@stamhoofd/email": "2.122.9",
65
+ "@stamhoofd/excel-writer": "2.122.9",
66
+ "@stamhoofd/logging": "2.122.9",
67
+ "@stamhoofd/models": "2.122.9",
68
+ "@stamhoofd/object-differ": "2.122.9",
69
+ "@stamhoofd/queues": "2.122.9",
70
+ "@stamhoofd/sql": "2.122.9",
71
+ "@stamhoofd/structures": "2.122.9",
72
+ "@stamhoofd/types": "2.122.9",
73
+ "@stamhoofd/utility": "2.122.9",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.122.7",
94
+ "@stamhoofd/test-utils": "2.122.9",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "2fcad274db9243e6eea1ab70a63046b949608e2a"
110
+ "gitHead": "f3f81008f51b26e7eb368643a05995f70cfad327"
111
111
  }
@@ -1,7 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization, OrganizationRegistrationPeriod, RegistrationPeriod } from '@stamhoofd/models';
3
3
  import { GroupFactory, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
4
- import { Group, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
4
+ import { Group, GroupCategory, GroupCategorySettings, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, OrganizationRegistrationPeriodSettings, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
5
5
  import { PatchOrganizationRegistrationPeriodsEndpoint } from './PatchOrganizationRegistrationPeriodsEndpoint.js';
6
6
 
7
7
  import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
@@ -486,3 +486,228 @@ describe('Endpoint.PatchOrganizationRegistrationPeriodsEndpoint.MoveRegistration
486
486
  });
487
487
  });
488
488
  });
489
+
490
+ describe('Endpoint.PatchOrganizationRegistrationPeriodsEndpoint.MoveCategoryToPeriod', () => {
491
+ let organization: Organization;
492
+ let token: Token;
493
+
494
+ beforeEach(async () => {
495
+ TestUtils.setEnvironment('userMode', 'platform');
496
+ });
497
+
498
+ beforeAll(async () => {
499
+ TestUtils.setEnvironment('userMode', 'platform');
500
+ const period = await new RegistrationPeriodFactory({}).create();
501
+ organization = await new OrganizationFactory({ period }).create();
502
+
503
+ const user = await new UserFactory({
504
+ organization,
505
+ permissions: Permissions.create({
506
+ level: PermissionLevel.Full,
507
+ }),
508
+ }).create();
509
+ token = await Token.createToken(user);
510
+ });
511
+
512
+ const patchOrganizationRegistrationPeriods = async ({ patch }: { patch: PatchableArrayAutoEncoder<OrganizationRegistrationPeriodStruct> }) => {
513
+ const request = Request.buildJson('PATCH', `/v${Version}/organization/registration-periods`, organization.getApiHost(), patch);
514
+ request.headers.authorization = 'Bearer ' + token.accessToken;
515
+ return await testServer.test(endpoint, request);
516
+ };
517
+
518
+ /**
519
+ * Creates an organization period with a single top-level category (under the root) holding the given groups.
520
+ */
521
+ const createPeriodWithCategory = async (groupIds: string[]) => {
522
+ const period = await new RegistrationPeriodFactory({}).create();
523
+ const organizationPeriod = await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
524
+
525
+ const category = GroupCategory.create({
526
+ settings: GroupCategorySettings.create({ name: 'Movable category' }),
527
+ groupIds,
528
+ });
529
+
530
+ const root = organizationPeriod.settings.rootCategory!;
531
+ root.categoryIds = [category.id];
532
+ organizationPeriod.settings.categories = [root, category];
533
+ await organizationPeriod.save();
534
+
535
+ return { period, organizationPeriod, category };
536
+ };
537
+
538
+ /**
539
+ * Builds the patch the frontend (useGroupCategoryActions.moveToOtherPeriod) sends:
540
+ * the category and all its descendants are added to the destination, the groups are moved,
541
+ * and the category is removed from its parent in the source period.
542
+ */
543
+ const buildMovePatch = ({ source, destination, categories, topCategoryId, groupIds, destinationPeriodId }: {
544
+ source: OrganizationRegistrationPeriod;
545
+ destination: OrganizationRegistrationPeriod;
546
+ categories: GroupCategory[];
547
+ topCategoryId: string;
548
+ groupIds: string[];
549
+ destinationPeriodId: string;
550
+ }) => {
551
+ const body = new PatchableArray() as PatchableArrayAutoEncoder<OrganizationRegistrationPeriodStruct>;
552
+
553
+ const destinationSettings = OrganizationRegistrationPeriodSettings.patch({});
554
+ for (const category of categories) {
555
+ destinationSettings.categories.addPut(category);
556
+ }
557
+ const destinationParent = GroupCategory.patch({ id: destination.settings.rootCategoryId });
558
+ destinationParent.categoryIds.addPut(topCategoryId);
559
+ destinationSettings.categories.addPatch(destinationParent);
560
+
561
+ const destinationPatch = OrganizationRegistrationPeriodStruct.patch({ id: destination.id, settings: destinationSettings });
562
+ for (const groupId of groupIds) {
563
+ destinationPatch.groups.addPatch(Group.patch({ id: groupId, periodId: destinationPeriodId }));
564
+ }
565
+
566
+ const sourceSettings = OrganizationRegistrationPeriodSettings.patch({});
567
+ const sourceParent = GroupCategory.patch({ id: source.settings.rootCategoryId });
568
+ sourceParent.categoryIds.addDelete(topCategoryId);
569
+ sourceSettings.categories.addPatch(sourceParent);
570
+ const sourcePatch = OrganizationRegistrationPeriodStruct.patch({ id: source.id, settings: sourceSettings });
571
+
572
+ // Order is important: the destination patch moves the groups first, then the source is cleaned up
573
+ body.addPatch(destinationPatch);
574
+ body.addPatch(sourcePatch);
575
+
576
+ return body;
577
+ };
578
+
579
+ test('Moving a category with a group to another period', async () => {
580
+ const group = await new GroupFactory({ organization }).create();
581
+ const member = await new MemberFactory({ organization }).create();
582
+ const registration = await new RegistrationFactory({ group, member }).create();
583
+
584
+ const { organizationPeriod: source, category } = await createPeriodWithCategory([group.id]);
585
+
586
+ const destinationPeriod = await new RegistrationPeriodFactory({}).create();
587
+ const destination = await new OrganizationRegistrationPeriodFactory({ organization, period: destinationPeriod }).create();
588
+
589
+ const body = buildMovePatch({
590
+ source,
591
+ destination,
592
+ categories: [category],
593
+ topCategoryId: category.id,
594
+ groupIds: [group.id],
595
+ destinationPeriodId: destinationPeriod.id,
596
+ });
597
+
598
+ const result = await patchOrganizationRegistrationPeriods({ patch: body });
599
+
600
+ const destinationResult = result.body.find(p => p.id === destination.id)!;
601
+ const sourceResult = result.body.find(p => p.id === source.id)!;
602
+
603
+ // Category and group are now in the destination period
604
+ expect(destinationResult.settings.categories.find(c => c.id === category.id)).toBeDefined();
605
+ expect(destinationResult.settings.rootCategory?.categoryIds).toContain(category.id);
606
+ const movedGroup = destinationResult.groups.find(g => g.id === group.id);
607
+ expect(movedGroup).toBeDefined();
608
+ expect(movedGroup!.periodId).toBe(destinationPeriod.id);
609
+
610
+ // Category and group are removed from the source period
611
+ expect(sourceResult.settings.categories.find(c => c.id === category.id)).toBeUndefined();
612
+ expect(sourceResult.settings.rootCategory?.categoryIds ?? []).not.toContain(category.id);
613
+ expect(sourceResult.groups.find(g => g.id === group.id)).toBeUndefined();
614
+
615
+ // The registration in the group is also moved to the destination period
616
+ const updatedRegistration = await Registration.getByID(registration.id);
617
+ expect(updatedRegistration!.periodId).toBe(destinationPeriod.id);
618
+ });
619
+
620
+ test('Moving a category also moves its nested subcategories', async () => {
621
+ const period = await new RegistrationPeriodFactory({}).create();
622
+ const source = await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
623
+ const group = await new GroupFactory({ organization, period }).create();
624
+
625
+ // root -> parent -> child(group)
626
+ const child = GroupCategory.create({
627
+ settings: GroupCategorySettings.create({ name: 'Child' }),
628
+ groupIds: [group.id],
629
+ });
630
+ const parent = GroupCategory.create({
631
+ settings: GroupCategorySettings.create({ name: 'Parent' }),
632
+ categoryIds: [child.id],
633
+ });
634
+ const root = source.settings.rootCategory!;
635
+ root.categoryIds = [parent.id];
636
+ source.settings.categories = [root, parent, child];
637
+ await source.save();
638
+
639
+ const destinationPeriod = await new RegistrationPeriodFactory({}).create();
640
+ const destination = await new OrganizationRegistrationPeriodFactory({ organization, period: destinationPeriod }).create();
641
+
642
+ const body = buildMovePatch({
643
+ source,
644
+ destination,
645
+ categories: [parent, child],
646
+ topCategoryId: parent.id,
647
+ groupIds: [group.id],
648
+ destinationPeriodId: destinationPeriod.id,
649
+ });
650
+
651
+ const result = await patchOrganizationRegistrationPeriods({ patch: body });
652
+
653
+ const destinationResult = result.body.find(p => p.id === destination.id)!;
654
+ const sourceResult = result.body.find(p => p.id === source.id)!;
655
+
656
+ // The whole subtree is now in the destination period
657
+ expect(destinationResult.settings.rootCategory?.categoryIds).toContain(parent.id);
658
+ expect(destinationResult.settings.categories.find(c => c.id === parent.id)?.categoryIds).toContain(child.id);
659
+ expect(destinationResult.settings.categories.find(c => c.id === child.id)?.groupIds).toContain(group.id);
660
+ expect(destinationResult.groups.find(g => g.id === group.id)?.periodId).toBe(destinationPeriod.id);
661
+
662
+ // Nothing is left behind in the source period
663
+ expect(sourceResult.settings.categories.find(c => c.id === parent.id)).toBeUndefined();
664
+ expect(sourceResult.settings.categories.find(c => c.id === child.id)).toBeUndefined();
665
+ expect(sourceResult.groups.find(g => g.id === group.id)).toBeUndefined();
666
+ });
667
+
668
+ test('Moving towards a locked period is blocked', async () => {
669
+ const period = await new RegistrationPeriodFactory({}).create();
670
+ const source = await new OrganizationRegistrationPeriodFactory({ organization, period }).create();
671
+ const group = await new GroupFactory({ organization, period }).create();
672
+
673
+ // root -> parent -> child(group)
674
+ const child = GroupCategory.create({
675
+ settings: GroupCategorySettings.create({ name: 'Child' }),
676
+ groupIds: [group.id],
677
+ });
678
+ const parent = GroupCategory.create({
679
+ settings: GroupCategorySettings.create({ name: 'Parent' }),
680
+ categoryIds: [child.id],
681
+ });
682
+ const root = source.settings.rootCategory!;
683
+ root.categoryIds = [parent.id];
684
+ source.settings.categories = [root, parent, child];
685
+ await source.save();
686
+
687
+ const destinationPeriod = await new RegistrationPeriodFactory({}).create();
688
+
689
+ const destination = await new OrganizationRegistrationPeriodFactory({ organization, period: destinationPeriod }).create();
690
+
691
+ const body = buildMovePatch({
692
+ source,
693
+ destination,
694
+ categories: [parent, child],
695
+ topCategoryId: parent.id,
696
+ groupIds: [group.id],
697
+ destinationPeriodId: destinationPeriod.id,
698
+ });
699
+ destinationPeriod.locked = true;
700
+ await destinationPeriod.save();
701
+ expect(group.periodId).toEqual(period.id);
702
+
703
+ await expect(patchOrganizationRegistrationPeriods({ patch: body })).rejects.toThrow(STExpect.simpleError({
704
+ code: 'locked_period',
705
+ }));
706
+
707
+ // Check nothing moved
708
+ await group.refresh();
709
+ await source.refresh();
710
+ expect(group.periodId).toEqual(period.id);
711
+ expect(source.settings.categories).toEqual([root, parent, child]);
712
+ });
713
+ });
@@ -209,6 +209,14 @@ export class AdminPermissionChecker {
209
209
  return this.user.permissions.organizationPermissions.get(typeof organizationOrId === 'string' ? organizationOrId : organizationOrId.id) ?? null;
210
210
  }
211
211
 
212
+ getUnloadedPlatformPermissions() {
213
+ if (!this.user.permissions) {
214
+ return null;
215
+ }
216
+
217
+ return this.user.permissions.globalPermissions;
218
+ }
219
+
212
220
  async canAccessPrivateOrganizationData(organization: Organization) {
213
221
  if (!this.checkScope(organization.id)) {
214
222
  return false;
@@ -232,7 +240,7 @@ export class AdminPermissionChecker {
232
240
  return false;
233
241
  }
234
242
 
235
- if (!await this.hasSomeUnloadedAccess(organization)) {
243
+ if (!await this.hasSomeUnloadedAccess(organization) && !this.hasSomeUnloadedPlatformAccess()) {
236
244
  return false;
237
245
  }
238
246
  return true;
@@ -1130,6 +1138,11 @@ export class AdminPermissionChecker {
1130
1138
  return !!unloadedPermissions && !unloadedPermissions.isEmpty;
1131
1139
  }
1132
1140
 
1141
+ hasSomeUnloadedPlatformAccess(): boolean {
1142
+ const unloadedPermissions = this.getUnloadedPlatformPermissions();
1143
+ return !!unloadedPermissions && !unloadedPermissions.isEmpty;
1144
+ }
1145
+
1133
1146
  async canManageAdmins(organizationId: string) {
1134
1147
  return !this.user.isApiUser && (await this.hasFullAccess(organizationId));
1135
1148
  }
@@ -0,0 +1,257 @@
1
+ import { Address, Company } from '@stamhoofd/structures';
2
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
3
+ import { Country } from '@stamhoofd/types/Country';
4
+ import nock from 'nock';
5
+ import { ViesHelper } from './ViesHelper.js';
6
+
7
+ const VIES_HOST = 'https://ec.europa.eu';
8
+ const VIES_PATH = '/taxation_customs/vies/rest-api/check-vat-number';
9
+
10
+ describe('ViesHelper', () => {
11
+ /**
12
+ * Registers a mock for the (only) external dependency: the VIES check-vat-number endpoint.
13
+ * Returns the nock scope so tests can assert whether it was actually called.
14
+ */
15
+ function mockVies(response: unknown, statusCode = 200) {
16
+ return nock(VIES_HOST).post(VIES_PATH).reply(statusCode, response as nock.Body);
17
+ }
18
+
19
+ /**
20
+ * Captures the JSON body that was sent to the VIES endpoint, so we can assert
21
+ * what we send without depending on the internals of the helper.
22
+ */
23
+ function mockViesCapturingBody(response: unknown, statusCode = 200): { getBody: () => any } {
24
+ let body: any;
25
+ nock(VIES_HOST)
26
+ .post(VIES_PATH, (requestBody) => {
27
+ body = requestBody;
28
+ return true;
29
+ })
30
+ .reply(statusCode, response as nock.Body);
31
+ return { getBody: () => body };
32
+ }
33
+
34
+ afterEach(() => {
35
+ nock.cleanAll();
36
+ nock.disableNetConnect();
37
+ });
38
+
39
+ describe('checkVATNumber', () => {
40
+ describe('rejects invalid numbers before contacting the API', () => {
41
+ test('an invalid Belgian VAT number is rejected without a request', async () => {
42
+ const scope = mockVies({ valid: true });
43
+
44
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0123')).rejects.toThrow(
45
+ STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber' }),
46
+ );
47
+
48
+ // The VIES endpoint must not have been called.
49
+ expect(scope.isDone()).toBe(false);
50
+ });
51
+
52
+ test('an invalid Dutch VAT number is rejected without a request', async () => {
53
+ const scope = mockVies({ valid: true });
54
+
55
+ await expect(ViesHelper.checkVATNumber(Country.Netherlands, 'NL123456789B01')).rejects.toThrow(
56
+ STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber' }),
57
+ );
58
+
59
+ expect(scope.isDone()).toBe(false);
60
+ });
61
+
62
+ test('garbage input is rejected without a request', async () => {
63
+ const scope = mockVies({ valid: true });
64
+
65
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'not-a-vat-number')).rejects.toThrow(
66
+ STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber' }),
67
+ );
68
+
69
+ expect(scope.isDone()).toBe(false);
70
+ });
71
+
72
+ test('the error message contains the rejected number', async () => {
73
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0123')).rejects.toThrow(
74
+ STExpect.simpleError({ code: 'invalid_field', field: 'VATNumber', message: /Ongeldig BTW-nummer.*BE0123/ }),
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('parses the API response', () => {
80
+ test('returns the formatted number when VIES confirms it is valid', async () => {
81
+ const scope = mockVies({ valid: true });
82
+
83
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).resolves.toBe('BE0411905847');
84
+
85
+ // The number was actually validated against VIES.
86
+ expect(scope.isDone()).toBe(true);
87
+ });
88
+
89
+ test('sends the country code and the cleaned number (without country prefix) to VIES', async () => {
90
+ const mock = mockViesCapturingBody({ valid: true });
91
+
92
+ await ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847');
93
+
94
+ expect(mock.getBody()).toEqual({
95
+ countryCode: 'BE',
96
+ vatNumber: '0411905847',
97
+ });
98
+ });
99
+
100
+ test('validates Dutch numbers against VIES as well', async () => {
101
+ const mock = mockViesCapturingBody({ valid: true });
102
+
103
+ await expect(ViesHelper.checkVATNumber(Country.Netherlands, 'NL301828519B01')).resolves.toBe('NL301828519B01');
104
+
105
+ expect(mock.getBody()).toEqual({
106
+ countryCode: 'NL',
107
+ vatNumber: '301828519B01',
108
+ });
109
+ });
110
+
111
+ test('throws an invalid_field error when VIES reports the number as not valid', async () => {
112
+ mockVies({ valid: false });
113
+
114
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
115
+ STExpect.simpleError({
116
+ code: 'invalid_field',
117
+ field: 'VATNumber',
118
+ message: /BTW-plichtig.*BE0411905847/,
119
+ }),
120
+ );
121
+ });
122
+
123
+ test('throws service_unavailable when VIES returns a response without a valid boolean', async () => {
124
+ mockVies({ somethingElse: true });
125
+
126
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
127
+ STExpect.simpleError({ code: 'service_unavailable', field: 'VATNumber' }),
128
+ );
129
+ });
130
+
131
+ test('throws service_unavailable when VIES returns a non-object response', async () => {
132
+ mockVies('"plain string"');
133
+
134
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
135
+ STExpect.simpleError({ code: 'service_unavailable', field: 'VATNumber' }),
136
+ );
137
+ });
138
+
139
+ test('throws service_unavailable when the VIES endpoint errors out', async () => {
140
+ mockVies({ message: 'Internal Server Error' }, 500);
141
+
142
+ await expect(ViesHelper.checkVATNumber(Country.Belgium, 'BE0411905847')).rejects.toThrow(
143
+ STExpect.simpleError({ code: 'service_unavailable', field: 'VATNumber' }),
144
+ );
145
+ });
146
+ });
147
+
148
+ test('skips the API in development for the known development number', async () => {
149
+ TestUtils.setEnvironment('environment', 'development');
150
+ const scope = mockVies({ valid: false });
151
+
152
+ await expect(ViesHelper.checkVATNumber(Country.Netherlands, 'NL301828519B01')).resolves.toBe('NL301828519B01');
153
+
154
+ // No request was made, even though VIES would have reported it invalid.
155
+ expect(scope.isDone()).toBe(false);
156
+ });
157
+ });
158
+
159
+ describe('checkCompanyNumber', () => {
160
+ test('returns the number unchanged for non-Belgian countries without contacting the API', async () => {
161
+ const scope = mockVies({ valid: true });
162
+
163
+ const result = await ViesHelper.checkCompanyNumber(Country.Netherlands, '12345678');
164
+
165
+ expect(result).toEqual({ companyNumber: '12345678' });
166
+ expect(scope.isDone()).toBe(false);
167
+ });
168
+
169
+ test('rejects an invalid Belgian company number without contacting the API', async () => {
170
+ const scope = mockVies({ valid: true });
171
+
172
+ await expect(ViesHelper.checkCompanyNumber(Country.Belgium, '123')).rejects.toThrow(
173
+ STExpect.simpleError({ code: 'invalid_field', field: 'companyNumber', message: /Ongeldig ondernemingsnummer: 123/ }),
174
+ );
175
+
176
+ expect(scope.isDone()).toBe(false);
177
+ });
178
+
179
+ test('promotes a valid Belgian company number to a VAT number when VIES confirms it', async () => {
180
+ mockVies({ valid: true });
181
+
182
+ const result = await ViesHelper.checkCompanyNumber(Country.Belgium, '0411905847');
183
+
184
+ expect(result).toEqual({
185
+ companyNumber: '0411905847',
186
+ VATNumber: 'BE0411905847',
187
+ });
188
+ });
189
+
190
+ test('keeps the company number but clears the VAT number when VIES does not recognise it', async () => {
191
+ mockVies({ valid: false });
192
+
193
+ const result = await ViesHelper.checkCompanyNumber(Country.Belgium, '0411905847');
194
+
195
+ expect(result).toEqual({
196
+ companyNumber: '0411905847',
197
+ VATNumber: null,
198
+ });
199
+ });
200
+ });
201
+
202
+ describe('checkCompany', () => {
203
+ const belgianAddress = () => Address.create({
204
+ street: 'Demostraat',
205
+ number: '12',
206
+ city: 'Gent',
207
+ postalCode: '9000',
208
+ country: Country.Belgium,
209
+ });
210
+
211
+ test('clears both numbers when the company has no address, without contacting the API', async () => {
212
+ const scope = mockVies({ valid: true });
213
+
214
+ const company = Company.create({
215
+ name: 'Demo Company',
216
+ VATNumber: 'BE0411905847',
217
+ companyNumber: '0411905847',
218
+ address: null,
219
+ });
220
+ const patch = Company.create({});
221
+
222
+ await ViesHelper.checkCompany(company, patch);
223
+
224
+ expect(patch.VATNumber).toBeNull();
225
+ expect(patch.companyNumber).toBeNull();
226
+ expect(scope.isDone()).toBe(false);
227
+ });
228
+
229
+ test('rejects a company name that is too short', async () => {
230
+ const company = Company.create({
231
+ name: 'ab',
232
+ address: belgianAddress(),
233
+ });
234
+
235
+ await expect(ViesHelper.checkCompany(company, Company.create({}))).rejects.toThrow(
236
+ STExpect.simpleError({ code: 'invalid_company_name', field: 'companyName' }),
237
+ );
238
+ });
239
+
240
+ test('validates the VAT number and derives the company number for a Belgian company', async () => {
241
+ mockVies({ valid: true });
242
+
243
+ const company = Company.create({
244
+ name: 'Demo Company',
245
+ VATNumber: 'BE0411905847',
246
+ companyNumber: null,
247
+ address: belgianAddress(),
248
+ });
249
+ const patch = Company.create({});
250
+
251
+ await ViesHelper.checkCompany(company, patch);
252
+
253
+ expect(patch.VATNumber).toBe('BE0411905847');
254
+ expect(patch.companyNumber).toBe('0411905847');
255
+ });
256
+ });
257
+ });
@@ -3,7 +3,7 @@ import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-e
3
3
  import type { Company } from '@stamhoofd/structures';
4
4
  import { Country } from '@stamhoofd/types/Country';
5
5
  import axios from 'axios';
6
- import * as jsvat from 'jsvat-next'; // has no default export, so we need the wildcard
6
+ import jsvat from 'jsvat-next';
7
7
 
8
8
  export class ViesHelperStatic {
9
9
  testMode = false;