• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kiva / ui / 19147367510

06 Nov 2025 07:26PM UTC coverage: 91.443% (+41.5%) from 49.902%
19147367510

push

github

emuvente
test: refactor category-row-arrows-visible-mixin test with runner method

3722 of 3979 branches covered (93.54%)

Branch coverage included in aggregate %.

18923 of 20785 relevant lines covered (91.04%)

78.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.48
/src/util/loanUtils.js
1
import numeral from 'numeral';
1✔
2
import _get from 'lodash/get';
1✔
3

4
export const ERL_COOKIE_NAME = 'kverlfivedollarnotes';
1✔
5
export const TOP_UP_CAMPAIGN = 'TOPUP-VB-BALANCE-MPV1';
1✔
6
export const BASE_CAMPAIGN = 'BASE-VB_BALANCE_MPV1';
1✔
7

8
/**
1✔
9
 * Loan Statuses Available on borrower profile
1✔
10
 */
1✔
11
export const ALLOWED_LOAN_STATUSES = [
1✔
12
        // 'defaulted',
1✔
13
        // 'deleted',
1✔
14
        // 'ended',
1✔
15
        'expired',
1✔
16
        'funded',
1✔
17
        'fundraising',
1✔
18
        'inactive',
1✔
19
        // 'inactiveExpired',
1✔
20
        // 'issue',
1✔
21
        // 'payingBack',
1✔
22
        'pfp',
1✔
23
        'raised',
1✔
24
        // 'refunded',
1✔
25
        // 'reviewed'
1✔
26
];
1✔
27

28
/**
1✔
29
 * Returns true if loan is fundraising / can be lent to
1✔
30
 *
1✔
31
 * @param {object} loan
1✔
32
 * @returns {boolean|null}
1✔
33
 */
1✔
34
export function isLoanFundraising(loan) {
1✔
35
        // check status
4✔
36
        if (_get(loan, 'status') !== 'fundraising') {
4✔
37
                return false;
1✔
38
        }
1✔
39
        // check fundraising information
3✔
40
        const loanAmount = numeral(_get(loan, 'loanAmount'));
3✔
41
        const fundedAmount = numeral(_get(loan, 'loanFundraisingInfo.fundedAmount'));
3✔
42
        const reservedAmount = numeral(_get(loan, 'loanFundraisingInfo.reservedAmount'));
3✔
43
        // loan amount vs funded amount
3✔
44
        if (loanAmount.value() === fundedAmount.value()) {
4✔
45
                return false;
1✔
46
        }
1✔
47
        // loan amount vs funded + reserved amount
2✔
48
        if (loanAmount.value() <= (fundedAmount.value() + reservedAmount.value())) {
4✔
49
                return false;
1✔
50
        }
1✔
51
        // all clear
1✔
52
        return true;
1✔
53
}
1✔
54

55
/**
1✔
56
 * Returns the why special string for the loan if it is defined
1✔
57
 *
1✔
58
 * @param {string} whySpecial from LoanBasic.whySpecial
1✔
59
 * @returns {string}
1✔
60
 */
1✔
61
export function formatWhySpecial(whySpecial = '') {
1✔
62
        if (whySpecial) {
4✔
63
                const lowerCaseWhySpecial = whySpecial.toString().charAt(0).toLowerCase() + whySpecial.toString().slice(1);
2✔
64
                return `This loan is special because ${lowerCaseWhySpecial.trim()}`;
2✔
65
        }
2✔
66
        return '';
2✔
67
}
2✔
68

69
export function buildPriceArray(amountLeft, minAmount) {
1✔
70
        // get count of shares based on available remaining amount.
21✔
71
        const N = amountLeft / minAmount;
21✔
72
        // convert this to formatted array for our select element
21✔
73
        const priceArray = []; // ex. priceArray = ['25', '50', '75']
21✔
74
        for (let i = 1; i <= N; i += 1) {
21✔
75
                priceArray.push(numeral(minAmount * i).format('0,0'));
1,950✔
76
        }
1,950✔
77
        return priceArray;
21✔
78
}
21✔
79

80
export function build5DollarsPriceArray(amountLeft) {
1✔
81
        const limit5Notes = amountLeft < 50 ? amountLeft : 50;
2!
82
        const numberOf5 = limit5Notes / 5;
2✔
83
        const numberOf25 = Math.ceil((amountLeft - limit5Notes) / 25) + 1;
2✔
84
        const priceArray = [];
2✔
85
        for (let i = 1; i <= numberOf5; i += 1) {
2✔
86
                priceArray.push(numeral(5 * i).format('0,0'));
20✔
87
        }
20✔
88
        if (amountLeft > limit5Notes) {
2✔
89
                for (let i = 3; i <= numberOf25; i += 1) {
2✔
90
                        priceArray.push(numeral(25 * i).format('0,0'));
154✔
91
                }
154✔
92
        }
2✔
93
        return priceArray;
2✔
94
}
2✔
95

96
function buildHugePriceArray(amountLeft) {
9✔
97
        const priceArray = [];
9✔
98

99
        // Add $100 options up to $1,000
9✔
100
        let minAmount = 100;
9✔
101
        let limitAmount = amountLeft > 1000 ? 1000 : amountLeft;
9✔
102
        let optionCount = limitAmount / minAmount;
9✔
103
        for (let i = 1; i <= optionCount; i += 1) {
9✔
104
                const price = minAmount * i + 500;
42✔
105
                if (price > limitAmount) break;
42✔
106
                priceArray.push(numeral(price).format('0,0'));
33✔
107
        }
33✔
108

109
        // Add $1000 options up to $10,000
9✔
110
        minAmount = 1000;
9✔
111
        limitAmount = amountLeft > 10000 ? 10000 : amountLeft;
9✔
112
        optionCount = limitAmount / minAmount;
9✔
113
        for (let i = 1; i <= optionCount; i += 1) {
9✔
114
                const price = minAmount * i + 1000;
27✔
115
                if (price > limitAmount) break;
27✔
116
                priceArray.push(numeral(price).format('0,0'));
22✔
117
        }
22✔
118

119
        // Ensure final option is added
9✔
120
        if (!priceArray.includes(numeral(limitAmount).format('0,0'))) {
9✔
121
                priceArray.push(numeral(limitAmount).format('0,0'));
5✔
122
        }
5✔
123

124
        return priceArray;
9✔
125
}
9✔
126

127
export function getDropdownPriceArray(
1✔
128
        unreservedAmount,
14✔
129
        minAmount,
14✔
130
        enableFiveDollarsNotes,
14✔
131
        inPfp = false,
14✔
132
        isLoggedIn = false,
14✔
133
) {
14✔
134
        const parsedAmountLeft = parseFloat(unreservedAmount);
14✔
135
        let combinedPricesArray = [];
14✔
136

137
        const priceArray = (enableFiveDollarsNotes && !inPfp)
14✔
138
                ? build5DollarsPriceArray(parsedAmountLeft).slice(0, 28)
14✔
139
                : buildPriceArray(parsedAmountLeft, minAmount).slice(0, 20);
14✔
140

141
        const showHugeAmount = isLoggedIn && parsedAmountLeft > 500;
14✔
142
        if (showHugeAmount) {
14✔
143
                const hugePriceArray = buildHugePriceArray(parsedAmountLeft);
6✔
144
                combinedPricesArray = priceArray.concat(hugePriceArray);
6✔
145
        }
6✔
146
        return showHugeAmount ? combinedPricesArray : priceArray;
14✔
147
}
14✔
148

149
export function getDropdownPriceArrayCheckout(remainingAmount, minAmount, enableFiveDollarsNotes, isLoggedIn) {
1✔
150
        const parsedAmountLeft = parseFloat(remainingAmount);
9✔
151
        if (enableFiveDollarsNotes) {
9✔
152
                return build5DollarsPriceArray(parsedAmountLeft).slice(0, 47);
1✔
153
        }
1✔
154
        let combinedPricesArray = [];
8✔
155
        const pricesArray = buildPriceArray(remainingAmount, minAmount);
8✔
156
        const reducedArray = pricesArray.filter(element => {
8✔
157
                return element % 25 === 0;
590✔
158
        });
8✔
159

160
        const showHugeAmount = isLoggedIn && parsedAmountLeft > 500;
9✔
161
        if (showHugeAmount) {
9✔
162
                const hugePriceArray = buildHugePriceArray(parsedAmountLeft);
3✔
163
                combinedPricesArray = reducedArray.slice(0, 20).concat(hugePriceArray);
3✔
164
        }
3✔
165

166
        return showHugeAmount ? combinedPricesArray : reducedArray;
9✔
167
}
9✔
168

169
export function toParagraphs(text) {
1✔
170
        return String(text).replace(/\r|\n|<br\s*\/?>/g, '\n').split(/\n+/);
5✔
171
}
5✔
172

173
/**
1✔
174
 * Determines if the remaining amount of a loan is less than the match amount
1✔
175
 * If the full match amount can't be purchased we hide the match text as the purchase would not be matched
1✔
176
 *
1✔
177
 * @param {object} loan from LoanBasic
1✔
178
 * @returns {boolean}
1✔
179
 */
1✔
180
export function isMatchAtRisk(loan) {
1✔
181
        // exit if this loan isn't matched
5✔
182
        if (!loan || !loan.matchingText) return false;
5✔
183
        // get vars
3✔
184
        const {
3✔
185
                fundedAmount,
3✔
186
                reservedAmount
3✔
187
        } = loan.loanFundraisingInfo;
3✔
188
        // make strings numbers + perform preliminary calculations
3✔
189
        const loanAmountCalc = numeral(loan.loanAmount || 0).value();
5!
190
        const loanFundraisingCalc = numeral(reservedAmount || 0).add(numeral(fundedAmount || 0).value());
5!
191
        const remainingAmountCalculation = loanAmountCalc - loanFundraisingCalc.value();
5✔
192
        // 25_hard_coded - match ratio * 25 (match purchase) + 25 (lowest lender purchase possible)
5✔
193
        const matchAmountCalculation = numeral(numeral(loan.matchRatio || 1).multiply(25)).add(25);
5!
194
        // final comparison: is the loan amount remaining less than the potential match amount?
5✔
195
        return numeral(remainingAmountCalculation).value()
5✔
196
                < numeral(matchAmountCalculation).value();
5✔
197
}
5✔
198

199
export function queryLoanData({
1✔
200
        apollo, cookieStore, loanId, loanQuery
1✔
201
}) {
1✔
202
        return apollo.query({
1✔
203
                query: loanQuery,
1✔
204
                variables: {
1✔
205
                        basketId: cookieStore.get('kvbskt'),
1✔
206
                        loanId,
1✔
207
                },
1✔
208
        });
1✔
209
}
1✔
210

211
export function readLoanData({
1✔
212
        apollo, cookieStore, loanId, loanQuery
2✔
213
}) {
2✔
214
        // Read loan data from the cache (synchronus)
2✔
215
        try {
2✔
216
                return apollo.readQuery({
2✔
217
                        query: loanQuery,
2✔
218
                        variables: {
2✔
219
                                basketId: cookieStore.get('kvbskt'),
2✔
220
                                loanId,
2✔
221
                        },
2✔
222
                });
2✔
223
        } catch (e) {
2✔
224
                // if there's an error it means there's no loan data in the cache yet, so return null
1✔
225
                return null;
1✔
226
        }
1✔
227
}
2✔
228

229
export function watchLoanData({
1✔
230
        apollo, cookieStore, loanId, loanQuery, callback
3✔
231
}) {
3✔
232
        // Setup query observer to watch for changes to the loan data (async)
3✔
233
        const queryObserver = apollo.watchQuery({
3✔
234
                query: loanQuery,
3✔
235
                variables: {
3✔
236
                        basketId: cookieStore.get('kvbskt'),
3✔
237
                        loanId,
3✔
238
                },
3✔
239
        });
3✔
240

241
        // Subscribe to the observer to see each result
3✔
242
        const subscription = queryObserver.subscribe({
3✔
243
                next: result => callback(result),
3✔
244
                error: error => callback({ error }),
3✔
245
        });
3✔
246

247
        // Return the observer to allow modification of variables
3✔
248
        return { queryObserver, subscription };
3✔
249
}
3✔
250

251
export function watchLoanCardData({
1✔
252
        apollo, loanId, loanCardQuery, callback, publicId
2✔
253
}) {
2✔
254
        // Setup query observer to watch for changes to the loan data (async)
2✔
255
        const queryObserver = apollo.watchQuery({
2✔
256
                query: loanCardQuery,
2✔
257
                variables: {
2✔
258
                        loanId,
2✔
259
                        publicId,
2✔
260
                },
2✔
261
        });
2✔
262

263
        // Subscribe to the observer to see each result
2✔
264
        queryObserver.subscribe({
2✔
265
                next: result => callback(result),
2✔
266
                error: error => callback({ error }),
2✔
267
        });
2✔
268

269
        // Return the observer to allow modification of variables
2✔
270
        return queryObserver;
2✔
271
}
2✔
272

273
export function readLoanFragment({
1✔
274
        apollo, loanId, fragment, publicId
3✔
275
}) {
3✔
276
        let partnerFragment;
3✔
277
        let directFragment;
3✔
278
        try {
3✔
279
                // Attempt to read the loan card fragment for LoanPartner from the cache
3✔
280
                // If cache is missing fragment fields, this will throw an invariant error
3✔
281
                partnerFragment = apollo.readFragment({
3✔
282
                        id: `LoanPartner:${loanId}`,
3✔
283
                        publicId,
3✔
284
                        fragment,
3✔
285
                }) || null;
3!
286
        } catch (e) {
3✔
287
                // no-op
2✔
288
        }
2✔
289
        try {
3✔
290
                // Attempt to read the loan card fragment for LoanDirect from the cache
3✔
291
                // If cache is missing fragment fields, this will throw an invariant error
3✔
292
                directFragment = apollo.readFragment({
3✔
293
                        id: `LoanDirect:${loanId}`,
3✔
294
                        fragment,
3✔
295
                }) || null;
3✔
296
        } catch (e) {
3!
297
                // no-op
×
298
        }
×
299
        return partnerFragment || directFragment;
3✔
300
}
3✔
301

302
export function isLessThan25(unreservedAmount) {
1✔
303
        return unreservedAmount < 25 && unreservedAmount > 0;
10✔
304
}
10✔
305

306
export function isBetween25And50(unreservedAmount) {
1✔
307
        return unreservedAmount <= 50 && unreservedAmount > 25;
11✔
308
}
11✔
309

310
export function isBetween25And500(unreservedAmount) {
1✔
311
        return unreservedAmount < 500 && unreservedAmount >= 25;
7✔
312
}
7✔
313

314
/**
1✔
315
 * Gets the selected option for the Lend CTA component
1✔
316
 *
1✔
317
 * @param {Object} cookieStore The cookie store object form the Vue component
1✔
318
 * @param {boolean} enableFiveDollarsNotes Whether $5 notes experiment is assigned
1✔
319
 * @param {string} campaign The "utm_campaign" query param sourced from the Vue component route
1✔
320
 * @param {string} unreservedAmount The unreserved amount for the loan
1✔
321
 * @param {string} userBalance The balance of the current user
1✔
322
 * @returns {string} The option to be selected in the CTA dropdown
1✔
323
 */
1✔
324
export function getLendCtaSelectedOption(cookieStore, enableFiveDollarsNotes, campaign, unreservedAmount, userBalance) {
1✔
325
        // Don't enable the campaign changes when the user balance is undefined (user not logged in)
13✔
326
        if (enableFiveDollarsNotes && typeof userBalance !== 'undefined') {
13✔
327
                let currentCampaign = cookieStore.get(ERL_COOKIE_NAME);
12✔
328

329
                if (campaign && typeof campaign === 'string' && !currentCampaign) {
12✔
330
                        // Effects of the campaign lasts for 24 hours
7✔
331
                        const expires = new Date();
7✔
332
                        expires.setHours(expires.getHours() + 24);
7✔
333

334
                        const campaignToCheck = campaign.toUpperCase();
7✔
335

336
                        // eslint-disable-next-line no-nested-ternary
7✔
337
                        currentCampaign = campaignToCheck.includes(TOP_UP_CAMPAIGN)
7✔
338
                                ? TOP_UP_CAMPAIGN
7✔
339
                                : (campaignToCheck.includes(BASE_CAMPAIGN) ? BASE_CAMPAIGN : '');
7!
340

341
                        if (currentCampaign) {
7✔
342
                                cookieStore.set(ERL_COOKIE_NAME, currentCampaign, { expires });
7✔
343
                        }
7✔
344
                }
7✔
345

346
                if (currentCampaign) {
12✔
347
                        // Base campaign gets largest increment of $5 under the user's balance up to $25 or the unreserved amount
9✔
348
                        if (currentCampaign === BASE_CAMPAIGN) {
9✔
349
                                let val = Math.floor(userBalance / 5) * 5;
5✔
350

351
                                // eslint-disable-next-line no-nested-ternary
5✔
352
                                val = val === 0 ? 5 : (val > 25 ? 25 : val);
5✔
353

354
                                return Number(val <= unreservedAmount ? val : unreservedAmount).toFixed();
5✔
355
                        }
5✔
356

357
                        // Top up campaign defaults to $5
4✔
358
                        return Number(unreservedAmount > 5 ? 5 : unreservedAmount).toFixed();
9!
359
                }
9✔
360
        }
12✔
361

362
        // Handle when $5 notes isn't enabled
4✔
363
        if (isBetween25And50(unreservedAmount) || isLessThan25(unreservedAmount)) {
13✔
364
                return Number(unreservedAmount).toFixed();
2✔
365
        }
2✔
366

367
        // $25 is the fallback default selected option
2✔
368
        return '25';
2✔
369
}
2✔
370

371
/**
1✔
372
 * Gets the custom href for the loan card
1✔
373
 *
1✔
374
 * @param {Object} router The router object from the Vue component
1✔
375
 * @param {string} loanId The ID of the loan
1✔
376
 * @returns {string} The custom href for the loan card
1✔
377
 */
1✔
378
export function getCustomHref(router, loanId) {
1✔
379
        const resolvedRoute = router.resolve({
3✔
380
                query: { ...router.currentRoute.value.query, loanId },
3✔
381
        });
3✔
382
        const urlString = resolvedRoute.href;
3✔
383

384
        return urlString;
3✔
385
}
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc