@stamhoofd/backend 2.138.2 → 2.139.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 (37) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +165 -0
  3. package/src/crons/fake-settlements.ts +117 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
  6. package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
  8. package/src/endpoints/auth/MFA.test.ts +187 -3
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +1 -1
  10. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
  11. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
  13. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
  16. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  17. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
  18. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
  19. package/src/excel-loaders/balance-item-payments.ts +145 -0
  20. package/src/excel-loaders/index.ts +1 -0
  21. package/src/excel-loaders/payments.ts +45 -37
  22. package/src/helpers/StripeInvoicer.ts +1 -1
  23. package/src/helpers/TwoFactorHelper.ts +91 -9
  24. package/src/helpers/breakdownRelations.ts +48 -0
  25. package/src/helpers/getNextPageRequest.ts +24 -0
  26. package/src/helpers/streamForBreakdown.test.ts +107 -0
  27. package/src/helpers/streamForBreakdown.ts +104 -0
  28. package/src/services/PasswordForgotService.ts +31 -1
  29. package/src/services/PaymentService.ts +1 -1
  30. package/src/services/SSOService.ts +14 -1
  31. package/src/sql-filters/balance-item-payments-root.ts +48 -0
  32. package/src/sql-filters/balance-items.ts +55 -0
  33. package/src/sql-filters/payment-settlement.test.ts +68 -0
  34. package/src/sql-filters/payment-settlement.ts +43 -0
  35. package/src/sql-filters/payments.ts +93 -31
  36. package/src/sql-sorters/balance-item-payments.ts +32 -0
  37. package/tests/helpers/ExportSlice.ts +71 -0
@@ -116,7 +116,7 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
116
116
  // the request already comes from a session of that same user (changing your email
117
117
  // address while signed in): the second factor was checked when it was created.
118
118
  if (authenticatedUser?.id !== user.id) {
119
- await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
119
+ await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion(), { loginMethod: 'email', i18n: request.i18n });
120
120
  }
121
121
 
122
122
  const token = await Token.createToken(user);
@@ -0,0 +1,543 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { BalanceItem, Organization, User } from '@stamhoofd/models';
3
+ import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import type { StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, Option, OptionMenu, OrderData, PaymentMethod, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, Settlement, TranslatedString } from '@stamhoofd/structures';
6
+ import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
7
+ import type { BalanceItemBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
8
+ import { BreakdownAmountType, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
9
+ import { exportSlice } from '../../../../../tests/helpers/ExportSlice.js';
10
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
11
+ import { GetBalanceItemBreakdownEndpoint } from './GetBalanceItemBreakdownEndpoint.js';
12
+ import { GetBalanceItemEndpoint } from './GetBalanceItemEndpoint.js';
13
+
14
+ describe('Endpoint.GetBalanceItemBreakdownEndpoint', () => {
15
+ const endpoint = new GetBalanceItemBreakdownEndpoint();
16
+
17
+ const getBreakdown = async ({ filter, path, organization, user }: { filter?: StamhoofdFilter; path?: BreakdownPathItem[]; organization: Organization; user: User }) => {
18
+ const token = await Token.createToken(user);
19
+
20
+ const request = Request.get({
21
+ path: '/balance-items/breakdown',
22
+ host: organization.getApiHost(),
23
+ query: new BreakdownRequest({ filter: filter ?? null, path: path ?? [] }),
24
+ headers: {
25
+ authorization: 'Bearer ' + token.accessToken,
26
+ },
27
+ });
28
+
29
+ return testServer.test<BalanceItemBreakdown>(endpoint, request);
30
+ };
31
+
32
+ const createFinanceUser = async (organization: Organization) => {
33
+ return await new UserFactory({
34
+ organization,
35
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ }).create();
37
+ };
38
+
39
+ const createItem = async (organization: Organization, options: { price: number; quantity?: number; groupId: string; groupName: string; priceName?: string; status?: BalanceItemStatus; pricePaid?: number; pricePending?: number }) => {
40
+ const relations = new Map([
41
+ [BalanceItemRelationType.Group, BalanceItemRelation.create({ id: options.groupId, name: new TranslatedString(options.groupName) })],
42
+ ]);
43
+
44
+ if (options.priceName) {
45
+ relations.set(
46
+ BalanceItemRelationType.GroupPrice,
47
+ BalanceItemRelation.create({ id: 'price-' + options.priceName, name: new TranslatedString(options.priceName) }),
48
+ );
49
+ }
50
+
51
+ return await new BalanceItemFactory({
52
+ organizationId: organization.id,
53
+ type: BalanceItemType.Registration,
54
+ amount: options.quantity ?? 1,
55
+ unitPrice: options.price,
56
+ pricePaid: options.pricePaid ?? 0,
57
+ pricePending: options.pricePending ?? 0,
58
+ status: options.status,
59
+ relations,
60
+ }).create();
61
+ };
62
+
63
+ test('splits what was charged per category and per article', async () => {
64
+ const organization = await new OrganizationFactory({}).create();
65
+ const user = await createFinanceUser(organization);
66
+
67
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Standaardtarief', pricePaid: 40_00 });
68
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Standaardtarief' });
69
+ await createItem(organization, { price: 20_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Verminderd tarief' });
70
+ await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen', priceName: 'Standaardtarief' });
71
+
72
+ const response = await getBreakdown({ organization, user });
73
+
74
+ expect(response.status).toBe(200);
75
+ expect(response.body.price).toBe(125_00);
76
+ expect(response.body.pricePaid).toBe(40_00);
77
+ expect(response.body.priceOpen).toBe(85_00);
78
+
79
+ expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price, quantity: g.quantity }))).toEqual([
80
+ { name: 'Kapoenen', price: 100_00, quantity: 3 },
81
+ { name: 'Welpen', price: 25_00, quantity: 1 },
82
+ ]);
83
+
84
+ // The two identical registrations are one article, the reduced price is another
85
+ expect(response.body.byArticle).toHaveLength(3);
86
+ expect(response.body.byArticle[0].quantity).toBe(2);
87
+ });
88
+
89
+ test('a webshop order is charged as one balance item, but split up per ordered product', async () => {
90
+ const organization = await new OrganizationFactory({}).create();
91
+ const user = await createFinanceUser(organization);
92
+
93
+ const product = Product.create({ name: 'T-shirt', prices: [ProductPrice.create({ name: 'Standaard', price: 15_00 })] });
94
+ const optionMenu = OptionMenu.create({ name: 'Maat', options: [Option.create({ name: 'XL', price: 2_00 })] });
95
+ product.optionMenus.push(optionMenu);
96
+
97
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
98
+ const order = await new OrderFactory({
99
+ webshop,
100
+ data: OrderData.create({
101
+ customer: Customer.create({ firstName: 'Eva', lastName: 'Peeters', email: 'eva@example.com' }),
102
+ cart: Cart.create({
103
+ items: [
104
+ CartItem.create({
105
+ product,
106
+ productPrice: product.prices[0],
107
+ options: [CartItemOption.create({ optionMenu, option: optionMenu.options[0] })],
108
+ amount: 2,
109
+ // Normally calculated during checkout
110
+ unitPrice: 17_00,
111
+ calculatedPrices: [CartItemPrice.create({ price: 17_00 }), CartItemPrice.create({ price: 17_00 })],
112
+ }),
113
+ ],
114
+ }),
115
+ }),
116
+ }).create();
117
+
118
+ await new BalanceItemFactory({
119
+ organizationId: organization.id,
120
+ orderId: order.id,
121
+ type: BalanceItemType.Order,
122
+ amount: 1,
123
+ unitPrice: order.data.totalPrice,
124
+ relations: new Map([
125
+ [BalanceItemRelationType.Webshop, BalanceItemRelation.create({ id: webshop.id, name: new TranslatedString(webshop.meta.name) })],
126
+ ]),
127
+ }).create();
128
+
129
+ const response = await getBreakdown({ organization, user });
130
+
131
+ expect(response.status).toBe(200);
132
+ expect(response.body.byCategory).toHaveLength(1);
133
+ expect(response.body.byArticle.map(g => ({ name: g.name.toString(), price: g.price, quantity: g.quantity, count: g.count }))).toEqual([
134
+ { name: 'T-shirt', price: 30_00, quantity: 2, count: 1 },
135
+ { name: 'Maat: XL', price: 4_00, quantity: 2, count: 1 },
136
+ ]);
137
+ });
138
+
139
+ test('canceled items are not counted as charged', async () => {
140
+ const organization = await new OrganizationFactory({}).create();
141
+ const user = await createFinanceUser(organization);
142
+
143
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen' });
144
+ await createItem(organization, { price: 999_00, groupId: 'group-a', groupName: 'Kapoenen', status: BalanceItemStatus.Canceled });
145
+
146
+ const response = await getBreakdown({ organization, user });
147
+
148
+ expect(response.status).toBe(200);
149
+ expect(response.body.price).toBe(40_00);
150
+ });
151
+
152
+ test('narrowing down to a category leaves only that category, and exports it', async () => {
153
+ const organization = await new OrganizationFactory({}).create();
154
+ const user = await createFinanceUser(organization);
155
+
156
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen' });
157
+ await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen' });
158
+
159
+ const all = await getBreakdown({ organization, user });
160
+ const category = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
161
+
162
+ const narrowed = await getBreakdown({
163
+ organization,
164
+ user,
165
+ path: [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: category.id })],
166
+ });
167
+
168
+ expect(narrowed.status).toBe(200);
169
+ expect(narrowed.body.price).toBe(40_00);
170
+ expect(narrowed.body.selection.filter).toMatchObject({
171
+ $and: [
172
+ { type: BalanceItemType.Registration },
173
+ { relations: { [BalanceItemRelationType.Group]: { id: 'group-a' } } },
174
+ ],
175
+ });
176
+ });
177
+
178
+ test('exporting a category gives back exactly the balance items that were shown', async () => {
179
+ const organization = await new OrganizationFactory({}).create();
180
+ const user = await createFinanceUser(organization);
181
+
182
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen' });
183
+ await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen' });
184
+
185
+ const all = await getBreakdown({ organization, user });
186
+ const category = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
187
+
188
+ const narrowed = await getBreakdown({
189
+ organization,
190
+ user,
191
+ path: [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: category.id })],
192
+ });
193
+
194
+ expect(narrowed.body.price).toBe(40_00);
195
+
196
+ // Running the export filter through the database selects the same balance items
197
+ const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.listFilter });
198
+ expect(exported.body.price).toBe(40_00);
199
+ expect(exported.body.balanceItemCount).toBe(1);
200
+ });
201
+
202
+ test('items without relations are a category per description, and export as one', async () => {
203
+ const organization = await new OrganizationFactory({}).create();
204
+ const user = await createFinanceUser(organization);
205
+
206
+ await new BalanceItemFactory({ organizationId: organization.id, type: BalanceItemType.Other, amount: 1, unitPrice: 30_00, description: 'Kampinschrijving' }).create();
207
+ await new BalanceItemFactory({ organizationId: organization.id, type: BalanceItemType.Other, amount: 1, unitPrice: 12_00, description: 'Drankkaart' }).create();
208
+
209
+ const all = await getBreakdown({ organization, user });
210
+ expect(all.body.byCategory.map(g => g.name.toString())).toEqual(['Kampinschrijving', 'Drankkaart']);
211
+
212
+ const category = all.body.byCategory.find(g => g.name.toString() === 'Drankkaart')!;
213
+ const narrowed = await getBreakdown({
214
+ organization,
215
+ user,
216
+ path: [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: category.id })],
217
+ });
218
+
219
+ expect(narrowed.body.price).toBe(12_00);
220
+
221
+ const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.listFilter });
222
+ expect(exported.body.price).toBe(12_00);
223
+ });
224
+
225
+ test('an article can be listed on its own, without the items that have an extra option', async () => {
226
+ const organization = await new OrganizationFactory({}).create();
227
+ const user = await createFinanceUser(organization);
228
+
229
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Standaardtarief' });
230
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Standaardtarief' });
231
+
232
+ // The same group and price, but for an option that was bought on top of it
233
+ await new BalanceItemFactory({
234
+ organizationId: organization.id,
235
+ type: BalanceItemType.Registration,
236
+ amount: 1,
237
+ unitPrice: 5_00,
238
+ relations: new Map([
239
+ [BalanceItemRelationType.Group, BalanceItemRelation.create({ id: 'group-a', name: new TranslatedString('Kapoenen') })],
240
+ [BalanceItemRelationType.GroupPrice, BalanceItemRelation.create({ id: 'price-Standaardtarief', name: new TranslatedString('Standaardtarief') })],
241
+ [BalanceItemRelationType.GroupOptionMenu, BalanceItemRelation.create({ id: 'menu-a', name: new TranslatedString('Kamp') })],
242
+ [BalanceItemRelationType.GroupOption, BalanceItemRelation.create({ id: 'option-a', name: new TranslatedString('Met overnachting') })],
243
+ ]),
244
+ }).create();
245
+
246
+ const all = await getBreakdown({ organization, user });
247
+ const article = all.body.byArticle.find(g => g.name.toString() === 'Inschrijving voor Kapoenen')!;
248
+
249
+ // Listing the article shows the two registrations, not the option that was bought on top
250
+ const listed = await getBreakdown({ organization, user, filter: article.selection!.listFilter });
251
+
252
+ expect(listed.body.price).toBe(80_00);
253
+ expect(listed.body.balanceItemCount).toBe(2);
254
+ });
255
+
256
+ describe('Grouping by payout', () => {
257
+ const march = Settlement.create({ id: 'stl_march', reference: '1234567.0312.01', settledAt: new Date(2026, 2, 12), amount: 0 });
258
+ const april = Settlement.create({ id: 'stl_april', reference: '1234567.0409.01', settledAt: new Date(2026, 3, 9), amount: 0 });
259
+
260
+ /**
261
+ * Pays a part of a balance item, the way a payment provider settles it afterwards.
262
+ */
263
+ const payItem = async (organization: Organization, balanceItem: BalanceItem, options: { price: number; settlement?: Settlement; status?: PaymentStatus; method?: PaymentMethod }) => {
264
+ const payment = new Payment();
265
+ payment.organizationId = organization.id;
266
+ payment.method = options.method ?? PaymentMethod.Bancontact;
267
+ payment.provider = payment.method === PaymentMethod.Bancontact ? PaymentProvider.Mollie : null;
268
+ payment.status = options.status ?? PaymentStatus.Succeeded;
269
+ payment.price = options.price;
270
+ payment.paidAt = new Date();
271
+ payment.settlement = options.settlement ?? null;
272
+ await payment.save();
273
+
274
+ const balanceItemPayment = new BalanceItemPayment();
275
+ balanceItemPayment.balanceItemId = balanceItem.id;
276
+ balanceItemPayment.paymentId = payment.id;
277
+ balanceItemPayment.organizationId = organization.id;
278
+ balanceItemPayment.price = options.price;
279
+ await balanceItemPayment.save();
280
+ };
281
+
282
+ test('a balance item that was paid out in parts is counted in every payout it was part of', async () => {
283
+ const organization = await new OrganizationFactory({}).create();
284
+ const user = await createFinanceUser(organization);
285
+
286
+ const first = await createItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen', pricePaid: 100_00 });
287
+ const second = await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen', pricePaid: 25_00 });
288
+
289
+ await payItem(organization, first, { price: 60_00, settlement: march });
290
+ await payItem(organization, first, { price: 40_00, settlement: april });
291
+ await payItem(organization, second, { price: 25_00, settlement: march });
292
+
293
+ const response = await getBreakdown({ organization, user });
294
+
295
+ expect(response.status).toBe(200);
296
+
297
+ // The price is still what was charged, the payouts only hold what was received
298
+ expect(response.body.price).toBe(125_00);
299
+ expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price, count: g.count }))).toEqual([
300
+ { name: '1234567.0312.01', price: 85_00, count: 2 },
301
+ { name: '1234567.0409.01', price: 40_00, count: 1 },
302
+ ]);
303
+ });
304
+
305
+ test('money that was not received gets a row per status instead of a payout', async () => {
306
+ const organization = await new OrganizationFactory({}).create();
307
+ const user = await createFinanceUser(organization);
308
+
309
+ const item = await createItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen', pricePaid: 40_00 });
310
+
311
+ await payItem(organization, item, { price: 40_00, settlement: march });
312
+ await payItem(organization, item, { price: 30_00, status: PaymentStatus.Pending });
313
+ await payItem(organization, item, { price: 20_00, status: PaymentStatus.Failed, settlement: april });
314
+
315
+ const response = await getBreakdown({ organization, user });
316
+
317
+ expect(response.status).toBe(200);
318
+ expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
319
+ { name: '1234567.0312.01', price: 40_00 },
320
+ { name: $t('In verwerking'), price: 30_00 },
321
+ // Only what was tried sits under the failed payment, the rest was never attempted
322
+ { name: $t('Mislukte betaling'), price: 20_00 },
323
+ { name: $t('Openstaand na mislukte poging'), price: 10_00 },
324
+ ]);
325
+
326
+ // Every part of what was charged ends up in exactly one row
327
+ expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
328
+
329
+ // Running the rows through the database gives back the balance items they were added up from
330
+ for (const name of [$t('Mislukte betaling'), $t('Openstaand na mislukte poging')]) {
331
+ const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
332
+ const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
333
+ expect(exported.body.balanceItemCount).toBe(1);
334
+ }
335
+ });
336
+
337
+ test('what was paid for something that is not owed anymore is money to pay back', async () => {
338
+ const organization = await new OrganizationFactory({}).create();
339
+ const user = await createFinanceUser(organization);
340
+
341
+ const canceled = await createItem(organization, { price: 50_00, groupId: 'group-a', groupName: 'Kapoenen', status: BalanceItemStatus.Canceled, pricePaid: 50_00 });
342
+ await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen' });
343
+
344
+ await payItem(organization, canceled, { price: 50_00, settlement: march });
345
+
346
+ const response = await getBreakdown({ organization, user });
347
+
348
+ expect(response.status).toBe(200);
349
+ expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
350
+ { name: '1234567.0312.01', price: 50_00 },
351
+ { name: $t('Terug te betalen'), price: -50_00 },
352
+ { name: $t('Openstaand'), price: 25_00 },
353
+ ]);
354
+
355
+ // A canceled item is not charged anymore, so it doesn't add to what is open
356
+ expect(response.body.bySettlement.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
357
+
358
+ // Each row selects exactly the balance items it was added up from
359
+ const refund = response.body.bySettlement.find(g => g.name.toString() === $t('Terug te betalen'))!;
360
+ const refunded = await getBreakdown({ organization, user, filter: refund.selection!.listFilter });
361
+ expect(refunded.body.balanceItemCount).toBe(1);
362
+
363
+ const open = response.body.bySettlement.find(g => g.name.toString() === $t('Openstaand'))!;
364
+ const stillOpen = await getBreakdown({ organization, user, filter: open.selection!.listFilter });
365
+ expect(stillOpen.body.balanceItemCount).toBe(1);
366
+ expect(stillOpen.body.price).toBe(25_00);
367
+ });
368
+
369
+ test('what is still open is split over whether paying it was already tried', async () => {
370
+ const organization = await new OrganizationFactory({}).create();
371
+ const user = await createFinanceUser(organization);
372
+
373
+ const tried = await createItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
374
+ const untried = await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen' });
375
+ const paid = await createItem(organization, { price: 40_00, groupId: 'group-c', groupName: 'Jonggivers', pricePaid: 40_00 });
376
+
377
+ await payItem(organization, tried, { price: 100_00, status: PaymentStatus.Failed });
378
+ await payItem(organization, paid, { price: 40_00, settlement: march });
379
+
380
+ const response = await getBreakdown({ organization, user });
381
+
382
+ expect(response.status).toBe(200);
383
+ expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
384
+ { name: $t('Mislukte betaling'), price: 100_00 },
385
+ { name: '1234567.0312.01', price: 40_00 },
386
+ { name: $t('Openstaand'), price: 25_00 },
387
+ ]);
388
+
389
+ // Both rows select exactly the balance items they were added up from
390
+ for (const [name, count] of [[$t('Mislukte betaling'), 1], [$t('Openstaand'), 1]] as [string, number][]) {
391
+ const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
392
+ const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
393
+ expect(exported.body.balanceItemCount).toBe(count);
394
+ }
395
+ });
396
+
397
+ test('exporting what is still being processed gives back exactly those balance items', async () => {
398
+ const organization = await new OrganizationFactory({}).create();
399
+ const user = await createFinanceUser(organization);
400
+
401
+ const paid = await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', pricePaid: 40_00 });
402
+ const processing = await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen', pricePending: 25_00 });
403
+
404
+ await payItem(organization, paid, { price: 40_00, settlement: march });
405
+ await payItem(organization, processing, { price: 25_00, status: PaymentStatus.Pending });
406
+
407
+ const all = await getBreakdown({ organization, user });
408
+ const row = all.body.bySettlement.find(g => g.name.toString() === $t('In verwerking'))!;
409
+
410
+ const narrowed = await getBreakdown({
411
+ organization,
412
+ user,
413
+ path: [BreakdownPathItem.create({ tab: BreakdownTab.Settlement, id: row.id })],
414
+ });
415
+
416
+ expect(narrowed.status).toBe(200);
417
+ expect(narrowed.body.balanceItemCount).toBe(1);
418
+ expect(narrowed.body.price).toBe(25_00);
419
+
420
+ // Running the export filter through the database selects the same balance items
421
+ const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.listFilter });
422
+ expect(exported.body.balanceItemCount).toBe(1);
423
+ expect(exported.body.price).toBe(25_00);
424
+ });
425
+
426
+ test('nothing is broken down per payout when no provider has to pay out', async () => {
427
+ const organization = await new OrganizationFactory({}).create();
428
+ const user = await createFinanceUser(organization);
429
+
430
+ const item = await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', pricePaid: 40_00 });
431
+ await payItem(organization, item, { price: 40_00, method: PaymentMethod.Transfer });
432
+
433
+ const response = await getBreakdown({ organization, user });
434
+
435
+ expect(response.status).toBe(200);
436
+ expect(response.body.bySettlement).toEqual([]);
437
+ });
438
+
439
+ test('exporting a payout gives back exactly the balance items that were shown', async () => {
440
+ const organization = await new OrganizationFactory({}).create();
441
+ const user = await createFinanceUser(organization);
442
+
443
+ const first = await createItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen', pricePaid: 100_00 });
444
+ const second = await createItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen', pricePaid: 25_00 });
445
+
446
+ await payItem(organization, first, { price: 60_00, settlement: march });
447
+ await payItem(organization, first, { price: 40_00, settlement: april });
448
+ await payItem(organization, second, { price: 25_00, settlement: april });
449
+
450
+ const all = await getBreakdown({ organization, user });
451
+ const payout = all.body.bySettlement.find(g => g.name.toString() === '1234567.0312.01')!;
452
+
453
+ const narrowed = await getBreakdown({
454
+ organization,
455
+ user,
456
+ path: [BreakdownPathItem.create({ tab: BreakdownTab.Settlement, id: payout.id })],
457
+ });
458
+
459
+ // The amount the row showed, not what the item behind it was charged
460
+ expect(narrowed.status).toBe(200);
461
+ expect(narrowed.body.balanceItemCount).toBe(1);
462
+ expect(narrowed.body.price).toBe(60_00);
463
+ expect(narrowed.body.price).toBe(payout.price);
464
+ expect(narrowed.body.selection.amountType).toBe(BreakdownAmountType.Paid);
465
+
466
+ // The item is worth more than what this payout holds, so the list behind it holds more
467
+ expect(narrowed.body.selection.isListPartial).toBe(true);
468
+
469
+ // Running the export filter through the database selects the same balance items
470
+ const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.listFilter });
471
+ expect(exported.body.balanceItemCount).toBe(1);
472
+ expect(exported.body.price).toBe(100_00);
473
+
474
+ // But the export itself holds the €60 that landed in this payout, not the whole €100 item
475
+ const slice = await exportSlice({ organization, user, filter: narrowed.body.selection.filter });
476
+ expect(slice.price).toBe(60_00);
477
+ expect(slice.count).toBe(1);
478
+ });
479
+
480
+ test('a payout row exports the money that landed in it, not the whole balance items', async () => {
481
+ const organization = await new OrganizationFactory({}).create();
482
+ const user = await createFinanceUser(organization);
483
+
484
+ const item = await createItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen', pricePaid: 100_00 });
485
+ await payItem(organization, item, { price: 60_00, settlement: march });
486
+ await payItem(organization, item, { price: 40_00, settlement: april });
487
+
488
+ const all = await getBreakdown({ organization, user });
489
+ const row = all.body.bySettlement.find(g => g.name.toString() === '1234567.0312.01')!;
490
+
491
+ expect(row.price).toBe(60_00);
492
+ expect(row.selection!.objectType).toBe(BreakdownObjectType.BalanceItemPayments);
493
+
494
+ const slice = await exportSlice({ organization, user, filter: row.selection!.filter });
495
+ expect(slice.price).toBe(60_00);
496
+ expect(slice.count).toBe(1);
497
+ });
498
+ });
499
+
500
+ test('the breakdown url is not matched by the endpoint of a single balance item', async () => {
501
+ const organization = await new OrganizationFactory({}).create();
502
+
503
+ const request = Request.get({
504
+ path: '/balance-items/breakdown',
505
+ host: organization.getApiHost(),
506
+ });
507
+
508
+ expect(await new GetBalanceItemEndpoint().decode(request)).toBeNull();
509
+ });
510
+
511
+ test('never includes balance items of another organization', async () => {
512
+ const organization = await new OrganizationFactory({}).create();
513
+ const otherOrganization = await new OrganizationFactory({}).create();
514
+ const user = await createFinanceUser(organization);
515
+
516
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen' });
517
+ await createItem(otherOrganization, { price: 999_00, groupId: 'group-x', groupName: 'Andere' });
518
+
519
+ const response = await getBreakdown({ organization, user });
520
+
521
+ expect(response.status).toBe(200);
522
+ expect(response.body.price).toBe(40_00);
523
+ });
524
+
525
+ test('a user without permissions is not allowed to see the breakdown', async () => {
526
+ const organization = await new OrganizationFactory({}).create();
527
+ const user = await new UserFactory({ organization }).create();
528
+
529
+ await expect(getBreakdown({ organization, user })).rejects.toThrow();
530
+ });
531
+
532
+ test('an admin without access to the finances is not allowed to see the breakdown', async () => {
533
+ const organization = await new OrganizationFactory({}).create();
534
+ const user = await new UserFactory({
535
+ organization,
536
+ permissions: Permissions.create({ level: PermissionLevel.Read }),
537
+ }).create();
538
+
539
+ await createItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen' });
540
+
541
+ await expect(getBreakdown({ organization, user })).rejects.toThrow();
542
+ });
543
+ });
@@ -0,0 +1,78 @@
1
+ import type { Decoder } from '@simonbackx/simple-encoding';
2
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
+ import type { BalanceItem as BalanceItemStruct } from '@stamhoofd/structures';
5
+ import { BalanceItemBreakdownBuilder } from '@stamhoofd/structures/breakdown/BalanceItemBreakdownBuilder.js';
6
+ import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
7
+ import type { BalanceItemBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
8
+ import { loadOrdersForBreakdown, loadPaymentsForBreakdown } from '../../../../helpers/breakdownRelations.js';
9
+ import { Context } from '../../../../helpers/Context.js';
10
+ import { streamForBreakdown } from '../../../../helpers/streamForBreakdown.js';
11
+ import { GetBalanceItemsEndpoint } from './GetBalanceItemsEndpoint.js';
12
+
13
+ type Params = Record<string, never>;
14
+ type Query = BreakdownRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = BalanceItemBreakdown;
17
+
18
+ /**
19
+ * Breaks a selection of balance items down into what was charged, per category and per article.
20
+ *
21
+ * Reads the same balance items the Excel export would and groups them with the same rules the rest of
22
+ * the app uses (see BalanceItemBreakdownBuilder in @stamhoofd/structures).
23
+ */
24
+ export class GetBalanceItemBreakdownEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
25
+ queryDecoder = BreakdownRequest as Decoder<BreakdownRequest>;
26
+
27
+ protected doesMatch(request: Request): [true, Params] | [false] {
28
+ if (request.method !== 'GET') {
29
+ return [false];
30
+ }
31
+
32
+ const params = Endpoint.parseParameters(request.url, '/balance-items/breakdown', {});
33
+
34
+ if (params) {
35
+ return [true, params as Params];
36
+ }
37
+ return [false];
38
+ }
39
+
40
+ async handle(request: DecodedRequest<Params, Query, Body>) {
41
+ await Context.setOrganizationScope();
42
+ const { user } = await Context.authenticate();
43
+
44
+ const organization = Context.organization;
45
+
46
+ if (!organization) {
47
+ throw Context.auth.error();
48
+ }
49
+
50
+ if (!await Context.auth.canManagePayments(organization.id)) {
51
+ throw Context.auth.error();
52
+ }
53
+
54
+ const builder = new BalanceItemBreakdownBuilder(request.query.path);
55
+
56
+ await streamForBreakdown<BalanceItemStruct>({
57
+ userId: user.id,
58
+ filter: request.query.readFilter,
59
+ search: request.query.search,
60
+ count: async (countRequest) => {
61
+ return await (await GetBalanceItemsEndpoint.buildQuery(countRequest)).count();
62
+ },
63
+ fetch: async (pageRequest) => {
64
+ return await GetBalanceItemsEndpoint.buildData(pageRequest);
65
+ },
66
+ handle: async (items) => {
67
+ const [orders, balanceItemPayments] = await Promise.all([
68
+ loadOrdersForBreakdown(items.map(item => item.orderId)),
69
+ loadPaymentsForBreakdown(items.map(item => item.id)),
70
+ ]);
71
+
72
+ builder.add(items, { orders, balanceItemPayments });
73
+ },
74
+ });
75
+
76
+ return new Response(builder.build(request.query.filter));
77
+ }
78
+ }
@@ -20,7 +20,9 @@ export class GetBalanceItemEndpoint extends Endpoint<Params, Query, Body, Respon
20
20
 
21
21
  const params = Endpoint.parseParameters(request.url, '/balance-items/@id', { id: String });
22
22
 
23
- if (params && params.id !== 'count') {
23
+ // The endpoints for /balance-items/count and /balance-items/breakdown live in this same folder,
24
+ // so they are not guaranteed to be matched before this one
25
+ if (params && params.id !== 'count' && params.id !== 'breakdown') {
24
26
  return [true, params as Params];
25
27
  }
26
28
  return [false];