@stamhoofd/backend 2.109.0 → 2.111.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 +11 -11
- package/src/endpoints/auth/PatchUserEndpoint.test.ts +56 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.ts +16 -18
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +8 -8
- package/src/seeds/1765896674-document-update-year.test.ts +2 -2
- package/src/seeds/1765896674-document-update-year.ts +4 -13
- package/src/services/InvoiceService.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.111.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
"@bwip-js/node": "^4.5.1",
|
|
49
49
|
"@mollie/api-client": "4.3.3",
|
|
50
50
|
"@simonbackx/simple-database": "1.34.0",
|
|
51
|
-
"@simonbackx/simple-encoding": "2.
|
|
51
|
+
"@simonbackx/simple-encoding": "2.23.1",
|
|
52
52
|
"@simonbackx/simple-endpoints": "1.20.1",
|
|
53
53
|
"@simonbackx/simple-logging": "^1.0.1",
|
|
54
|
-
"@stamhoofd/backend-i18n": "2.
|
|
55
|
-
"@stamhoofd/backend-middleware": "2.
|
|
56
|
-
"@stamhoofd/email": "2.
|
|
57
|
-
"@stamhoofd/models": "2.
|
|
58
|
-
"@stamhoofd/queues": "2.
|
|
59
|
-
"@stamhoofd/sql": "2.
|
|
60
|
-
"@stamhoofd/structures": "2.
|
|
61
|
-
"@stamhoofd/utility": "2.
|
|
54
|
+
"@stamhoofd/backend-i18n": "2.111.0",
|
|
55
|
+
"@stamhoofd/backend-middleware": "2.111.0",
|
|
56
|
+
"@stamhoofd/email": "2.111.0",
|
|
57
|
+
"@stamhoofd/models": "2.111.0",
|
|
58
|
+
"@stamhoofd/queues": "2.111.0",
|
|
59
|
+
"@stamhoofd/sql": "2.111.0",
|
|
60
|
+
"@stamhoofd/structures": "2.111.0",
|
|
61
|
+
"@stamhoofd/utility": "2.111.0",
|
|
62
62
|
"archiver": "^7.0.1",
|
|
63
63
|
"axios": "^1.13.2",
|
|
64
64
|
"cookie": "^0.7.0",
|
|
@@ -76,5 +76,5 @@
|
|
|
76
76
|
"publishConfig": {
|
|
77
77
|
"access": "public"
|
|
78
78
|
},
|
|
79
|
-
"gitHead": "
|
|
79
|
+
"gitHead": "21344c902f356539e4034b499b94f54adbe10e50"
|
|
80
80
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
|
|
3
|
+
|
|
4
|
+
import { NewUser, PermissionRole, Permissions, UserPermissions } from '@stamhoofd/structures';
|
|
5
|
+
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
6
|
+
import { initAdmin } from '../../../tests/init/initAdmin.js';
|
|
7
|
+
import { PatchUserEndpoint } from './PatchUserEndpoint.js';
|
|
8
|
+
|
|
9
|
+
describe('Endpoint.PatchUser', () => {
|
|
10
|
+
// Test endpoint
|
|
11
|
+
const endpoint = new PatchUserEndpoint();
|
|
12
|
+
|
|
13
|
+
test('[Regression] Sending a patch for organization permissions that does not exist', async () => {
|
|
14
|
+
// Case: User A does not have permissions for organization A.
|
|
15
|
+
// You send a patch to change User A's permissions for organization A
|
|
16
|
+
// In the past, this caused data corruption because the way simple-encoding was implemented
|
|
17
|
+
|
|
18
|
+
const organization = await new OrganizationFactory({}).create();
|
|
19
|
+
const user = await new UserFactory({ organization }).create();
|
|
20
|
+
|
|
21
|
+
const { adminToken } = await initAdmin({ organization });
|
|
22
|
+
|
|
23
|
+
// Try to request members for this group
|
|
24
|
+
const userPermissions = UserPermissions.patch({});
|
|
25
|
+
const permissionsPatch = Permissions.patch({});
|
|
26
|
+
permissionsPatch.roles.addPut(PermissionRole.create({
|
|
27
|
+
id: 'test',
|
|
28
|
+
name: 'Test role',
|
|
29
|
+
}));
|
|
30
|
+
userPermissions.organizationPermissions.set(organization.id, permissionsPatch);
|
|
31
|
+
|
|
32
|
+
const request = Request.patch({
|
|
33
|
+
path: `/user/${user.id}`,
|
|
34
|
+
host: organization.getApiHost(),
|
|
35
|
+
headers: {
|
|
36
|
+
authorization: 'Bearer ' + adminToken.accessToken,
|
|
37
|
+
},
|
|
38
|
+
body: NewUser.patch({
|
|
39
|
+
id: user.id,
|
|
40
|
+
permissions: userPermissions,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const response = await testServer.test(endpoint, request);
|
|
45
|
+
expect(response.status).toBe(200);
|
|
46
|
+
|
|
47
|
+
// This threw in the past when something was wrong
|
|
48
|
+
await user.refresh();
|
|
49
|
+
|
|
50
|
+
expect(user.permissions?.organizationPermissions.size).toEqual(1);
|
|
51
|
+
|
|
52
|
+
expect(user.permissions?.organizationPermissions.get(organization.id)).toBeDefined();
|
|
53
|
+
expect(user.permissions?.organizationPermissions.get(organization.id)?.roles.length).toEqual(1);
|
|
54
|
+
expect(user.permissions?.organizationPermissions.get(organization.id)?.roles[0].id).toEqual('test');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -53,15 +53,7 @@ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body
|
|
|
53
53
|
template.year = put.year;
|
|
54
54
|
template.organizationId = organization.id;
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
throw new SimpleError({
|
|
58
|
-
code: 'double_fiscal_document',
|
|
59
|
-
field: 'year',
|
|
60
|
-
message: 'This year already has a fiscal document',
|
|
61
|
-
human: $t('475f5f96-86bf-4124-a005-9904aaf72b37'),
|
|
62
|
-
|
|
63
|
-
});
|
|
64
|
-
}
|
|
56
|
+
await this.throwErrorIfAddMoreThanOneFiscalDocumentInYear(template);
|
|
65
57
|
|
|
66
58
|
await template.save();
|
|
67
59
|
|
|
@@ -113,13 +105,8 @@ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body
|
|
|
113
105
|
template.year = patch.year;
|
|
114
106
|
}
|
|
115
107
|
|
|
116
|
-
if (shouldCheckIfAlreadyHasFiscalDocument
|
|
117
|
-
|
|
118
|
-
code: 'double_fiscal_document',
|
|
119
|
-
field: 'year',
|
|
120
|
-
message: 'This year already has a fiscal document',
|
|
121
|
-
human: $t('475f5f96-86bf-4124-a005-9904aaf72b37'),
|
|
122
|
-
});
|
|
108
|
+
if (shouldCheckIfAlreadyHasFiscalDocument) {
|
|
109
|
+
await this.throwErrorIfAddMoreThanOneFiscalDocumentInYear(template);
|
|
123
110
|
}
|
|
124
111
|
|
|
125
112
|
await template.save();
|
|
@@ -149,7 +136,11 @@ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body
|
|
|
149
136
|
);
|
|
150
137
|
}
|
|
151
138
|
|
|
152
|
-
private async
|
|
139
|
+
private async throwErrorIfAddMoreThanOneFiscalDocumentInYear(template: DocumentTemplate): Promise<void> {
|
|
140
|
+
if (template.privateSettings.templateDefinition.type !== 'fiscal') {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
153
144
|
let query = SQL.select().from(SQL.table(DocumentTemplate.table))
|
|
154
145
|
.where(SQL.column('organizationId'), template.organizationId)
|
|
155
146
|
.where(SQL.column('year'), template.year)
|
|
@@ -162,6 +153,13 @@ export class PatchDocumentTemplatesEndpoint extends Endpoint<Params, Query, Body
|
|
|
162
153
|
|
|
163
154
|
const result = await query.limit(1).count();
|
|
164
155
|
|
|
165
|
-
|
|
156
|
+
if (result > 0) {
|
|
157
|
+
throw new SimpleError({
|
|
158
|
+
code: 'double_fiscal_document',
|
|
159
|
+
field: 'year',
|
|
160
|
+
message: 'This year already has a fiscal document',
|
|
161
|
+
human: $t('475f5f96-86bf-4124-a005-9904aaf72b37'),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
166
164
|
}
|
|
167
165
|
}
|
|
@@ -5,14 +5,14 @@ import { Organization, OrganizationRegistrationPeriod, PayconiqPayment, Platform
|
|
|
5
5
|
import { BuckarooSettings, Company, MemberResponsibility, OrganizationMetaData, Organization as OrganizationStruct, PayconiqAccount, PaymentMethod, PaymentMethodHelper, PermissionLevel, PermissionRoleDetailed, PermissionRoleForResponsibility, PermissionsResourceType, ResourcePermissions, UitpasClientCredentialsStatus } from '@stamhoofd/structures';
|
|
6
6
|
import { Formatter } from '@stamhoofd/utility';
|
|
7
7
|
|
|
8
|
-
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
|
|
9
|
-
import { BuckarooHelper } from '../../../../helpers/BuckarooHelper';
|
|
10
|
-
import { Context } from '../../../../helpers/Context';
|
|
11
|
-
import { MemberUserSyncer } from '../../../../helpers/MemberUserSyncer';
|
|
12
|
-
import { SetupStepUpdater } from '../../../../helpers/SetupStepUpdater';
|
|
13
|
-
import { TagHelper } from '../../../../helpers/TagHelper';
|
|
14
|
-
import { ViesHelper } from '../../../../helpers/ViesHelper';
|
|
15
|
-
import { UitpasService } from '../../../../services/uitpas/UitpasService';
|
|
8
|
+
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
9
|
+
import { BuckarooHelper } from '../../../../helpers/BuckarooHelper.js';
|
|
10
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
11
|
+
import { MemberUserSyncer } from '../../../../helpers/MemberUserSyncer.js';
|
|
12
|
+
import { SetupStepUpdater } from '../../../../helpers/SetupStepUpdater.js';
|
|
13
|
+
import { TagHelper } from '../../../../helpers/TagHelper.js';
|
|
14
|
+
import { ViesHelper } from '../../../../helpers/ViesHelper.js';
|
|
15
|
+
import { UitpasService } from '../../../../services/uitpas/UitpasService.js';
|
|
16
16
|
|
|
17
17
|
type Params = Record<string, never>;
|
|
18
18
|
type Query = undefined;
|
|
@@ -150,10 +150,10 @@ describe('migration.document-update-year', () => {
|
|
|
150
150
|
const updatedDocument3 = await DocumentTemplate.getByID(document3.id);
|
|
151
151
|
|
|
152
152
|
// take most frequent year and prefer date of document creation
|
|
153
|
-
expect(updatedDocument1?.year).toBe(
|
|
153
|
+
expect(updatedDocument1?.year).toBe(2020);
|
|
154
154
|
// should take 2020 because document was created in 2020
|
|
155
155
|
expect(updatedDocument2?.year).toBe(2020);
|
|
156
|
-
expect(updatedDocument3?.year).toBe(
|
|
156
|
+
expect(updatedDocument3?.year).toBe(2020);
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
|
|
@@ -28,27 +28,18 @@ export async function migrateDocumentYears() {
|
|
|
28
28
|
const yearMap = new Map<number, number>();
|
|
29
29
|
|
|
30
30
|
for (const group of groupModels) {
|
|
31
|
-
const
|
|
32
|
-
const endYear = group.settings.endDate.getFullYear();
|
|
31
|
+
const y = group.settings.startDate.getFullYear();
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
yearMap.set(y, count + 1);
|
|
37
|
-
}
|
|
33
|
+
const count = yearMap.get(y) ?? 0;
|
|
34
|
+
yearMap.set(y, count + 1);
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
// find the year with the highest count
|
|
41
38
|
let topYear = 0;
|
|
42
39
|
let topCount = 0;
|
|
43
|
-
const yearBeforeCreation = document.createdAt.getFullYear() - 1;
|
|
44
40
|
|
|
45
41
|
for (const [year, count] of yearMap) {
|
|
46
|
-
if (count > topCount
|
|
47
|
-
// prefer the year before creation
|
|
48
|
-
|| (count === topCount && year === yearBeforeCreation)
|
|
49
|
-
// next prefer the most recent year
|
|
50
|
-
|| (topYear !== yearBeforeCreation && year > topYear)
|
|
51
|
-
) {
|
|
42
|
+
if (count > topCount) {
|
|
52
43
|
topYear = year;
|
|
53
44
|
topCount = count;
|
|
54
45
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { BalanceItem, BalanceItemPayment, Invoice, Organization, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { InvoicedBalanceItem as InvoicedBalanceItemStruct, Invoice as InvoiceStruct } from '@stamhoofd/structures';
|
|
4
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
|
|
6
|
+
export class InvoiceService {
|
|
7
|
+
static async invoicePayment(payment: Payment) {
|
|
8
|
+
if (!payment.customer) {
|
|
9
|
+
throw new SimpleError({
|
|
10
|
+
code: 'missing_customer',
|
|
11
|
+
message: 'Missing customer',
|
|
12
|
+
field: 'customer',
|
|
13
|
+
human: $t('Er kan geen factuur aangemaakt worden omdat de klantgegevens ontbreken voor deze betaling'),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (payment.price % 100 !== 0) {
|
|
18
|
+
throw new SimpleError({
|
|
19
|
+
code: 'invalid_price_decimals',
|
|
20
|
+
message: 'Cannot invoice a payment with a price having more than two decimals',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const organization = payment.organizationId ? await Organization.getByID(payment.organizationId) : null;
|
|
25
|
+
if (!organization) {
|
|
26
|
+
throw new SimpleError({
|
|
27
|
+
code: 'missing_organization',
|
|
28
|
+
message: 'Cannot invoice a payment without corresponding organization',
|
|
29
|
+
statusCode: 500,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Find default company
|
|
34
|
+
const seller = organization.meta.companies[0];
|
|
35
|
+
|
|
36
|
+
if (!seller) {
|
|
37
|
+
throw new SimpleError({
|
|
38
|
+
code: 'missing_company',
|
|
39
|
+
message: 'Missing invoice settings (companies)',
|
|
40
|
+
human: $t('Het is niet mogelijk om facturen uit te schrijven omdat er nog geen facturatiegegevens zijn ingesteld voor {organization-name}', {
|
|
41
|
+
'organization-name': organization.name,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const items: InvoicedBalanceItemStruct[] = [];
|
|
47
|
+
const balanceItemPayments = await BalanceItemPayment.select().where('paymentId', payment.id).fetch();
|
|
48
|
+
const balanceItems = await BalanceItem.getByIDs(...Formatter.uniqueArray(balanceItemPayments.map(d => d.balanceItemId)));
|
|
49
|
+
|
|
50
|
+
for (const balanceItemPayment of balanceItemPayments) {
|
|
51
|
+
const balanceItem = balanceItems.find(b => b.id === balanceItemPayment.balanceItemId);
|
|
52
|
+
if (!balanceItem) {
|
|
53
|
+
throw new SimpleError({
|
|
54
|
+
code: 'missing_balance_item',
|
|
55
|
+
message: 'Balance item missing for balanceItemPayment ' + balanceItemPayment.id,
|
|
56
|
+
statusCode: 500,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const item = InvoicedBalanceItemStruct.createFor(balanceItem.getStructure(), balanceItemPayment.price);
|
|
61
|
+
items.push(item);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const struct = InvoiceStruct.create({
|
|
65
|
+
organizationId: organization.id,
|
|
66
|
+
seller,
|
|
67
|
+
customer: payment.customer,
|
|
68
|
+
payingOrganizationId: payment.payingOrganizationId,
|
|
69
|
+
items,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
struct.calculateVAT();
|
|
73
|
+
|
|
74
|
+
if (struct.totalBalanceInvoicedAmount <= 0) {
|
|
75
|
+
throw new SimpleError({
|
|
76
|
+
code: 'invalid_invoiced_amount',
|
|
77
|
+
message: 'Unexpected 0 totalBalanceInvoicedAmount',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (struct.totalWithVAT % 100 !== 0) {
|
|
82
|
+
throw new SimpleError({
|
|
83
|
+
code: 'invalid_price_decimals',
|
|
84
|
+
message: 'Unexpected invoice total price with more than two decimals',
|
|
85
|
+
statusCode: 500,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Update payableRoundingAmount to match payment price
|
|
90
|
+
const difference = payment.price - struct.totalWithVAT;
|
|
91
|
+
if (Math.abs(difference) > 10_00) {
|
|
92
|
+
// Difference of more than 10 cent!
|
|
93
|
+
throw new SimpleError({
|
|
94
|
+
code: 'unexpected_price_difference',
|
|
95
|
+
message: 'The invoice and payment amounts differ by more than €0.10.',
|
|
96
|
+
statusCode: 500,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (difference % 100 !== 0) {
|
|
101
|
+
throw new SimpleError({
|
|
102
|
+
code: 'invalid_price_decimals',
|
|
103
|
+
message: 'Unexpected payableRoundingAmount with more than two decimals',
|
|
104
|
+
statusCode: 500,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
struct.payableRoundingAmount = difference;
|
|
109
|
+
|
|
110
|
+
// Todo: check we are not invoicing more than maximum invoiceable for these items
|
|
111
|
+
|
|
112
|
+
return await Invoice.createFrom(organization, struct);
|
|
113
|
+
}
|
|
114
|
+
}
|