@stamhoofd/backend 2.126.2 → 2.127.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/invoices.ts +5 -6
- package/src/crons/mollie-refunds.test.ts +310 -0
- package/src/crons/mollie-refunds.ts +157 -0
- package/src/crons/stripe-invoices.ts +3 -2
- package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +3 -0
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.ts +12 -1
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +148 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.test.ts +490 -0
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +72 -9
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +209 -0
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +97 -136
- package/src/helpers/StripeInvoicer.ts +32 -56
- package/src/services/BalanceItemService.ts +19 -2
- package/src/services/InvoiceService.ts +9 -0
- package/src/services/MollieService.ts +108 -1
- package/src/services/PaymentService.ts +441 -34
- package/src/services/data/invoice.hbs.html +8 -6
- package/src/sql-filters/invoiced-balance-items.ts +5 -0
- package/tests/helpers/MollieMocker.ts +141 -1
- package/tests/vitest.setup.ts +5 -0
|
@@ -3,6 +3,7 @@ import { PatchableArrayDecoder, patchObject, StringDecoder } from '@simonbackx/s
|
|
|
3
3
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import type { Organization } from '@stamhoofd/models';
|
|
6
7
|
import { BalanceItem, BalanceItemPayment, Payment, User } from '@stamhoofd/models';
|
|
7
8
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
8
9
|
import { PaymentCustomer, PaymentGeneral, PaymentMethod, PaymentStatus, Payment as PaymentStruct, PaymentType, PermissionLevel } from '@stamhoofd/structures';
|
|
@@ -60,6 +61,13 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// A put with a reversingPaymentId creates a refund via the API of the payment
|
|
65
|
+
// provider of that payment (instead of registering a manual payment)
|
|
66
|
+
if (put.reversingPaymentId !== null) {
|
|
67
|
+
changedPayments.push(await this.createOnlineRefund(organization, user, put));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
if (![PaymentMethod.Unknown, PaymentMethod.Transfer, PaymentMethod.PointOfSale].includes(put.method)) {
|
|
64
72
|
throw new SimpleError({
|
|
65
73
|
code: 'invalid_field',
|
|
@@ -227,16 +235,9 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
227
235
|
|
|
228
236
|
// Mark paid or failed
|
|
229
237
|
if (put.status !== PaymentStatus.Created && put.status !== PaymentStatus.Pending) {
|
|
230
|
-
await PaymentService.handlePaymentStatusUpdate(payment, organization, put.status);
|
|
231
|
-
|
|
232
|
-
if (put.status === PaymentStatus.Succeeded) {
|
|
233
|
-
payment.paidAt = put.paidAt;
|
|
234
|
-
await payment.save();
|
|
235
|
-
}
|
|
238
|
+
await PaymentService.handlePaymentStatusUpdate(payment, organization, put.status, put.status === PaymentStatus.Succeeded ? (put.paidAt ?? undefined) : undefined);
|
|
236
239
|
} else {
|
|
237
|
-
|
|
238
|
-
await BalanceItemService.markUpdated(balanceItem, payment, organization);
|
|
239
|
-
}
|
|
240
|
+
await PaymentService.handlePaymentCreated(payment, organization);
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
changedPayments.push(payment);
|
|
@@ -344,6 +345,8 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
344
345
|
|
|
345
346
|
if (patch.status && patch.status !== payment.status) {
|
|
346
347
|
await PaymentService.handlePaymentStatusUpdate(payment, organization, patch.status);
|
|
348
|
+
} else {
|
|
349
|
+
await PaymentService.handlePaymentUpdated(payment, organization);
|
|
347
350
|
}
|
|
348
351
|
|
|
349
352
|
changedPayments.push(
|
|
@@ -359,4 +362,64 @@ export class PatchPaymentsEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
359
362
|
await AuthenticatedStructures.paymentsGeneral(changedPayments, true),
|
|
360
363
|
);
|
|
361
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Create a refund via the API of the payment provider (currently only Mollie).
|
|
368
|
+
*
|
|
369
|
+
* The put contains the new (negative) payment to create, with `reversingPaymentId` set to the
|
|
370
|
+
* existing online payment that will be (partially) refunded via the payment provider. The
|
|
371
|
+
* balance items of the new payment can differ from the balance items of the source payment.
|
|
372
|
+
*/
|
|
373
|
+
private async createOnlineRefund(organization: Organization, user: User, put: PaymentGeneral): Promise<Payment> {
|
|
374
|
+
if (put.type !== PaymentType.Refund) {
|
|
375
|
+
throw new SimpleError({
|
|
376
|
+
code: 'invalid_field',
|
|
377
|
+
message: 'Only payments of type Refund can reverse another payment',
|
|
378
|
+
human: $t('%Zab'),
|
|
379
|
+
field: 'type',
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const sourcePayment = await Payment.getByID(put.reversingPaymentId!);
|
|
384
|
+
if (!sourcePayment || sourcePayment.organizationId !== organization.id || !(await Context.auth.canAccessPayment(sourcePayment, PermissionLevel.Write))) {
|
|
385
|
+
throw new SimpleError({
|
|
386
|
+
code: 'not_found',
|
|
387
|
+
message: 'Payment not found',
|
|
388
|
+
human: $t('%ZZy'),
|
|
389
|
+
field: 'reversingPaymentId',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (sourcePayment.type !== PaymentType.Payment) {
|
|
394
|
+
throw new SimpleError({
|
|
395
|
+
code: 'invalid_field',
|
|
396
|
+
message: 'Can only refund payments of type Payment',
|
|
397
|
+
human: $t('%ZaJ'),
|
|
398
|
+
field: 'reversingPaymentId',
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const balanceItems = new Map<BalanceItem, number>();
|
|
403
|
+
|
|
404
|
+
for (const item of put.balanceItemPayments) {
|
|
405
|
+
const balanceItem = await BalanceItem.getByID(item.balanceItem.id);
|
|
406
|
+
if (!balanceItem || balanceItem.organizationId !== organization.id) {
|
|
407
|
+
throw Context.auth.notFoundOrNoAccess($t('%Za2'));
|
|
408
|
+
}
|
|
409
|
+
balanceItems.set(balanceItem, (balanceItems.get(balanceItem) ?? 0) + item.price);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Check permissions
|
|
413
|
+
if (!(await Context.auth.canAccessBalanceItems([...balanceItems.keys()], PermissionLevel.Write))) {
|
|
414
|
+
throw Context.auth.error($t('%Za9'));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return await PaymentService.createRefund({
|
|
418
|
+
organization,
|
|
419
|
+
sourcePayment,
|
|
420
|
+
balanceItems,
|
|
421
|
+
adminUserId: user.id,
|
|
422
|
+
customer: put.customer,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
362
425
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemFactory, BalanceItemPayment, Invoice, InvoicedBalanceItem, MemberFactory, OrganizationFactory, Payment, Token, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import type { DetailedReceivableBalance } from '@stamhoofd/structures';
|
|
5
|
+
import { PaymentMethod, PaymentStatus, PermissionLevel, Permissions, ReceivableBalanceType } from '@stamhoofd/structures';
|
|
6
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
7
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { GetReceivableBalanceEndpoint } from './GetReceivableBalanceEndpoint.js';
|
|
9
|
+
|
|
10
|
+
describe('Endpoint.GetReceivableBalanceEndpoint', () => {
|
|
11
|
+
const endpoint = new GetReceivableBalanceEndpoint();
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const createPayment = async ({ organization, balanceItems, invoiceId }: { organization: Organization; balanceItems: BalanceItem[]; invoiceId?: string | null }) => {
|
|
18
|
+
const payment = new Payment();
|
|
19
|
+
payment.method = PaymentMethod.Transfer;
|
|
20
|
+
payment.status = PaymentStatus.Succeeded;
|
|
21
|
+
payment.organizationId = organization.id;
|
|
22
|
+
payment.price = balanceItems.reduce((sum, b) => sum + b.price, 0);
|
|
23
|
+
payment.invoiceId = invoiceId ?? null;
|
|
24
|
+
await payment.save();
|
|
25
|
+
|
|
26
|
+
for (const balanceItem of balanceItems) {
|
|
27
|
+
const balanceItemPayment = new BalanceItemPayment();
|
|
28
|
+
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
29
|
+
balanceItemPayment.paymentId = payment.id;
|
|
30
|
+
balanceItemPayment.price = balanceItem.price;
|
|
31
|
+
balanceItemPayment.organizationId = organization.id;
|
|
32
|
+
await balanceItemPayment.save();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return payment;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const createInvoice = async ({ organization, balanceItems, payingOrganizationId }: { organization: Organization; balanceItems?: BalanceItem[]; payingOrganizationId?: string | null }) => {
|
|
39
|
+
const invoice = new Invoice();
|
|
40
|
+
invoice.organizationId = organization.id;
|
|
41
|
+
invoice.payingOrganizationId = payingOrganizationId ?? null;
|
|
42
|
+
await invoice.save();
|
|
43
|
+
|
|
44
|
+
for (const balanceItem of balanceItems ?? []) {
|
|
45
|
+
const item = new InvoicedBalanceItem();
|
|
46
|
+
item.organizationId = organization.id;
|
|
47
|
+
item.invoiceId = invoice.id;
|
|
48
|
+
item.balanceItemId = balanceItem.id;
|
|
49
|
+
item.name = 'Test item';
|
|
50
|
+
item.unitPrice = balanceItem.unitPrice;
|
|
51
|
+
item.balanceInvoicedAmount = balanceItem.price;
|
|
52
|
+
await item.save();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return invoice;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const getReceivableBalance = async ({ type, id, organization, user }: { type: ReceivableBalanceType; id: string; organization: Organization; user: User }) => {
|
|
59
|
+
const token = await Token.createToken(user);
|
|
60
|
+
|
|
61
|
+
const request = Request.get({
|
|
62
|
+
path: `/receivable-balances/${type}/${id}`,
|
|
63
|
+
host: organization.getApiHost(),
|
|
64
|
+
headers: {
|
|
65
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return testServer.test<DetailedReceivableBalance>(endpoint, request);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const createAdmin = async (organization: Organization) => {
|
|
73
|
+
return await new UserFactory({
|
|
74
|
+
organization,
|
|
75
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
76
|
+
}).create();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe('Invoices of a member receivable balance', () => {
|
|
80
|
+
test('returns invoices that invoiced a balance item of the member, and marks the payments as invoiced', async () => {
|
|
81
|
+
const organization = await new OrganizationFactory({}).create();
|
|
82
|
+
const admin = await createAdmin(organization);
|
|
83
|
+
const member = await new MemberFactory({ organization }).create();
|
|
84
|
+
const otherMember = await new MemberFactory({ organization }).create();
|
|
85
|
+
|
|
86
|
+
const balanceItem = await new BalanceItemFactory({
|
|
87
|
+
organizationId: organization.id,
|
|
88
|
+
memberId: member.id,
|
|
89
|
+
amount: 1,
|
|
90
|
+
unitPrice: 50_00,
|
|
91
|
+
}).create();
|
|
92
|
+
|
|
93
|
+
const otherBalanceItem = await new BalanceItemFactory({
|
|
94
|
+
organizationId: organization.id,
|
|
95
|
+
memberId: otherMember.id,
|
|
96
|
+
amount: 1,
|
|
97
|
+
unitPrice: 10_00,
|
|
98
|
+
}).create();
|
|
99
|
+
|
|
100
|
+
const invoice = await createInvoice({ organization, balanceItems: [balanceItem] });
|
|
101
|
+
const otherInvoice = await createInvoice({ organization, balanceItems: [otherBalanceItem] });
|
|
102
|
+
|
|
103
|
+
const invoicedPayment = await createPayment({ organization, balanceItems: [balanceItem], invoiceId: invoice.id });
|
|
104
|
+
const notInvoicedPayment = await createPayment({ organization, balanceItems: [balanceItem] });
|
|
105
|
+
|
|
106
|
+
const response = await getReceivableBalance({
|
|
107
|
+
type: ReceivableBalanceType.member,
|
|
108
|
+
id: member.id,
|
|
109
|
+
organization,
|
|
110
|
+
user: admin,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(response.status).toBe(200);
|
|
114
|
+
expect(response.body.invoices.map(i => i.id)).toEqual([invoice.id]);
|
|
115
|
+
expect(response.body.invoices.map(i => i.id)).not.toContain(otherInvoice.id);
|
|
116
|
+
|
|
117
|
+
const payments = response.body.payments;
|
|
118
|
+
expect(payments.find(p => p.id === invoicedPayment.id)?.invoiceId).toBe(invoice.id);
|
|
119
|
+
expect(payments.find(p => p.id === notInvoicedPayment.id)?.invoiceId).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('Invoices of an organization receivable balance', () => {
|
|
124
|
+
test('returns invoices via the paying organization and via invoiced balance items, without duplicates', async () => {
|
|
125
|
+
const organization = await new OrganizationFactory({}).create();
|
|
126
|
+
const payingOrganization = await new OrganizationFactory({}).create();
|
|
127
|
+
const admin = await createAdmin(organization);
|
|
128
|
+
|
|
129
|
+
const balanceItem = await new BalanceItemFactory({
|
|
130
|
+
organizationId: organization.id,
|
|
131
|
+
payingOrganizationId: payingOrganization.id,
|
|
132
|
+
amount: 1,
|
|
133
|
+
unitPrice: 50_00,
|
|
134
|
+
}).create();
|
|
135
|
+
|
|
136
|
+
// Linked both directly and via the invoiced balance item: should only be returned once
|
|
137
|
+
const invoiceViaBoth = await createInvoice({ organization, balanceItems: [balanceItem], payingOrganizationId: payingOrganization.id });
|
|
138
|
+
|
|
139
|
+
// Only linked via the invoiced balance item: the invoice itself has no payingOrganizationId,
|
|
140
|
+
// so it can only be found through the balance item that belongs to the paying organization
|
|
141
|
+
const invoiceViaItemOnly = await createInvoice({ organization, balanceItems: [balanceItem], payingOrganizationId: null });
|
|
142
|
+
|
|
143
|
+
// Only linked directly to the paying organization (no invoiced balance items)
|
|
144
|
+
const directInvoice = await createInvoice({ organization, payingOrganizationId: payingOrganization.id });
|
|
145
|
+
|
|
146
|
+
// Invoice of another paying organization
|
|
147
|
+
const otherPayingOrganization = await new OrganizationFactory({}).create();
|
|
148
|
+
const otherInvoice = await createInvoice({ organization, payingOrganizationId: otherPayingOrganization.id });
|
|
149
|
+
|
|
150
|
+
const response = await getReceivableBalance({
|
|
151
|
+
type: ReceivableBalanceType.organization,
|
|
152
|
+
id: payingOrganization.id,
|
|
153
|
+
organization,
|
|
154
|
+
user: admin,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(response.status).toBe(200);
|
|
158
|
+
expect(response.body.invoices.map(i => i.id).sort()).toEqual([invoiceViaBoth.id, invoiceViaItemOnly.id, directInvoice.id].sort());
|
|
159
|
+
expect(response.body.invoices.map(i => i.id)).not.toContain(otherInvoice.id);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('does not return invoices of a different selling organization', async () => {
|
|
163
|
+
const organization = await new OrganizationFactory({}).create();
|
|
164
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
165
|
+
const payingOrganization = await new OrganizationFactory({}).create();
|
|
166
|
+
const admin = await createAdmin(organization);
|
|
167
|
+
|
|
168
|
+
await createInvoice({ organization: otherOrganization, payingOrganizationId: payingOrganization.id });
|
|
169
|
+
|
|
170
|
+
const response = await getReceivableBalance({
|
|
171
|
+
type: ReceivableBalanceType.organization,
|
|
172
|
+
id: payingOrganization.id,
|
|
173
|
+
organization,
|
|
174
|
+
user: admin,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(response.status).toBe(200);
|
|
178
|
+
expect(response.body.invoices).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('Invoices of a user receivable balance', () => {
|
|
183
|
+
test('returns invoices that invoiced a balance item of the user', async () => {
|
|
184
|
+
const organization = await new OrganizationFactory({}).create();
|
|
185
|
+
const admin = await createAdmin(organization);
|
|
186
|
+
const user = await new UserFactory({ organization }).create();
|
|
187
|
+
|
|
188
|
+
const balanceItem = await new BalanceItemFactory({
|
|
189
|
+
organizationId: organization.id,
|
|
190
|
+
userId: user.id,
|
|
191
|
+
amount: 1,
|
|
192
|
+
unitPrice: 25_00,
|
|
193
|
+
}).create();
|
|
194
|
+
|
|
195
|
+
const invoice = await createInvoice({ organization, balanceItems: [balanceItem] });
|
|
196
|
+
await createPayment({ organization, balanceItems: [balanceItem], invoiceId: invoice.id });
|
|
197
|
+
|
|
198
|
+
const response = await getReceivableBalance({
|
|
199
|
+
type: ReceivableBalanceType.user,
|
|
200
|
+
id: user.id,
|
|
201
|
+
organization,
|
|
202
|
+
user: admin,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(response.status).toBe(200);
|
|
206
|
+
expect(response.body.invoices.map(i => i.id)).toEqual([invoice.id]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
|
3
3
|
import { DetailedReceivableBalance, PaymentStatus, PermissionLevel, ReceivableBalanceType } from '@stamhoofd/structures';
|
|
4
4
|
|
|
5
5
|
import type { MemberWithUsersAndRegistrations } from '@stamhoofd/models';
|
|
6
|
-
import { BalanceItem, BalanceItemPayment, CachedBalance, Member, MemberUser, Payment, Registration } from '@stamhoofd/models';
|
|
6
|
+
import { BalanceItem, BalanceItemPayment, CachedBalance, Invoice, InvoicedBalanceItem, Member, MemberUser, Payment, Registration } from '@stamhoofd/models';
|
|
7
7
|
import { Context } from '../../../../helpers/Context.js';
|
|
8
8
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
9
|
+
import type { SQLWhere } from '@stamhoofd/sql';
|
|
9
10
|
import { SQL } from '@stamhoofd/sql';
|
|
10
11
|
import { BalanceItemService } from '../../../../services/BalanceItemService.js';
|
|
11
|
-
import { Formatter } from '@stamhoofd/utility';
|
|
12
|
+
import { Formatter, Sorter } from '@stamhoofd/utility';
|
|
12
13
|
|
|
13
14
|
type Params = { id: string; type: ReceivableBalanceType };
|
|
14
15
|
type Query = undefined;
|
|
@@ -53,146 +54,36 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
case ReceivableBalanceType.member: {
|
|
83
|
-
// Force cache updates, because sometimes the cache could be out of date
|
|
84
|
-
// BalanceItemService.scheduleMemberUpdate(organization.id, request.params.id);
|
|
85
|
-
|
|
86
|
-
paymentModels = await Payment.select()
|
|
87
|
-
.where('organizationId', organization.id)
|
|
88
|
-
.join(
|
|
89
|
-
SQL.join(BalanceItemPayment.table)
|
|
90
|
-
.where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
|
|
91
|
-
)
|
|
92
|
-
.join(
|
|
93
|
-
SQL.join(BalanceItem.table)
|
|
94
|
-
.where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
|
|
95
|
-
)
|
|
96
|
-
.where(SQL.column(BalanceItem.table, 'memberId'), request.params.id)
|
|
97
|
-
.andWhere(
|
|
98
|
-
SQL.whereNot('status', PaymentStatus.Failed),
|
|
99
|
-
)
|
|
100
|
-
.groupBy(SQL.column(Payment.table, 'id'))
|
|
101
|
-
.fetch();
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
case ReceivableBalanceType.user: {
|
|
106
|
-
const memberUsers = await MemberUser.select().where('usersId', request.params.id).fetch();
|
|
107
|
-
const memberIds = Formatter.uniqueArray(memberUsers.map(mu => mu.membersId));
|
|
108
|
-
|
|
109
|
-
// Force cache updates, because sometimes the cache could be out of date
|
|
110
|
-
// BalanceItemService.scheduleUserUpdate(organization.id, request.params.id);
|
|
111
|
-
//
|
|
112
|
-
// for (const memberId of memberIds) {
|
|
113
|
-
// BalanceItemService.scheduleMemberUpdate(organization.id, memberId);
|
|
114
|
-
// }
|
|
115
|
-
|
|
116
|
-
const q = Payment.select()
|
|
117
|
-
.where('organizationId', organization.id)
|
|
118
|
-
.join(
|
|
119
|
-
SQL.join(BalanceItemPayment.table)
|
|
120
|
-
.where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
|
|
121
|
-
)
|
|
122
|
-
.join(
|
|
123
|
-
SQL.join(BalanceItem.table)
|
|
124
|
-
.where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
if (memberIds.length === 0) {
|
|
128
|
-
q.where(SQL.column(BalanceItem.table, 'userId'), request.params.id);
|
|
129
|
-
} else {
|
|
130
|
-
q.where(
|
|
131
|
-
SQL.where(SQL.column(BalanceItem.table, 'userId'), request.params.id)
|
|
132
|
-
.or(SQL.column(BalanceItem.table, 'memberId'), memberIds),
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
paymentModels = await q
|
|
137
|
-
.andWhere(
|
|
138
|
-
SQL.whereNot('status', PaymentStatus.Failed),
|
|
139
|
-
)
|
|
140
|
-
.groupBy(SQL.column(Payment.table, 'id'))
|
|
141
|
-
.fetch();
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
case ReceivableBalanceType.userWithoutMembers: {
|
|
146
|
-
// Force cache updates, because sometimes the cache could be out of date
|
|
147
|
-
// BalanceItemService.scheduleUserUpdate(organization.id, request.params.id);
|
|
148
|
-
|
|
149
|
-
const q = Payment.select()
|
|
150
|
-
.where('organizationId', organization.id)
|
|
151
|
-
.join(
|
|
152
|
-
SQL.join(BalanceItemPayment.table)
|
|
153
|
-
.where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
|
|
154
|
-
)
|
|
155
|
-
.join(
|
|
156
|
-
SQL.join(BalanceItem.table)
|
|
157
|
-
.where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
|
|
158
|
-
)
|
|
159
|
-
.where(SQL.column(BalanceItem.table, 'userId'), request.params.id);
|
|
160
|
-
|
|
161
|
-
paymentModels = await q
|
|
162
|
-
.andWhere(
|
|
163
|
-
SQL.whereNot('status', PaymentStatus.Failed),
|
|
164
|
-
)
|
|
165
|
-
.groupBy(SQL.column(Payment.table, 'id'))
|
|
166
|
-
.fetch();
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
case ReceivableBalanceType.registration: {
|
|
171
|
-
paymentModels = await Payment.select()
|
|
172
|
-
.where('organizationId', organization.id)
|
|
173
|
-
.join(
|
|
174
|
-
SQL.join(BalanceItemPayment.table)
|
|
175
|
-
.where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
|
|
176
|
-
)
|
|
177
|
-
.join(
|
|
178
|
-
SQL.join(BalanceItem.table)
|
|
179
|
-
.where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
|
|
180
|
-
)
|
|
181
|
-
.where(SQL.column(BalanceItem.table, 'registrationId'), request.params.id)
|
|
182
|
-
.andWhere(
|
|
183
|
-
SQL.whereNot('status', PaymentStatus.Failed),
|
|
184
|
-
)
|
|
185
|
-
.groupBy(SQL.column(Payment.table, 'id'))
|
|
186
|
-
.fetch();
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
57
|
+
// Note: the cache updates are disabled because they caused performance issues
|
|
58
|
+
// BalanceItemService.scheduleOrganizationUpdate / scheduleMemberUpdate / scheduleUserUpdate
|
|
59
|
+
|
|
60
|
+
const balanceItemWhere = await GetReceivableBalanceEndpoint.getBalanceItemWhere(request.params.type, request.params.id);
|
|
61
|
+
|
|
62
|
+
const paymentModels = await Payment.select()
|
|
63
|
+
.where('organizationId', organization.id)
|
|
64
|
+
.andWhere(
|
|
65
|
+
SQL.whereNot('status', PaymentStatus.Failed),
|
|
66
|
+
)
|
|
67
|
+
.join(
|
|
68
|
+
SQL.join(BalanceItemPayment.table)
|
|
69
|
+
.where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
|
|
70
|
+
)
|
|
71
|
+
.join(
|
|
72
|
+
SQL.join(BalanceItem.table)
|
|
73
|
+
.where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
|
|
74
|
+
)
|
|
75
|
+
.andWhere(balanceItemWhere)
|
|
76
|
+
.groupBy(SQL.column(Payment.table, 'id'))
|
|
77
|
+
.fetch();
|
|
78
|
+
|
|
79
|
+
const invoiceModels = await GetReceivableBalanceEndpoint.getInvoices(organization.id, request.params.type, request.params.id, balanceItemWhere);
|
|
190
80
|
|
|
191
81
|
// Flush caches (this makes sure that we do a reload in the frontend after a registration or change, we get the newest balances)
|
|
192
82
|
// await BalanceItemService.flushCaches(organization.id);
|
|
193
83
|
const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type);
|
|
194
84
|
const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels);
|
|
195
85
|
const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false);
|
|
86
|
+
const invoices = await AuthenticatedStructures.invoices(invoiceModels);
|
|
196
87
|
|
|
197
88
|
const balances = await CachedBalance.getForObjects([request.params.id], organization.id, request.params.type);
|
|
198
89
|
|
|
@@ -211,7 +102,77 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
|
|
|
211
102
|
...base,
|
|
212
103
|
balanceItems,
|
|
213
104
|
payments,
|
|
105
|
+
invoices,
|
|
214
106
|
}),
|
|
215
107
|
);
|
|
216
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds the condition that matches all balance items that belong to this receivable balance.
|
|
112
|
+
* Requires the balance_items table to be joined in the query.
|
|
113
|
+
*/
|
|
114
|
+
private static async getBalanceItemWhere(type: ReceivableBalanceType, id: string): Promise<SQLWhere> {
|
|
115
|
+
switch (type) {
|
|
116
|
+
case ReceivableBalanceType.organization:
|
|
117
|
+
return SQL.where(SQL.column(BalanceItem.table, 'payingOrganizationId'), id);
|
|
118
|
+
|
|
119
|
+
case ReceivableBalanceType.member:
|
|
120
|
+
return SQL.where(SQL.column(BalanceItem.table, 'memberId'), id);
|
|
121
|
+
|
|
122
|
+
case ReceivableBalanceType.user: {
|
|
123
|
+
const memberUsers = await MemberUser.select().where('usersId', id).fetch();
|
|
124
|
+
const memberIds = Formatter.uniqueArray(memberUsers.map(mu => mu.membersId));
|
|
125
|
+
|
|
126
|
+
if (memberIds.length === 0) {
|
|
127
|
+
return SQL.where(SQL.column(BalanceItem.table, 'userId'), id);
|
|
128
|
+
}
|
|
129
|
+
return SQL.where(SQL.column(BalanceItem.table, 'userId'), id)
|
|
130
|
+
.or(SQL.column(BalanceItem.table, 'memberId'), memberIds);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case ReceivableBalanceType.userWithoutMembers:
|
|
134
|
+
return SQL.where(SQL.column(BalanceItem.table, 'userId'), id);
|
|
135
|
+
|
|
136
|
+
case ReceivableBalanceType.registration:
|
|
137
|
+
return SQL.where(SQL.column(BalanceItem.table, 'registrationId'), id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* All invoices that are related to this receivable balance: invoices that invoiced one of the
|
|
143
|
+
* balance items of this receivable balance, and for organizations also invoices that are
|
|
144
|
+
* directly addressed to the paying organization.
|
|
145
|
+
*/
|
|
146
|
+
private static async getInvoices(organizationId: string, type: ReceivableBalanceType, id: string, balanceItemWhere: SQLWhere): Promise<Invoice[]> {
|
|
147
|
+
const invoiceModels = await Invoice.select()
|
|
148
|
+
.where('organizationId', organizationId)
|
|
149
|
+
.join(
|
|
150
|
+
SQL.join(InvoicedBalanceItem.table)
|
|
151
|
+
.where(SQL.column(InvoicedBalanceItem.table, 'invoiceId'), SQL.column(Invoice.table, 'id')),
|
|
152
|
+
)
|
|
153
|
+
.join(
|
|
154
|
+
SQL.join(BalanceItem.table)
|
|
155
|
+
.where(SQL.column(InvoicedBalanceItem.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
|
|
156
|
+
)
|
|
157
|
+
.andWhere(balanceItemWhere)
|
|
158
|
+
.groupBy(SQL.column(Invoice.table, 'id'))
|
|
159
|
+
.fetch();
|
|
160
|
+
|
|
161
|
+
if (type === ReceivableBalanceType.organization) {
|
|
162
|
+
const directInvoices = await Invoice.select()
|
|
163
|
+
.where('organizationId', organizationId)
|
|
164
|
+
.where('payingOrganizationId', id)
|
|
165
|
+
.fetch();
|
|
166
|
+
|
|
167
|
+
for (const invoice of directInvoices) {
|
|
168
|
+
if (!invoiceModels.some(i => i.id === invoice.id)) {
|
|
169
|
+
invoiceModels.push(invoice);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Oldest first
|
|
175
|
+
invoiceModels.sort((a, b) => Sorter.byDateValue(b.invoicedAt ?? b.createdAt, a.invoicedAt ?? a.createdAt));
|
|
176
|
+
return invoiceModels;
|
|
177
|
+
}
|
|
217
178
|
}
|