@stamhoofd/backend 2.138.2 → 2.140.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/fake-settlements.test.ts +165 -0
- package/src/crons/fake-settlements.ts +117 -0
- package/src/crons/index.ts +1 -0
- package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
- package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
- package/src/endpoints/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/MFA.test.ts +187 -3
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -1
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
- package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
- package/src/excel-loaders/balance-item-payments.ts +145 -0
- package/src/excel-loaders/index.ts +1 -0
- package/src/excel-loaders/payments.ts +45 -37
- package/src/helpers/StripeInvoicer.ts +1 -1
- package/src/helpers/TwoFactorHelper.ts +91 -9
- package/src/helpers/breakdownRelations.ts +48 -0
- package/src/helpers/getNextPageRequest.ts +24 -0
- package/src/helpers/streamForBreakdown.test.ts +107 -0
- package/src/helpers/streamForBreakdown.ts +104 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/PasswordForgotService.ts +31 -1
- package/src/services/PaymentService.ts +9 -1
- package/src/services/SSOService.ts +14 -1
- package/src/sql-filters/balance-item-payments-root.ts +48 -0
- package/src/sql-filters/balance-items.ts +55 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +68 -0
- package/src/sql-filters/payment-settlement.ts +43 -0
- package/src/sql-filters/payments.ts +93 -31
- package/src/sql-sorters/balance-item-payments.ts +32 -0
- package/tests/filters/orders.test.ts +635 -0
- package/tests/helpers/ExportSlice.ts +71 -0
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { BalanceItem, Organization, User } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItemFactory, BalanceItemPayment, OrderFactory, OrganizationFactory, Payment, StripeAccount, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
|
+
import type { StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemOption, CartItemPrice, Customer, getBalanceItemTypeIcon, getPaymentProviderName, OptionMenu, OrderData, Option, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PermissionLevel, Permissions, Product, ProductPrice, Settlement, StripeBusinessProfile, StripeMetaData, TransferSettings, TranslatedString } from '@stamhoofd/structures';
|
|
6
|
+
import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
|
|
7
|
+
import type { PaymentBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
8
|
+
import { BreakdownGraphUnit, BreakdownObjectType, BreakdownPathItem, BreakdownTab } from '@stamhoofd/structures/PaymentBreakdown.js';
|
|
9
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
10
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
11
|
+
import { exportSlice } from '../../../../../tests/helpers/ExportSlice.js';
|
|
12
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
13
|
+
import { GetPaymentEndpoint } from '../../shared/GetPaymentEndpoint.js';
|
|
14
|
+
import { GetPaymentBreakdownEndpoint } from './GetPaymentBreakdownEndpoint.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What was ordered: the name of the product, its unit price, how many, and optionally one chosen
|
|
18
|
+
* option (menu name, option name, option price).
|
|
19
|
+
*/
|
|
20
|
+
type OrderedProduct = [string, number, number, [string, string, number]?];
|
|
21
|
+
|
|
22
|
+
describe('Endpoint.GetPaymentBreakdownEndpoint', () => {
|
|
23
|
+
const endpoint = new GetPaymentBreakdownEndpoint();
|
|
24
|
+
|
|
25
|
+
const getBreakdown = async ({ filter, path, search, narrowFilter, organization, user }: { filter?: StamhoofdFilter; path?: BreakdownPathItem[]; search?: string; narrowFilter?: StamhoofdFilter; organization: Organization; user: User }) => {
|
|
26
|
+
const token = await Token.createToken(user);
|
|
27
|
+
|
|
28
|
+
const request = Request.get({
|
|
29
|
+
path: '/payments/breakdown',
|
|
30
|
+
host: organization.getApiHost(),
|
|
31
|
+
query: new BreakdownRequest({
|
|
32
|
+
filter: filter ?? null,
|
|
33
|
+
path: path ?? [],
|
|
34
|
+
search: search ?? null,
|
|
35
|
+
narrowFilter: narrowFilter ?? null,
|
|
36
|
+
}),
|
|
37
|
+
headers: {
|
|
38
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return testServer.test<PaymentBreakdown>(endpoint, request);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An order with a few products in its cart, so it can be broken down per article.
|
|
47
|
+
*/
|
|
48
|
+
const createOrderData = (products: OrderedProduct[], extra: { fixedDiscount?: number; percentageDiscount?: number; administrationFee?: number } = {}) => {
|
|
49
|
+
// Two lines of the same product are the same product in a webshop, and its options are shared
|
|
50
|
+
const catalog = new Map<string, { product: Product; optionMenus: Map<string, OptionMenu> }>();
|
|
51
|
+
|
|
52
|
+
const getProduct = (name: string, unitPrice: number) => {
|
|
53
|
+
let entry = catalog.get(name);
|
|
54
|
+
|
|
55
|
+
if (!entry) {
|
|
56
|
+
entry = {
|
|
57
|
+
product: Product.create({ name, prices: [ProductPrice.create({ name: 'Standaard', price: unitPrice })] }),
|
|
58
|
+
optionMenus: new Map(),
|
|
59
|
+
};
|
|
60
|
+
catalog.set(name, entry);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return entry;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getOption = (name: string, unitPrice: number, [menuName, optionName, optionPrice]: [string, string, number]) => {
|
|
67
|
+
const { product, optionMenus } = getProduct(name, unitPrice);
|
|
68
|
+
let menu = optionMenus.get(menuName);
|
|
69
|
+
|
|
70
|
+
if (!menu) {
|
|
71
|
+
menu = OptionMenu.create({ name: menuName });
|
|
72
|
+
optionMenus.set(menuName, menu);
|
|
73
|
+
product.optionMenus.push(menu);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let option = menu.options.find(o => o.name === optionName);
|
|
77
|
+
|
|
78
|
+
if (!option) {
|
|
79
|
+
option = Option.create({ name: optionName, price: optionPrice });
|
|
80
|
+
menu.options.push(option);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return CartItemOption.create({ optionMenu: menu, option });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return OrderData.create({
|
|
87
|
+
...extra,
|
|
88
|
+
customer: Customer.create({ firstName: 'Eva', lastName: 'Peeters', email: 'eva@example.com' }),
|
|
89
|
+
cart: Cart.create({
|
|
90
|
+
items: products.map(([name, unitPrice, amount, option]) => {
|
|
91
|
+
const { product } = getProduct(name, unitPrice);
|
|
92
|
+
const options = option ? [getOption(name, unitPrice, option)] : [];
|
|
93
|
+
const priceWithOptions = unitPrice + options.reduce((total, o) => total + o.option.price, 0);
|
|
94
|
+
|
|
95
|
+
return CartItem.create({
|
|
96
|
+
product,
|
|
97
|
+
productPrice: product.prices[0],
|
|
98
|
+
options,
|
|
99
|
+
amount,
|
|
100
|
+
// Normally calculated during checkout
|
|
101
|
+
unitPrice: priceWithOptions,
|
|
102
|
+
calculatedPrices: Array.from({ length: amount }, () => CartItemPrice.create({ price: priceWithOptions })),
|
|
103
|
+
});
|
|
104
|
+
}),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const createFinanceUser = async (organization: Organization) => {
|
|
110
|
+
return await new UserFactory({
|
|
111
|
+
organization,
|
|
112
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
113
|
+
}).create();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const createRegistrationItem = async (organization: Organization, options: { price: number; groupId: string; groupName: string; priceName?: string }) => {
|
|
117
|
+
const relations = new Map([
|
|
118
|
+
[BalanceItemRelationType.Group, BalanceItemRelation.create({ id: options.groupId, name: new TranslatedString(options.groupName) })],
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
if (options.priceName) {
|
|
122
|
+
relations.set(
|
|
123
|
+
BalanceItemRelationType.GroupPrice,
|
|
124
|
+
BalanceItemRelation.create({ id: 'price-' + options.priceName, name: new TranslatedString(options.priceName) }),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return await new BalanceItemFactory({
|
|
129
|
+
organizationId: organization.id,
|
|
130
|
+
type: BalanceItemType.Registration,
|
|
131
|
+
amount: 1,
|
|
132
|
+
unitPrice: options.price,
|
|
133
|
+
relations,
|
|
134
|
+
}).create();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const createPayment = async (organization: Organization, options: {
|
|
138
|
+
items: [BalanceItem, number][];
|
|
139
|
+
method?: PaymentMethod;
|
|
140
|
+
provider?: PaymentProvider | null;
|
|
141
|
+
status?: PaymentStatus;
|
|
142
|
+
iban?: string;
|
|
143
|
+
transferDescription?: string;
|
|
144
|
+
transferFee?: number;
|
|
145
|
+
settlement?: Settlement;
|
|
146
|
+
/**
|
|
147
|
+
* What this payment rounded away, because a payment goes to the cent while what was charged
|
|
148
|
+
* goes to four digits after the comma.
|
|
149
|
+
*/
|
|
150
|
+
roundingAmount?: number;
|
|
151
|
+
}) => {
|
|
152
|
+
const payment = new Payment();
|
|
153
|
+
payment.organizationId = organization.id;
|
|
154
|
+
payment.method = options.method ?? PaymentMethod.Transfer;
|
|
155
|
+
payment.provider = options.provider ?? null;
|
|
156
|
+
payment.status = options.status ?? PaymentStatus.Succeeded;
|
|
157
|
+
payment.roundingAmount = options.roundingAmount ?? 0;
|
|
158
|
+
payment.price = options.items.reduce((total, [_, price]) => total + price, 0) + payment.roundingAmount;
|
|
159
|
+
payment.paidAt = new Date();
|
|
160
|
+
payment.transferFee = options.transferFee ?? 0;
|
|
161
|
+
payment.settlement = options.settlement ?? null;
|
|
162
|
+
payment.transferDescription = options.transferDescription ?? null;
|
|
163
|
+
|
|
164
|
+
if (options.iban) {
|
|
165
|
+
payment.transferSettings = TransferSettings.create({ iban: options.iban, creditor: 'Hoofdrekening' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await payment.save();
|
|
169
|
+
|
|
170
|
+
for (const [balanceItem, price] of options.items) {
|
|
171
|
+
const balanceItemPayment = new BalanceItemPayment();
|
|
172
|
+
balanceItemPayment.balanceItemId = balanceItem.id;
|
|
173
|
+
balanceItemPayment.paymentId = payment.id;
|
|
174
|
+
balanceItemPayment.organizationId = organization.id;
|
|
175
|
+
balanceItemPayment.price = price;
|
|
176
|
+
await balanceItemPayment.save();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return payment;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
describe('The three ways a selection is split up', () => {
|
|
183
|
+
test('per account, per category and per article', async () => {
|
|
184
|
+
const organization = await new OrganizationFactory({}).create();
|
|
185
|
+
const user = await createFinanceUser(organization);
|
|
186
|
+
|
|
187
|
+
const standard = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Standaardtarief' });
|
|
188
|
+
const reduced = await createRegistrationItem(organization, { price: 20_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Verminderd tarief' });
|
|
189
|
+
const welpen = await createRegistrationItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen', priceName: 'Standaardtarief' });
|
|
190
|
+
|
|
191
|
+
await createPayment(organization, { items: [[standard, 40_00]], iban: 'BE68539007547034' });
|
|
192
|
+
await createPayment(organization, { items: [[reduced, 20_00]], method: PaymentMethod.PointOfSale });
|
|
193
|
+
await createPayment(organization, { items: [[welpen, 25_00]], iban: 'BE68539007547034' });
|
|
194
|
+
|
|
195
|
+
const response = await getBreakdown({ organization, user });
|
|
196
|
+
|
|
197
|
+
expect(response.status).toBe(200);
|
|
198
|
+
expect(response.body.price).toBe(85_00);
|
|
199
|
+
expect(response.body.paymentCount).toBe(3);
|
|
200
|
+
|
|
201
|
+
// Where the money arrived: transfers to the same account are one row
|
|
202
|
+
expect(response.body.byAccount.map(g => ({ name: g.name.toString(), price: g.price, icon: g.icon }))).toEqual([
|
|
203
|
+
{ name: 'Hoofdrekening', price: 65_00, icon: 'bank' },
|
|
204
|
+
{ name: 'Ter plaatse', price: 20_00, icon: 'card' },
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
// What it was for
|
|
208
|
+
expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
209
|
+
{ name: 'Kapoenen', price: 60_00 },
|
|
210
|
+
{ name: 'Welpen', price: 25_00 },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
// Which article: the two prices of Kapoenen are separate rows
|
|
214
|
+
expect(response.body.byArticle).toHaveLength(3);
|
|
215
|
+
expect(response.body.byArticle.every(g => !g.canNarrowDown)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a group can be shown like a balance item, with an icon and its relations', async () => {
|
|
219
|
+
const organization = await new OrganizationFactory({}).create();
|
|
220
|
+
const user = await createFinanceUser(organization);
|
|
221
|
+
|
|
222
|
+
const item = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-a', groupName: 'Kapoenen', priceName: 'Standaardtarief' });
|
|
223
|
+
await createPayment(organization, { items: [[item, 40_00]] });
|
|
224
|
+
|
|
225
|
+
const response = await getBreakdown({ organization, user });
|
|
226
|
+
|
|
227
|
+
expect(response.status).toBe(200);
|
|
228
|
+
|
|
229
|
+
const category = response.body.byCategory[0];
|
|
230
|
+
expect(category.icon).toBe(getBalanceItemTypeIcon(BalanceItemType.Registration));
|
|
231
|
+
expect(category.relations.get(BalanceItemRelationType.Group)?.name.toString()).toBe('Kapoenen');
|
|
232
|
+
|
|
233
|
+
const article = response.body.byArticle[0];
|
|
234
|
+
expect(article.name.toString()).toContain('Kapoenen');
|
|
235
|
+
expect(article.relations.get(BalanceItemRelationType.GroupPrice)?.name.toString()).toBe('Standaardtarief');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('Webshop orders', () => {
|
|
240
|
+
test('an order is charged as one balance item, so the articles come from the order itself', async () => {
|
|
241
|
+
const organization = await new OrganizationFactory({}).create();
|
|
242
|
+
const user = await createFinanceUser(organization);
|
|
243
|
+
|
|
244
|
+
const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
245
|
+
const order = await new OrderFactory({ webshop, data: createOrderData([['T-shirt', 15_00, 2], ['Wafels', 6_00, 3]]) }).create();
|
|
246
|
+
|
|
247
|
+
const balanceItem = await new BalanceItemFactory({
|
|
248
|
+
organizationId: organization.id,
|
|
249
|
+
orderId: order.id,
|
|
250
|
+
type: BalanceItemType.Order,
|
|
251
|
+
amount: 1,
|
|
252
|
+
unitPrice: order.data.totalPrice,
|
|
253
|
+
relations: new Map([
|
|
254
|
+
[BalanceItemRelationType.Webshop, BalanceItemRelation.create({ id: webshop.id, name: new TranslatedString(webshop.meta.name) })],
|
|
255
|
+
]),
|
|
256
|
+
}).create();
|
|
257
|
+
|
|
258
|
+
await createPayment(organization, { items: [[balanceItem, order.data.totalPrice]] });
|
|
259
|
+
|
|
260
|
+
const response = await getBreakdown({ organization, user });
|
|
261
|
+
|
|
262
|
+
expect(response.status).toBe(200);
|
|
263
|
+
|
|
264
|
+
// One category for the webshop, but a row per ordered product
|
|
265
|
+
expect(response.body.byCategory).toHaveLength(1);
|
|
266
|
+
expect(response.body.byArticle.map(g => ({ name: g.name.toString(), price: g.price, quantity: g.quantity }))).toEqual([
|
|
267
|
+
{ name: 'T-shirt', price: 30_00, quantity: 2 },
|
|
268
|
+
{ name: 'Wafels', price: 18_00, quantity: 3 },
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
// An order is one balance item, so its articles can't be selected on their own
|
|
272
|
+
expect(response.body.byArticle.every(g => g.selection === null)).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('the options that were chosen for a product are counted on their own', async () => {
|
|
276
|
+
const organization = await new OrganizationFactory({}).create();
|
|
277
|
+
const user = await createFinanceUser(organization);
|
|
278
|
+
|
|
279
|
+
const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
280
|
+
const order = await new OrderFactory({
|
|
281
|
+
webshop,
|
|
282
|
+
data: createOrderData([
|
|
283
|
+
['T-shirt', 15_00, 2, ['Maat', 'XL', 2_00]],
|
|
284
|
+
['T-shirt', 15_00, 1, ['Maat', 'S', 0]],
|
|
285
|
+
]),
|
|
286
|
+
}).create();
|
|
287
|
+
|
|
288
|
+
const balanceItem = await new BalanceItemFactory({
|
|
289
|
+
organizationId: organization.id,
|
|
290
|
+
orderId: order.id,
|
|
291
|
+
type: BalanceItemType.Order,
|
|
292
|
+
amount: 1,
|
|
293
|
+
unitPrice: order.data.totalPrice,
|
|
294
|
+
}).create();
|
|
295
|
+
|
|
296
|
+
await createPayment(organization, { items: [[balanceItem, order.data.totalPrice]] });
|
|
297
|
+
|
|
298
|
+
const response = await getBreakdown({ organization, user });
|
|
299
|
+
|
|
300
|
+
expect(response.status).toBe(200);
|
|
301
|
+
|
|
302
|
+
// The t-shirts are one row of 3 at their own price, the options are rows of their own
|
|
303
|
+
expect(response.body.byArticle.map(g => ({ name: g.name.toString(), price: g.price, quantity: g.quantity }))).toEqual([
|
|
304
|
+
{ name: 'T-shirt', price: 45_00, quantity: 3 },
|
|
305
|
+
{ name: 'Maat: XL', price: 4_00, quantity: 2 },
|
|
306
|
+
{ name: 'Maat: S', price: 0, quantity: 1 },
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
// Everything of the order is accounted for
|
|
310
|
+
expect(response.body.byArticle.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('a discount code and the administration costs are articles of their own', async () => {
|
|
314
|
+
const organization = await new OrganizationFactory({}).create();
|
|
315
|
+
const user = await createFinanceUser(organization);
|
|
316
|
+
|
|
317
|
+
const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
318
|
+
const order = await new OrderFactory({
|
|
319
|
+
webshop,
|
|
320
|
+
data: createOrderData([['T-shirt', 10_00, 2]], { fixedDiscount: 5_00, administrationFee: 1_00 }),
|
|
321
|
+
}).create();
|
|
322
|
+
|
|
323
|
+
expect(order.data.totalPrice).toBe(16_00);
|
|
324
|
+
|
|
325
|
+
const balanceItem = await new BalanceItemFactory({
|
|
326
|
+
organizationId: organization.id,
|
|
327
|
+
orderId: order.id,
|
|
328
|
+
type: BalanceItemType.Order,
|
|
329
|
+
amount: 1,
|
|
330
|
+
unitPrice: order.data.totalPrice,
|
|
331
|
+
}).create();
|
|
332
|
+
|
|
333
|
+
await createPayment(organization, { items: [[balanceItem, order.data.totalPrice]] });
|
|
334
|
+
|
|
335
|
+
const response = await getBreakdown({ organization, user });
|
|
336
|
+
|
|
337
|
+
expect(response.status).toBe(200);
|
|
338
|
+
expect(response.body.byArticle.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
339
|
+
{ name: 'T-shirt', price: 20_00 },
|
|
340
|
+
{ name: 'Korting', price: -5_00 },
|
|
341
|
+
{ name: 'Administratiekosten', price: 1_00 },
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
// A discount that is not part of a cart line still has to be accounted for
|
|
345
|
+
expect(response.body.byArticle.reduce((total, g) => total + g.price, 0)).toBe(response.body.price);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('an order that was not paid in full is not attributed to single articles', async () => {
|
|
349
|
+
const organization = await new OrganizationFactory({}).create();
|
|
350
|
+
const user = await createFinanceUser(organization);
|
|
351
|
+
|
|
352
|
+
const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
353
|
+
const order = await new OrderFactory({ webshop, data: createOrderData([['T-shirt', 15_00, 2]]) }).create();
|
|
354
|
+
|
|
355
|
+
const balanceItem = await new BalanceItemFactory({
|
|
356
|
+
organizationId: organization.id,
|
|
357
|
+
orderId: order.id,
|
|
358
|
+
type: BalanceItemType.Order,
|
|
359
|
+
amount: 1,
|
|
360
|
+
unitPrice: order.data.totalPrice,
|
|
361
|
+
}).create();
|
|
362
|
+
|
|
363
|
+
// Only a part of the order was paid
|
|
364
|
+
await createPayment(organization, { items: [[balanceItem, 1_00]] });
|
|
365
|
+
|
|
366
|
+
const response = await getBreakdown({ organization, user });
|
|
367
|
+
|
|
368
|
+
expect(response.status).toBe(200);
|
|
369
|
+
expect(response.body.byArticle).toHaveLength(1);
|
|
370
|
+
expect(response.body.byArticle[0].name.toString()).toBe($t('%Zjb'));
|
|
371
|
+
expect(response.body.byArticle[0].price).toBe(1_00);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('an order that was paid for more than it costs was changed afterwards', async () => {
|
|
375
|
+
const organization = await new OrganizationFactory({}).create();
|
|
376
|
+
const user = await createFinanceUser(organization);
|
|
377
|
+
|
|
378
|
+
const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
|
|
379
|
+
const order = await new OrderFactory({ webshop, data: createOrderData([['T-shirt', 15_00, 2]]) }).create();
|
|
380
|
+
|
|
381
|
+
const balanceItem = await new BalanceItemFactory({
|
|
382
|
+
organizationId: organization.id,
|
|
383
|
+
orderId: order.id,
|
|
384
|
+
type: BalanceItemType.Order,
|
|
385
|
+
amount: 1,
|
|
386
|
+
unitPrice: order.data.totalPrice,
|
|
387
|
+
}).create();
|
|
388
|
+
|
|
389
|
+
await createPayment(organization, { items: [[balanceItem, 40_00]] });
|
|
390
|
+
|
|
391
|
+
const response = await getBreakdown({ organization, user });
|
|
392
|
+
|
|
393
|
+
expect(response.status).toBe(200);
|
|
394
|
+
expect(response.body.byArticle.map(g => g.name.toString())).toEqual([$t('%Zik')]);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('What came in over time', () => {
|
|
399
|
+
test('the money is counted per day, without the days nothing came in', async () => {
|
|
400
|
+
const organization = await new OrganizationFactory({}).create();
|
|
401
|
+
const user = await createFinanceUser(organization);
|
|
402
|
+
|
|
403
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
404
|
+
|
|
405
|
+
// Midday of two fixed days, so the days in between never change with the clock: 48 hours
|
|
406
|
+
// earlier is a different calendar day around a daylight saving switch
|
|
407
|
+
const noon = (day: number) => Formatter.luxon(new Date()).set({ year: 2026, month: 3, day, hour: 12, minute: 0, second: 0, millisecond: 0 }).toJSDate();
|
|
408
|
+
|
|
409
|
+
const first = await createPayment(organization, { items: [[item, 40_00]] });
|
|
410
|
+
first.paidAt = noon(2);
|
|
411
|
+
await first.save();
|
|
412
|
+
|
|
413
|
+
const second = await createPayment(organization, { items: [[item, 25_00]] });
|
|
414
|
+
second.paidAt = noon(4);
|
|
415
|
+
await second.save();
|
|
416
|
+
|
|
417
|
+
const response = await getBreakdown({ organization, user });
|
|
418
|
+
|
|
419
|
+
expect(response.status).toBe(200);
|
|
420
|
+
expect(response.body.graph.unit).toBe(BreakdownGraphUnit.Day);
|
|
421
|
+
expect(response.body.graph.points.map(p => p.price)).toEqual([40_00, 25_00]);
|
|
422
|
+
|
|
423
|
+
// The empty day in between is only added where the graph is drawn
|
|
424
|
+
expect(response.body.graph.filledPoints.map(p => p.price)).toEqual([40_00, 0, 25_00]);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('Narrowing down to one group', () => {
|
|
429
|
+
test('only the part of a payment that belongs to that group is left', async () => {
|
|
430
|
+
const organization = await new OrganizationFactory({}).create();
|
|
431
|
+
const user = await createFinanceUser(organization);
|
|
432
|
+
|
|
433
|
+
const registration = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
434
|
+
const fee = await new BalanceItemFactory({
|
|
435
|
+
organizationId: organization.id,
|
|
436
|
+
type: BalanceItemType.AdministrationFee,
|
|
437
|
+
amount: 1,
|
|
438
|
+
unitPrice: 40_00,
|
|
439
|
+
}).create();
|
|
440
|
+
|
|
441
|
+
// One payment that paid both
|
|
442
|
+
await createPayment(organization, { items: [[registration, 60_00], [fee, 40_00]] });
|
|
443
|
+
|
|
444
|
+
const all = await getBreakdown({ organization, user });
|
|
445
|
+
expect(all.body.price).toBe(100_00);
|
|
446
|
+
|
|
447
|
+
const category = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
448
|
+
|
|
449
|
+
const narrowed = await getBreakdown({
|
|
450
|
+
organization,
|
|
451
|
+
user,
|
|
452
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: category.id })],
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
expect(narrowed.status).toBe(200);
|
|
456
|
+
expect(narrowed.body.price).toBe(60_00);
|
|
457
|
+
|
|
458
|
+
// The payment as a whole paid for more than what is shown
|
|
459
|
+
expect(narrowed.body.selection.isListPartial).toBe(true);
|
|
460
|
+
|
|
461
|
+
const matchingItem = {
|
|
462
|
+
$and: [
|
|
463
|
+
{ type: BalanceItemType.Registration },
|
|
464
|
+
{ relations: { [BalanceItemRelationType.Group]: { id: 'group-a' } } },
|
|
465
|
+
],
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Exporting gives the parts of the payments that were added up here
|
|
469
|
+
expect(narrowed.body.selection.objectType).toBe(BreakdownObjectType.BalanceItemPayments);
|
|
470
|
+
expect(narrowed.body.selection.filter).toMatchObject({ balanceItem: matchingItem });
|
|
471
|
+
|
|
472
|
+
// A list gives the payments that paid for at least one matching item
|
|
473
|
+
expect(narrowed.body.selection.listFilter).toMatchObject({
|
|
474
|
+
balanceItemPayments: { $elemMatch: { balanceItem: matchingItem } },
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('narrowing down to an account keeps only that account', async () => {
|
|
479
|
+
const organization = await new OrganizationFactory({}).create();
|
|
480
|
+
const user = await createFinanceUser(organization);
|
|
481
|
+
|
|
482
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
483
|
+
|
|
484
|
+
await createPayment(organization, { items: [[item, 30_00]], iban: 'BE68539007547034' });
|
|
485
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
486
|
+
|
|
487
|
+
const all = await getBreakdown({ organization, user });
|
|
488
|
+
const account = all.body.byAccount.find(g => g.name.toString() === 'Hoofdrekening')!;
|
|
489
|
+
|
|
490
|
+
const narrowed = await getBreakdown({
|
|
491
|
+
organization,
|
|
492
|
+
user,
|
|
493
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Account, id: account.id })],
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(narrowed.status).toBe(200);
|
|
497
|
+
expect(narrowed.body.price).toBe(30_00);
|
|
498
|
+
expect(narrowed.body.selection.filter).toMatchObject({
|
|
499
|
+
$and: [
|
|
500
|
+
{ method: PaymentMethod.Transfer },
|
|
501
|
+
{ transferSettings: { iban: 'BE68539007547034' } },
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('exporting a category gives back exactly the payments that were shown', async () => {
|
|
507
|
+
const organization = await new OrganizationFactory({}).create();
|
|
508
|
+
const user = await createFinanceUser(organization);
|
|
509
|
+
|
|
510
|
+
const kapoenen = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
511
|
+
const welpen = await createRegistrationItem(organization, { price: 25_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
512
|
+
|
|
513
|
+
await createPayment(organization, { items: [[kapoenen, 60_00]] });
|
|
514
|
+
await createPayment(organization, { items: [[welpen, 25_00]] });
|
|
515
|
+
|
|
516
|
+
const all = await getBreakdown({ organization, user });
|
|
517
|
+
const category = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
518
|
+
|
|
519
|
+
const narrowed = await getBreakdown({
|
|
520
|
+
organization,
|
|
521
|
+
user,
|
|
522
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: category.id })],
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(narrowed.body.price).toBe(60_00);
|
|
526
|
+
|
|
527
|
+
// Running the export filter through the database selects the same payments
|
|
528
|
+
const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.filter });
|
|
529
|
+
expect(exported.body.price).toBe(60_00);
|
|
530
|
+
expect(exported.body.paymentCount).toBe(1);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('exporting a Stripe account gives back exactly the payments that were shown', async () => {
|
|
534
|
+
const organization = await new OrganizationFactory({}).create();
|
|
535
|
+
const user = await createFinanceUser(organization);
|
|
536
|
+
|
|
537
|
+
const stripeAccount = new StripeAccount();
|
|
538
|
+
stripeAccount.organizationId = organization.id;
|
|
539
|
+
stripeAccount.accountId = 'acct_' + uuidv4();
|
|
540
|
+
stripeAccount.meta = StripeMetaData.create({
|
|
541
|
+
business_profile: StripeBusinessProfile.create({ name: 'Scouts Gent' }),
|
|
542
|
+
});
|
|
543
|
+
await stripeAccount.save();
|
|
544
|
+
|
|
545
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
546
|
+
|
|
547
|
+
const online = await createPayment(organization, { items: [[item, 30_00]], method: PaymentMethod.Bancontact, provider: PaymentProvider.Stripe });
|
|
548
|
+
online.stripeAccountId = stripeAccount.id;
|
|
549
|
+
await online.save();
|
|
550
|
+
|
|
551
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
552
|
+
|
|
553
|
+
const all = await getBreakdown({ organization, user });
|
|
554
|
+
const account = all.body.byAccount.find(g => g.name.toString() === 'Scouts Gent')!;
|
|
555
|
+
|
|
556
|
+
const narrowed = await getBreakdown({
|
|
557
|
+
organization,
|
|
558
|
+
user,
|
|
559
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Account, id: account.id })],
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
expect(narrowed.body.price).toBe(30_00);
|
|
563
|
+
|
|
564
|
+
const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.filter });
|
|
565
|
+
expect(exported.body.price).toBe(30_00);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('a bundle discount is selected by its discount, not by the group it was given for', async () => {
|
|
569
|
+
const organization = await new OrganizationFactory({}).create();
|
|
570
|
+
const user = await createFinanceUser(organization);
|
|
571
|
+
|
|
572
|
+
// A discount carries the group it was given for, but it is named after the discount
|
|
573
|
+
const discount = await new BalanceItemFactory({
|
|
574
|
+
organizationId: organization.id,
|
|
575
|
+
type: BalanceItemType.RegistrationBundleDiscount,
|
|
576
|
+
amount: 1,
|
|
577
|
+
unitPrice: -10_00,
|
|
578
|
+
relations: new Map([
|
|
579
|
+
[BalanceItemRelationType.Group, BalanceItemRelation.create({ id: 'group-a', name: new TranslatedString('Kapoenen') })],
|
|
580
|
+
[BalanceItemRelationType.Discount, BalanceItemRelation.create({ id: 'discount-a', name: new TranslatedString('Gezinskorting') })],
|
|
581
|
+
]),
|
|
582
|
+
}).create();
|
|
583
|
+
|
|
584
|
+
const registration = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
585
|
+
|
|
586
|
+
await createPayment(organization, { items: [[registration, 60_00]] });
|
|
587
|
+
await createPayment(organization, { items: [[discount, -10_00]] });
|
|
588
|
+
|
|
589
|
+
const all = await getBreakdown({ organization, user });
|
|
590
|
+
const category = all.body.byCategory.find(g => g.name.toString() === 'Gezinskorting')!;
|
|
591
|
+
|
|
592
|
+
const narrowed = await getBreakdown({
|
|
593
|
+
organization,
|
|
594
|
+
user,
|
|
595
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: category.id })],
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
expect(narrowed.body.price).toBe(-10_00);
|
|
599
|
+
|
|
600
|
+
// The registration of the same group is not part of it
|
|
601
|
+
const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.filter });
|
|
602
|
+
expect(exported.body.price).toBe(-10_00);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test('transfers without a known account are not mixed up with the transfers that have one', async () => {
|
|
606
|
+
const organization = await new OrganizationFactory({}).create();
|
|
607
|
+
const user = await createFinanceUser(organization);
|
|
608
|
+
|
|
609
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
610
|
+
|
|
611
|
+
await createPayment(organization, { items: [[item, 30_00]], iban: 'BE68539007547034' });
|
|
612
|
+
await createPayment(organization, { items: [[item, 20_00]] });
|
|
613
|
+
|
|
614
|
+
const all = await getBreakdown({ organization, user });
|
|
615
|
+
const account = all.body.byAccount.find(g => g.name.toString() === PaymentMethodHelper.getNameCapitalized(PaymentMethod.Transfer))!;
|
|
616
|
+
|
|
617
|
+
const narrowed = await getBreakdown({
|
|
618
|
+
organization,
|
|
619
|
+
user,
|
|
620
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Account, id: account.id })],
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
expect(narrowed.status).toBe(200);
|
|
624
|
+
expect(narrowed.body.price).toBe(20_00);
|
|
625
|
+
|
|
626
|
+
// Exporting the narrowed selection gives back exactly what was shown
|
|
627
|
+
const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.filter });
|
|
628
|
+
expect(exported.body.price).toBe(20_00);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
describe('Grouping by payout', () => {
|
|
633
|
+
const createSettlement = (reference: string, settledAt: Date) => {
|
|
634
|
+
return Settlement.create({ id: 'stl_' + reference, reference, settledAt, amount: 0 });
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
test('per payout, with what is not online and what still has to be paid out apart', async () => {
|
|
638
|
+
const organization = await new OrganizationFactory({}).create();
|
|
639
|
+
const user = await createFinanceUser(organization);
|
|
640
|
+
|
|
641
|
+
const item = await createRegistrationItem(organization, { price: 200_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
642
|
+
const march = createSettlement('1234567.0312.01', new Date(2026, 2, 12));
|
|
643
|
+
|
|
644
|
+
const online = { method: PaymentMethod.Bancontact, provider: PaymentProvider.Mollie };
|
|
645
|
+
await createPayment(organization, { items: [[item, 40_00]], ...online, settlement: march });
|
|
646
|
+
await createPayment(organization, { items: [[item, 10_00]], ...online, settlement: march });
|
|
647
|
+
await createPayment(organization, { items: [[item, 30_00]], ...online });
|
|
648
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
649
|
+
|
|
650
|
+
const response = await getBreakdown({ organization, user });
|
|
651
|
+
|
|
652
|
+
expect(response.status).toBe(200);
|
|
653
|
+
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price, count: g.count }))).toEqual([
|
|
654
|
+
{ name: '1234567.0312.01', price: 50_00, count: 2 },
|
|
655
|
+
{ name: $t('%Zjn'), price: 30_00, count: 1 },
|
|
656
|
+
{ name: $t('%ZjN'), price: 20_00, count: 1 },
|
|
657
|
+
]);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test('nothing is broken down per payout when no provider has to pay out', async () => {
|
|
661
|
+
const organization = await new OrganizationFactory({}).create();
|
|
662
|
+
const user = await createFinanceUser(organization);
|
|
663
|
+
|
|
664
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
665
|
+
await createPayment(organization, { items: [[item, 40_00]], iban: 'BE68539007547034' });
|
|
666
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
667
|
+
|
|
668
|
+
const response = await getBreakdown({ organization, user });
|
|
669
|
+
|
|
670
|
+
expect(response.status).toBe(200);
|
|
671
|
+
expect(response.body.bySettlement).toEqual([]);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('exporting a payout gives back exactly the payments that were shown', async () => {
|
|
675
|
+
const organization = await new OrganizationFactory({}).create();
|
|
676
|
+
const user = await createFinanceUser(organization);
|
|
677
|
+
|
|
678
|
+
const item = await createRegistrationItem(organization, { price: 200_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
679
|
+
const march = createSettlement('1234567.0312.01', new Date(2026, 2, 12));
|
|
680
|
+
const april = createSettlement('1234567.0409.01', new Date(2026, 3, 9));
|
|
681
|
+
|
|
682
|
+
const online = { method: PaymentMethod.Bancontact, provider: PaymentProvider.Mollie };
|
|
683
|
+
await createPayment(organization, { items: [[item, 40_00]], ...online, settlement: march });
|
|
684
|
+
await createPayment(organization, { items: [[item, 25_00]], ...online, settlement: april });
|
|
685
|
+
await createPayment(organization, { items: [[item, 30_00]], ...online });
|
|
686
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
687
|
+
|
|
688
|
+
const all = await getBreakdown({ organization, user });
|
|
689
|
+
const payout = all.body.bySettlement.find(g => g.name.toString() === '1234567.0312.01')!;
|
|
690
|
+
|
|
691
|
+
const narrowed = await getBreakdown({
|
|
692
|
+
organization,
|
|
693
|
+
user,
|
|
694
|
+
path: [BreakdownPathItem.create({ tab: BreakdownTab.Settlement, id: payout.id })],
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
expect(narrowed.status).toBe(200);
|
|
698
|
+
expect(narrowed.body.price).toBe(40_00);
|
|
699
|
+
expect(narrowed.body.paymentCount).toBe(1);
|
|
700
|
+
|
|
701
|
+
// Running the export filter through the database selects the same payments
|
|
702
|
+
const exported = await getBreakdown({ organization, user, filter: narrowed.body.selection.filter });
|
|
703
|
+
expect(exported.body.price).toBe(40_00);
|
|
704
|
+
expect(exported.body.paymentCount).toBe(1);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('exporting what still has to be paid out leaves out what was already settled', async () => {
|
|
708
|
+
const organization = await new OrganizationFactory({}).create();
|
|
709
|
+
const user = await createFinanceUser(organization);
|
|
710
|
+
|
|
711
|
+
const item = await createRegistrationItem(organization, { price: 200_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
712
|
+
const march = createSettlement('1234567.0312.01', new Date(2026, 2, 12));
|
|
713
|
+
|
|
714
|
+
const online = { method: PaymentMethod.Bancontact, provider: PaymentProvider.Mollie };
|
|
715
|
+
await createPayment(organization, { items: [[item, 40_00]], ...online, settlement: march });
|
|
716
|
+
await createPayment(organization, { items: [[item, 30_00]], ...online });
|
|
717
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
718
|
+
|
|
719
|
+
const all = await getBreakdown({ organization, user });
|
|
720
|
+
const pending = all.body.bySettlement.find(g => g.name.toString() === $t('%Zjn'))!;
|
|
721
|
+
|
|
722
|
+
const exported = await getBreakdown({ organization, user, filter: pending.selection!.filter });
|
|
723
|
+
expect(exported.body.price).toBe(30_00);
|
|
724
|
+
expect(exported.body.paymentCount).toBe(1);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('exporting what was not paid online leaves out the online payments', async () => {
|
|
728
|
+
const organization = await new OrganizationFactory({}).create();
|
|
729
|
+
const user = await createFinanceUser(organization);
|
|
730
|
+
|
|
731
|
+
const item = await createRegistrationItem(organization, { price: 200_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
732
|
+
|
|
733
|
+
await createPayment(organization, { items: [[item, 40_00]], method: PaymentMethod.Bancontact, provider: PaymentProvider.Mollie });
|
|
734
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
735
|
+
await createPayment(organization, { items: [[item, 10_00]], iban: 'BE68539007547034' });
|
|
736
|
+
|
|
737
|
+
const all = await getBreakdown({ organization, user });
|
|
738
|
+
const offline = all.body.bySettlement.find(g => g.name.toString() === $t('%ZjN'))!;
|
|
739
|
+
|
|
740
|
+
const exported = await getBreakdown({ organization, user, filter: offline.selection!.filter });
|
|
741
|
+
expect(exported.body.price).toBe(30_00);
|
|
742
|
+
expect(exported.body.paymentCount).toBe(2);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('providers we get no payout information from get a row per provider', async () => {
|
|
746
|
+
const organization = await new OrganizationFactory({}).create();
|
|
747
|
+
const user = await createFinanceUser(organization);
|
|
748
|
+
|
|
749
|
+
const item = await createRegistrationItem(organization, { price: 200_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
750
|
+
|
|
751
|
+
await createPayment(organization, { items: [[item, 40_00]], method: PaymentMethod.Bancontact, provider: PaymentProvider.Mollie });
|
|
752
|
+
await createPayment(organization, { items: [[item, 25_00]], method: PaymentMethod.Bancontact, provider: PaymentProvider.Buckaroo });
|
|
753
|
+
await createPayment(organization, { items: [[item, 15_00]], method: PaymentMethod.Payconiq, provider: PaymentProvider.Payconiq });
|
|
754
|
+
await createPayment(organization, { items: [[item, 20_00]], method: PaymentMethod.PointOfSale });
|
|
755
|
+
|
|
756
|
+
const all = await getBreakdown({ organization, user });
|
|
757
|
+
|
|
758
|
+
expect(all.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
759
|
+
{ name: $t('%Zjn'), price: 40_00 },
|
|
760
|
+
{ name: getPaymentProviderName(PaymentProvider.Buckaroo), price: 25_00 },
|
|
761
|
+
{ name: $t('%ZjN'), price: 20_00 },
|
|
762
|
+
{ name: getPaymentProviderName(PaymentProvider.Payconiq), price: 15_00 },
|
|
763
|
+
]);
|
|
764
|
+
|
|
765
|
+
const row = all.body.bySettlement.find(g => g.name.toString() === getPaymentProviderName(PaymentProvider.Buckaroo))!;
|
|
766
|
+
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
767
|
+
expect(exported.body.price).toBe(25_00);
|
|
768
|
+
expect(exported.body.paymentCount).toBe(1);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
describe('What counts as received', () => {
|
|
773
|
+
test('only succeeded payments', async () => {
|
|
774
|
+
const organization = await new OrganizationFactory({}).create();
|
|
775
|
+
const user = await createFinanceUser(organization);
|
|
776
|
+
|
|
777
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
778
|
+
|
|
779
|
+
await createPayment(organization, { items: [[item, 30_00]] });
|
|
780
|
+
await createPayment(organization, { items: [[item, 70_00]], status: PaymentStatus.Pending });
|
|
781
|
+
|
|
782
|
+
const response = await getBreakdown({
|
|
783
|
+
organization,
|
|
784
|
+
user,
|
|
785
|
+
filter: { status: PaymentStatus.Succeeded },
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
expect(response.status).toBe(200);
|
|
789
|
+
expect(response.body.price).toBe(30_00);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('what a payment rounded away is an article of its own', async () => {
|
|
793
|
+
const organization = await new OrganizationFactory({}).create();
|
|
794
|
+
const user = await createFinanceUser(organization);
|
|
795
|
+
|
|
796
|
+
// VAT makes what is charged go further than the cent: 12,12 + 21% is 14,6652
|
|
797
|
+
const item = await createRegistrationItem(organization, { price: 12_1200, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
798
|
+
item.VATPercentage = 21;
|
|
799
|
+
item.VATIncluded = false;
|
|
800
|
+
await item.save();
|
|
801
|
+
|
|
802
|
+
expect(item.priceWithVAT).toBe(14_6652);
|
|
803
|
+
|
|
804
|
+
// A payment only goes to the cent, so it carries the difference
|
|
805
|
+
await createPayment(organization, { items: [[item, 14_6652]], roundingAmount: 48 });
|
|
806
|
+
|
|
807
|
+
const response = await getBreakdown({ organization, user });
|
|
808
|
+
|
|
809
|
+
expect(response.status).toBe(200);
|
|
810
|
+
expect(response.body.price).toBe(14_6700);
|
|
811
|
+
expect(response.body.selection.isListPartial).toBe(false);
|
|
812
|
+
|
|
813
|
+
expect(response.body.byCategory.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
814
|
+
{ name: 'Kapoenen', price: 14_6652 },
|
|
815
|
+
{ name: $t('%1b6'), price: 48 },
|
|
816
|
+
]);
|
|
817
|
+
|
|
818
|
+
// Everything the payment is worth is accounted for
|
|
819
|
+
expect(response.body.byAccount.reduce((total, g) => total + g.price, 0)).toBe(14_6700);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test('the costs of a payment are counted once, even when it paid for several things', async () => {
|
|
823
|
+
const organization = await new OrganizationFactory({}).create();
|
|
824
|
+
const user = await createFinanceUser(organization);
|
|
825
|
+
|
|
826
|
+
const first = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
827
|
+
const second = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
828
|
+
|
|
829
|
+
await createPayment(organization, {
|
|
830
|
+
items: [[first, 60_00], [second, 40_00]],
|
|
831
|
+
method: PaymentMethod.Bancontact,
|
|
832
|
+
provider: PaymentProvider.Mollie,
|
|
833
|
+
transferFee: 39,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const response = await getBreakdown({ organization, user });
|
|
837
|
+
|
|
838
|
+
expect(response.status).toBe(200);
|
|
839
|
+
expect(response.body.transferFee).toBe(39);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test('a selection that holds unfinished payments says what of it never arrived', async () => {
|
|
843
|
+
const organization = await new OrganizationFactory({}).create();
|
|
844
|
+
const user = await createFinanceUser(organization);
|
|
845
|
+
|
|
846
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
847
|
+
|
|
848
|
+
await createPayment(organization, { items: [[item, 40_00]] });
|
|
849
|
+
await createPayment(organization, { items: [[item, 30_00]], status: PaymentStatus.Pending });
|
|
850
|
+
await createPayment(organization, { items: [[item, 20_00]], status: PaymentStatus.Failed });
|
|
851
|
+
|
|
852
|
+
const response = await getBreakdown({ organization, user });
|
|
853
|
+
|
|
854
|
+
expect(response.status).toBe(200);
|
|
855
|
+
expect(response.body.price).toBe(90_00);
|
|
856
|
+
expect(response.body.pricePending).toBe(30_00);
|
|
857
|
+
expect(response.body.priceFailed).toBe(20_00);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('a deduction from another balance is not money that came in', async () => {
|
|
861
|
+
const organization = await new OrganizationFactory({}).create();
|
|
862
|
+
const user = await createFinanceUser(organization);
|
|
863
|
+
|
|
864
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
865
|
+
|
|
866
|
+
await createPayment(organization, { items: [[item, 40_00]] });
|
|
867
|
+
await createPayment(organization, { items: [[item, 10_00]], method: PaymentMethod.AccountDeductions });
|
|
868
|
+
|
|
869
|
+
const response = await getBreakdown({ organization, user });
|
|
870
|
+
|
|
871
|
+
expect(response.status).toBe(200);
|
|
872
|
+
expect(response.body.bySettlement.map(g => ({ name: g.name.toString(), price: g.price }))).toEqual([
|
|
873
|
+
{ name: $t('%ZjN'), price: 40_00 },
|
|
874
|
+
{ name: PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), price: 10_00 },
|
|
875
|
+
]);
|
|
876
|
+
|
|
877
|
+
// Running the rows through the database keeps them apart
|
|
878
|
+
for (const [name, price] of [[$t('%ZjN'), 40_00], [PaymentMethodHelper.getNameCapitalized(PaymentMethod.AccountDeductions), 10_00]] as [string, number][]) {
|
|
879
|
+
const row = response.body.bySettlement.find(g => g.name.toString() === name)!;
|
|
880
|
+
const exported = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
881
|
+
expect(exported.body.price).toBe(price);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
describe('Scope and permissions', () => {
|
|
887
|
+
test('never includes payments of another organization', async () => {
|
|
888
|
+
const organization = await new OrganizationFactory({}).create();
|
|
889
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
890
|
+
const user = await createFinanceUser(organization);
|
|
891
|
+
|
|
892
|
+
const item = await createRegistrationItem(organization, { price: 10_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
893
|
+
await createPayment(organization, { items: [[item, 10_00]] });
|
|
894
|
+
|
|
895
|
+
const otherItem = await createRegistrationItem(otherOrganization, { price: 999_00, groupId: 'group-x', groupName: 'Andere' });
|
|
896
|
+
await createPayment(otherOrganization, { items: [[otherItem, 999_00]] });
|
|
897
|
+
|
|
898
|
+
const response = await getBreakdown({ organization, user });
|
|
899
|
+
|
|
900
|
+
expect(response.status).toBe(200);
|
|
901
|
+
expect(response.body.price).toBe(10_00);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test('a user without permissions is not allowed to see the breakdown', async () => {
|
|
905
|
+
const organization = await new OrganizationFactory({}).create();
|
|
906
|
+
const user = await new UserFactory({ organization }).create();
|
|
907
|
+
|
|
908
|
+
await expect(getBreakdown({ organization, user })).rejects.toThrow();
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test('an admin without access to the finances is not allowed to see the breakdown', async () => {
|
|
912
|
+
const organization = await new OrganizationFactory({}).create();
|
|
913
|
+
const user = await new UserFactory({
|
|
914
|
+
organization,
|
|
915
|
+
permissions: Permissions.create({ level: PermissionLevel.Read }),
|
|
916
|
+
}).create();
|
|
917
|
+
|
|
918
|
+
const item = await createRegistrationItem(organization, { price: 10_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
919
|
+
await createPayment(organization, { items: [[item, 10_00]] });
|
|
920
|
+
|
|
921
|
+
await expect(getBreakdown({ organization, user })).rejects.toThrow();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test('the breakdown url is not matched by the endpoint of a single payment', async () => {
|
|
925
|
+
const organization = await new OrganizationFactory({}).create();
|
|
926
|
+
|
|
927
|
+
const request = Request.get({
|
|
928
|
+
path: '/payments/breakdown',
|
|
929
|
+
host: organization.getApiHost(),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
expect(await new GetPaymentEndpoint().decode(request)).toBeNull();
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe('Exporting what a row was added up from', () => {
|
|
937
|
+
test('a row that holds a part of its payments exports exactly that part', async () => {
|
|
938
|
+
const organization = await new OrganizationFactory({}).create();
|
|
939
|
+
const user = await createFinanceUser(organization);
|
|
940
|
+
|
|
941
|
+
const registration = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
942
|
+
const other = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
943
|
+
|
|
944
|
+
// One payment paid for both, so a row per category holds a part of it
|
|
945
|
+
await createPayment(organization, { items: [[registration, 60_00], [other, 40_00]] });
|
|
946
|
+
|
|
947
|
+
const all = await getBreakdown({ organization, user });
|
|
948
|
+
const row = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
949
|
+
|
|
950
|
+
expect(row.price).toBe(60_00);
|
|
951
|
+
expect(row.selection!.isListPartial).toBe(true);
|
|
952
|
+
expect(row.selection!.objectType).toBe(BreakdownObjectType.BalanceItemPayments);
|
|
953
|
+
|
|
954
|
+
// The export holds the €60 of that payment, not the €100 payment around it
|
|
955
|
+
const slice = await exportSlice({ organization, user, filter: row.selection!.filter });
|
|
956
|
+
expect(slice.price).toBe(60_00);
|
|
957
|
+
expect(slice.count).toBe(1);
|
|
958
|
+
|
|
959
|
+
// While the list still shows the whole payment
|
|
960
|
+
const listed = await getBreakdown({ organization, user, filter: row.selection!.listFilter });
|
|
961
|
+
expect(listed.body.price).toBe(100_00);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('narrowing down reads less without changing what a row adds up to', async () => {
|
|
965
|
+
const organization = await new OrganizationFactory({}).create();
|
|
966
|
+
const user = await createFinanceUser(organization);
|
|
967
|
+
|
|
968
|
+
const kapoenen = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
969
|
+
const welpen = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
970
|
+
|
|
971
|
+
await createPayment(organization, { items: [[kapoenen, 60_00], [welpen, 40_00]] });
|
|
972
|
+
await createPayment(organization, { items: [[welpen, 40_00]] });
|
|
973
|
+
|
|
974
|
+
const all = await getBreakdown({ organization, user });
|
|
975
|
+
const row = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
976
|
+
const path = [BreakdownPathItem.create({ tab: BreakdownTab.Category, id: row.id })];
|
|
977
|
+
|
|
978
|
+
const opened = await getBreakdown({ organization, user, path });
|
|
979
|
+
|
|
980
|
+
// Leaving out the payments this row doesn't hold may never change what it says
|
|
981
|
+
const narrowed = await getBreakdown({ organization, user, path, narrowFilter: row.selection!.listFilter });
|
|
982
|
+
|
|
983
|
+
expect(narrowed.body.price).toBe(opened.body.price);
|
|
984
|
+
expect(narrowed.body.paymentCount).toBe(opened.body.paymentCount);
|
|
985
|
+
expect(narrowed.body.selection.filter).toEqual(opened.body.selection.filter);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test('a search selects the same payments in the breakdown and in the export', async () => {
|
|
989
|
+
const organization = await new OrganizationFactory({}).create();
|
|
990
|
+
const user = await createFinanceUser(organization);
|
|
991
|
+
|
|
992
|
+
const wanted = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
993
|
+
const other = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
994
|
+
|
|
995
|
+
// One payment paid for both, so a row per category holds a part of it
|
|
996
|
+
await createPayment(organization, { items: [[wanted, 60_00], [other, 40_00]], transferDescription: 'GEZOCHT' });
|
|
997
|
+
await createPayment(organization, { items: [[other, 40_00]], transferDescription: 'ANDERE' });
|
|
998
|
+
|
|
999
|
+
const found = await getBreakdown({ organization, user, search: 'GEZOCHT' });
|
|
1000
|
+
|
|
1001
|
+
expect(found.body.price).toBe(100_00);
|
|
1002
|
+
expect(found.body.paymentCount).toBe(1);
|
|
1003
|
+
|
|
1004
|
+
// The file has to hold exactly what was added up: without the search it would hold both
|
|
1005
|
+
const row = found.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
1006
|
+
const slice = await exportSlice({ organization, user, filter: row.selection!.filter, search: 'GEZOCHT' });
|
|
1007
|
+
|
|
1008
|
+
expect(slice.price).toBe(row.price);
|
|
1009
|
+
expect(slice.count).toBe(1);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test('a row that holds whole payments is exported as payments', async () => {
|
|
1013
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1014
|
+
const user = await createFinanceUser(organization);
|
|
1015
|
+
|
|
1016
|
+
const item = await createRegistrationItem(organization, { price: 100_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
1017
|
+
await createPayment(organization, { items: [[item, 100_00]] });
|
|
1018
|
+
|
|
1019
|
+
const all = await getBreakdown({ organization, user });
|
|
1020
|
+
const row = all.body.byCategory[0];
|
|
1021
|
+
|
|
1022
|
+
// Nothing was split, so there is no reason to export the parts of these payments
|
|
1023
|
+
expect(row.selection!.isListPartial).toBe(false);
|
|
1024
|
+
expect(row.selection!.objectType).toBe(BreakdownObjectType.Payments);
|
|
1025
|
+
expect(row.selection!.filter).toEqual(row.selection!.listFilter);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
test('a row that holds a part of its payments can be opened to export it', async () => {
|
|
1029
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1030
|
+
const user = await createFinanceUser(organization);
|
|
1031
|
+
|
|
1032
|
+
const registration = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
1033
|
+
const other = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
1034
|
+
|
|
1035
|
+
await createPayment(organization, { items: [[registration, 60_00], [other, 40_00]] });
|
|
1036
|
+
|
|
1037
|
+
const all = await getBreakdown({ organization, user });
|
|
1038
|
+
|
|
1039
|
+
// An article is the deepest level, so it is normally only shown as a list. A row that holds
|
|
1040
|
+
// a part of its payments is the exception: that part only exists as its own breakdown
|
|
1041
|
+
const article = all.body.byArticle.find(g => g.name.toString().includes('Kapoenen'))!;
|
|
1042
|
+
expect(article.selection!.isListPartial).toBe(true);
|
|
1043
|
+
expect(article.canNarrowDown).toBe(true);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// The filter a breakdown starts from is about payments, while a slice is read from the balance
|
|
1047
|
+
// item payments: everything it says has to keep pointing at the payments table
|
|
1048
|
+
describe('The selection the breakdown started from', () => {
|
|
1049
|
+
test.each([
|
|
1050
|
+
['a date the payments were paid', () => ({ paidAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } })],
|
|
1051
|
+
['the payments that were picked by hand', (paymentId: string) => ({ id: { $in: [paymentId] } })],
|
|
1052
|
+
] as [string, (paymentId: string) => StamhoofdFilter][])('is kept by the slice export: %s', async (_name, buildFilter) => {
|
|
1053
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1054
|
+
const user = await createFinanceUser(organization);
|
|
1055
|
+
|
|
1056
|
+
const registration = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
1057
|
+
const other = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
1058
|
+
|
|
1059
|
+
const payment = await createPayment(organization, { items: [[registration, 60_00], [other, 40_00]] });
|
|
1060
|
+
const filter = buildFilter(payment.id);
|
|
1061
|
+
|
|
1062
|
+
const all = await getBreakdown({ organization, user, filter });
|
|
1063
|
+
const row = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
1064
|
+
expect(row.price).toBe(60_00);
|
|
1065
|
+
|
|
1066
|
+
const slice = await exportSlice({ organization, user, filter: row.selection!.filter });
|
|
1067
|
+
expect(slice.price).toBe(60_00);
|
|
1068
|
+
expect(slice.count).toBe(1);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('leaves the slice export empty when it selects other payments', async () => {
|
|
1072
|
+
const organization = await new OrganizationFactory({}).create();
|
|
1073
|
+
const user = await createFinanceUser(organization);
|
|
1074
|
+
|
|
1075
|
+
const registration = await createRegistrationItem(organization, { price: 60_00, groupId: 'group-a', groupName: 'Kapoenen' });
|
|
1076
|
+
const other = await createRegistrationItem(organization, { price: 40_00, groupId: 'group-b', groupName: 'Welpen' });
|
|
1077
|
+
|
|
1078
|
+
await createPayment(organization, { items: [[registration, 60_00], [other, 40_00]] });
|
|
1079
|
+
|
|
1080
|
+
const all = await getBreakdown({ organization, user });
|
|
1081
|
+
const row = all.body.byCategory.find(g => g.name.toString() === 'Kapoenen')!;
|
|
1082
|
+
|
|
1083
|
+
// The same row, but asked for payments that don't exist: reading the date of the
|
|
1084
|
+
// balance item payment instead of the one of the payment would still find them
|
|
1085
|
+
const slice = await exportSlice({
|
|
1086
|
+
organization,
|
|
1087
|
+
user,
|
|
1088
|
+
filter: { $and: [row.selection!.filter, { payment: { createdAt: { $lt: new Date('2000-01-01') } } }] },
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
expect(slice.count).toBe(0);
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
});
|