Skip to content

Commit 52f0ee7

Browse files
authored
feat: more no money; expose properties (#70)
* feat: added error reason for no money * feat: added error reason for no money; exposed properties in rule
1 parent 3fa848e commit 52f0ee7

13 files changed

+107
-89
lines changed

src/calculations/can-invest-by-user.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,39 +17,36 @@ export function canInvestByUser(
1717
rental: IRentalPropertyEntity,
1818
user: IUserInvestorCheck,
1919
date: Date,
20-
properties: IRentalPropertyEntity[]
20+
properties: IRentalPropertyEntity[],
2121
): IRentalInvestorValidator {
2222
const result = new RentalInvestorValidator();
2323

2424
if (rental.isOwned) {
25-
result.results.push(new UserInvestResult(InvestmentReasons.PropertyIsAlreadyOwned));
25+
result.results.push(new UserInvestResult(InvestmentReasons.PropertyIsAlreadyOwned, '', []));
2626
return result;
2727
}
2828

2929
const minCostDownByRule = getMinCostDownByRule(rental, user.purchaseRules);
3030
if (!user.hasMoneyToInvest(date, properties, minCostDownByRule)) {
3131
result.results.push(
32-
new UserInvestResult(
33-
InvestmentReasons.UserHasNoMoneyToInvest,
34-
`user balance: ${user.ledgerCollection.getBalance(date)}`
35-
)
32+
new UserInvestResult(InvestmentReasons.UserHasNoMoneyToInvest, `user balance: ${user.ledgerCollection.getBalance(date)}`, [
33+
{ name: 'balance', value: user.ledgerCollection.getBalance(date) },
34+
]),
3635
);
3736
}
3837

3938
if (!user.hasMinimumSavings(date, properties)) {
4039
result.results.push(
4140
new UserInvestResult(
4241
InvestmentReasons.UserHasNotSavedEnoughMoney,
43-
`user balance: ${user.ledgerCollection.getBalance(date)}, minimumSavings: ${user.getMinimumSavings(
44-
date,
45-
properties
46-
)}`
47-
)
42+
`user balance: ${user.ledgerCollection.getBalance(date)}, minimumSavings: ${user.getMinimumSavings(date, properties)}`,
43+
[{ name: 'balance', value: user.ledgerCollection.getBalance(date) }],
44+
),
4845
);
4946
}
5047

5148
if (!user.purchaseRules || user.purchaseRules.length === 0) {
52-
result.results.push(new UserInvestResult(InvestmentReasons.NoRules, 'user has no purchase rules'));
49+
result.results.push(new UserInvestResult(InvestmentReasons.NoRules, 'user has no purchase rules', []));
5350
return result;
5451
}
5552

@@ -72,7 +69,7 @@ export function canInvestByUser(
7269
return [];
7370
})
7471
.flat()
75-
.filter((x) => x !== undefined)
72+
.filter((x) => x !== undefined),
7673
);
7774

7875
return result;

src/calculations/get-cost-down-user-investment-results.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,20 @@ import { UserResultEstimates } from '../investments/user-result-estimates';
77
export const getCostDownUserInvestmentResults: UserResultEstimates = (
88
rental: IRentalPropertyEntity,
99
_holdRules: IRuleEvaluation<HoldRuleTypes>[],
10-
purchaseRules: IRuleEvaluation<PurchaseRuleTypes>[]
10+
purchaseRules: IRuleEvaluation<PurchaseRuleTypes>[],
1111
): UserInvestResult[] => {
1212
if (!rental) {
1313
throw new Error('Invalid Argument: rental is falsy');
1414
}
1515

16-
const outOfPocket = purchaseRules.find(
17-
(x) => x.propertyType === rental.propertyType && x.type === PurchaseRuleTypes.MaxEstimatedOutOfPocket
18-
);
16+
const outOfPocket = purchaseRules.find((x) => x.propertyType === rental.propertyType && x.type === PurchaseRuleTypes.MaxEstimatedOutOfPocket);
1917

2018
if (!outOfPocket) {
2119
return [];
2220
}
2321

2422
const resultReasonToRule = getInvestmentReasonsForPurchaseTypes<IRentalPropertyEntity>(rental).find((reasonToRule) =>
25-
reasonToRule.isRuleMatch(outOfPocket.type)
23+
reasonToRule.isRuleMatch(outOfPocket.type),
2624
);
2725

2826
if (!resultReasonToRule) {
@@ -31,7 +29,10 @@ export const getCostDownUserInvestmentResults: UserResultEstimates = (
3129

3230
const userInvestResults = resultReasonToRule.values.map((v) => {
3331
if (!outOfPocket.evaluate(v)) {
34-
return new UserInvestResult(resultReasonToRule.investmentReason, `rule: ${outOfPocket.value} property: ${v}`);
32+
return new UserInvestResult(resultReasonToRule.investmentReason, `rule: ${outOfPocket.value} property: ${v}`, [
33+
{ value: outOfPocket.value, name: 'rule' },
34+
{ value: v, name: 'property' },
35+
]);
3536
}
3637
return null;
3738
});

src/calculations/get-equity-capture-user-investment-results.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,33 @@ export const getEquityCaptureUserInvestmentResults: UserResultEstimates = (
1313
rental: IRentalPropertyEntity,
1414
holdRules: IRuleEvaluation<HoldRuleTypes>[],
1515
purchaseRules: IRuleEvaluation<PurchaseRuleTypes>[],
16-
date: Date
16+
date: Date,
1717
): UserInvestResult[] => {
1818
if (!rental) {
1919
throw new Error('Invalid Argument: rental is falsy');
2020
}
2121

2222
const maxCapGains = purchaseRules.find(
23-
(x) => x.propertyType === rental.propertyType && x.type === PurchaseRuleTypes.MinEstimatedCapitalGainsPercent
23+
(x) => x.propertyType === rental.propertyType && x.type === PurchaseRuleTypes.MinEstimatedCapitalGainsPercent,
2424
);
2525

2626
if (!maxCapGains) {
2727
return [];
2828
}
2929

3030
const resultReasonToRule = getInvestmentReasonsForPurchaseTypes<IRentalPropertyEntity>(rental).find((reasonToRule) =>
31-
reasonToRule.isRuleMatch(maxCapGains.type)
31+
reasonToRule.isRuleMatch(maxCapGains.type),
3232
);
3333

3434
if (!resultReasonToRule) {
3535
return [];
3636
}
3737

38-
const minYears = (holdRules || []).find(
39-
(x) => x.type === HoldRuleTypes.MinSellInYears && x.propertyType === rental.propertyType
40-
);
38+
const minYears = (holdRules || []).find((x) => x.type === HoldRuleTypes.MinSellInYears && x.propertyType === rental.propertyType);
4139

4240
const inHoldYears = addYears(date, minYears?.value || 1);
4341

44-
const sellPriceEstimate = getSellPriceEstimate(
45-
date,
46-
inHoldYears,
47-
rental.purchasePrice,
48-
rental.sellPriceAppreciationPercent
49-
);
42+
const sellPriceEstimate = getSellPriceEstimate(date, inHoldYears, rental.purchasePrice, rental.sellPriceAppreciationPercent);
5043

5144
const userInvestResults = resultReasonToRule.values.map((v) => {
5245
let investmentPercent = 100;
@@ -57,10 +50,10 @@ export const getEquityCaptureUserInvestmentResults: UserResultEstimates = (
5750

5851
const equityCaptureAmount = getEquityCaptureAmount(investmentPercent, v, sellPriceEstimate);
5952
if (!maxCapGains.evaluate(equityCaptureAmount)) {
60-
return new UserInvestResult(
61-
resultReasonToRule.investmentReason,
62-
`rule: ${maxCapGains.value} property: ${equityCaptureAmount}`
63-
);
53+
return new UserInvestResult(resultReasonToRule.investmentReason, `rule: ${maxCapGains.value} property: ${equityCaptureAmount}`, [
54+
{ value: maxCapGains.value, name: 'rule' },
55+
{ value: equityCaptureAmount, name: 'property' },
56+
]);
6457
}
6558
return null;
6659
});

src/investments/reason-to-rule.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ export interface IReasonToRuleMeta<TR extends PurchaseRuleTypes | HoldRuleTypes>
1212
ruleType?: TR;
1313
}
1414

15-
export interface IReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRuleTypes | HoldRuleTypes>
16-
extends IReasonToRuleMeta<TR> {
15+
export interface IReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRuleTypes | HoldRuleTypes> extends IReasonToRuleMeta<TR> {
1716
propertyType: PropertyType;
1817
propertyKey: keyof T & string;
1918
values: number[];
@@ -32,13 +31,11 @@ export interface IReasonToRule<T extends IRentalPropertyEntity, TR extends Purch
3231
rental: IRentalPropertyEntity,
3332
holdRules: IRuleEvaluation<HoldRuleTypes>[],
3433
purchaseRules: IRuleEvaluation<PurchaseRuleTypes>[],
35-
date: Date
34+
date: Date,
3635
): UserInvestResult[];
3736
}
3837

39-
export class ReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRuleTypes | HoldRuleTypes>
40-
implements IReasonToRule<T, TR>
41-
{
38+
export class ReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRuleTypes | HoldRuleTypes> implements IReasonToRule<T, TR> {
4239
private readonly overrideUserResultEstimates?: UserResultEstimates;
4340

4441
constructor(
@@ -47,7 +44,7 @@ export class ReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRu
4744
propertyKey: keyof T & string,
4845
value: number[],
4946
ruleType?: TR,
50-
overrideUserResultEstimates?: UserResultEstimates
47+
overrideUserResultEstimates?: UserResultEstimates,
5148
) {
5249
this.investmentReason = investmentReason;
5350
this.propertyType = propertyType;
@@ -107,7 +104,7 @@ export class ReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRu
107104
rental: IRentalPropertyEntity,
108105
holdRules: IRuleEvaluation<HoldRuleTypes>[],
109106
purchaseRules: IRuleEvaluation<PurchaseRuleTypes>[],
110-
date: Date
107+
date: Date,
111108
): UserInvestResult[] {
112109
if (this.overrideUserResultEstimates) {
113110
return this.overrideUserResultEstimates(rental, holdRules, purchaseRules, date);
@@ -121,7 +118,10 @@ export class ReasonToRule<T extends IRentalPropertyEntity, TR extends PurchaseRu
121118

122119
return this.values.map((v) => {
123120
if (!rule.evaluate(v)) {
124-
return new UserInvestResult(this.investmentReason, `rule: ${rule.value} property: ${v}`);
121+
return new UserInvestResult(this.investmentReason, `rule: ${rule.value} property: ${v}`, [
122+
{ name: 'rule', value: rule.value },
123+
{ name: 'property', value: v },
124+
]);
125125
}
126126
});
127127
}

src/investments/user-invest-result.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { InvestmentReasons } from './investment-reasons';
33
export interface IUserInvestResult {
44
message: string;
55
investmentReason: InvestmentReasons;
6+
properties: { name: string; value: number }[];
67
}
78

89
export class UserInvestResult implements IUserInvestResult {
@@ -12,10 +13,12 @@ export class UserInvestResult implements IUserInvestResult {
1213
}
1314

1415
public investmentReason: InvestmentReasons;
16+
public properties: { name: string; value: number }[];
1517

16-
constructor(reason: InvestmentReasons = InvestmentReasons.Unknown, message = '') {
18+
constructor(reason: InvestmentReasons = InvestmentReasons.Unknown, message: string, properties: { name: string; value: number }[]) {
1719
this.investmentReason = reason;
1820
this._message = message;
21+
this.properties = properties;
1922
}
2023

2124
private readonly _message: string;

src/time/i-historical-reason.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export interface IHistoricalReason {
22
reason: string;
33
date: Date;
4+
additionalInfo: { name: string; value: number }[];
45
}

src/time/looper.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,17 @@ export const looper: LooperType = (options: ILoopRecursiveOptions, timeline: ITi
9090
result.rentals.map((x) => x.property).filter((x) => x.isOwned),
9191
)
9292
) {
93-
const issue = new UserInvestResult(
93+
const issueUserHasNoMoneyToInvest = new UserInvestResult(
9494
InvestmentReasons.UserHasNoMoneyToInvest,
9595
`user balance: ${result.user.ledgerCollection.getBalance(result.endDate)}`,
96+
[{ name: 'balance', value: result.user.ledgerCollection.getBalance(result.endDate) }],
9697
);
9798

9899
result.rentals.forEach((r) => {
99100
r.reasons.push({
100-
reason: issue.message,
101+
reason: issueUserHasNoMoneyToInvest.message,
101102
date: cloneDateUtc(timeline.endDate),
103+
additionalInfo: [{ name: 'balance', value: result.user.ledgerCollection.getBalance(result.endDate) }],
102104
});
103105
});
104106

@@ -120,6 +122,7 @@ export const looper: LooperType = (options: ILoopRecursiveOptions, timeline: ITi
120122
validator.results.map((reasons) => ({
121123
reason: reasons.message,
122124
date: cloneDateUtc(timeline.endDate),
125+
additionalInfo: reasons.properties,
123126
})),
124127
);
125128
}
@@ -167,6 +170,22 @@ export const looper: LooperType = (options: ILoopRecursiveOptions, timeline: ITi
167170
rentalProperty.costDownPrice = minCostDownByRule;
168171
}
169172
}
173+
} else {
174+
const issueMetMinCostYetUserHasNoMoneyToInvest = new UserInvestResult(
175+
InvestmentReasons.UserHasNoMoneyToInvest,
176+
`user balance: ${result.user.ledgerCollection.getBalance(result.endDate)}`,
177+
[{ name: 'balance', value: result.user.ledgerCollection.getBalance(result.endDate) }],
178+
);
179+
180+
result.rentals
181+
.filter((x) => x.property.id === rentalProperty.id)
182+
.forEach((r) => {
183+
r.reasons.push({
184+
reason: issueMetMinCostYetUserHasNoMoneyToInvest.message,
185+
date: cloneDateUtc(timeline.endDate),
186+
additionalInfo: issueMetMinCostYetUserHasNoMoneyToInvest.properties,
187+
});
188+
});
170189
}
171190
}
172191
}

tests/calculations/can-invest-by-user.spec.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('and canInvestByUser', () => {
3030

3131
const expected: jest.Mocked<IRentalInvestorValidator> = {
3232
canInvest: false,
33-
results: [new UserInvestResult(InvestmentReasons.PropertyIsAlreadyOwned)],
33+
results: [new UserInvestResult(InvestmentReasons.PropertyIsAlreadyOwned, '', [])],
3434
} as jest.Mocked<IRentalInvestorValidator>;
3535

3636
const actual = canInvestByUser(instance, null, today, null);
@@ -62,8 +62,8 @@ describe('and canInvestByUser', () => {
6262
const expected: IRentalInvestorValidator = {
6363
canInvest: false,
6464
results: [
65-
new UserInvestResult(InvestmentReasons.UserHasNoMoneyToInvest, 'user balance: 0'),
66-
new UserInvestResult(InvestmentReasons.UserHasNotSavedEnoughMoney, 'user balance: 0, minimumSavings: 1'),
65+
new UserInvestResult(InvestmentReasons.UserHasNoMoneyToInvest, 'user balance: 0', [{ value: 0, name: 'balance' }]),
66+
new UserInvestResult(InvestmentReasons.UserHasNotSavedEnoughMoney, 'user balance: 0, minimumSavings: 1', [{ value: 0, name: 'balance' }]),
6767
],
6868
};
6969

@@ -98,7 +98,7 @@ describe('and canInvestByUser', () => {
9898
const expected: IRentalInvestorValidator = {
9999
canInvest: false,
100100
results: [
101-
new UserInvestResult(InvestmentReasons.UserHasNotSavedEnoughMoney, 'user balance: 0, minimumSavings: 1'),
101+
new UserInvestResult(InvestmentReasons.UserHasNotSavedEnoughMoney, 'user balance: 0, minimumSavings: 1', [{ value: 0, name: 'balance' }]),
102102
],
103103
};
104104

@@ -123,7 +123,7 @@ describe('and canInvestByUser', () => {
123123

124124
const expected: IRentalInvestorValidator = {
125125
canInvest: false,
126-
results: [new UserInvestResult(InvestmentReasons.NoRules, 'user has no purchase rules')],
126+
results: [new UserInvestResult(InvestmentReasons.NoRules, 'user has no purchase rules', [])],
127127
};
128128

129129
expect(canInvestByUser(instance, user, today, null)).toMatchObject(expected);
@@ -162,14 +162,14 @@ describe('and canInvestByUser', () => {
162162
const expected: IRentalInvestorValidator = {
163163
canInvest: false,
164164
results: [
165-
new UserInvestResult(
166-
InvestmentReasons.DoesNotMeetUserRuleAskingPrice,
167-
`rule: 20000 property: ${instance.purchasePrice}`
168-
),
169-
new UserInvestResult(
170-
InvestmentReasons.DoesNotMeetUserRuleOutOfPocket,
171-
`rule: 50000 property: ${instance.costDownPrice}`
172-
),
165+
new UserInvestResult(InvestmentReasons.DoesNotMeetUserRuleAskingPrice, `rule: 20000 property: ${instance.purchasePrice}`, [
166+
{ value: 20000, name: 'rule' },
167+
{ value: instance.purchasePrice, name: 'property' },
168+
]),
169+
new UserInvestResult(InvestmentReasons.DoesNotMeetUserRuleOutOfPocket, `rule: 50000 property: ${instance.costDownPrice}`, [
170+
{ value: 50000, name: 'rule' },
171+
{ value: instance.costDownPrice, name: 'property' },
172+
]),
173173
],
174174
};
175175

@@ -291,8 +291,14 @@ describe('and canInvestByUser', () => {
291291
const expected: IRentalInvestorValidator = {
292292
canInvest: false,
293293
results: [
294-
new UserInvestResult(InvestmentReasons.DoesNotMeetUserRuleEquityCapture, 'rule: 50000 property: 25687.5'),
295-
new UserInvestResult(InvestmentReasons.DoesNotMeetUserRuleEquityCapture, 'rule: 50000 property: 188000'),
294+
new UserInvestResult(InvestmentReasons.DoesNotMeetUserRuleEquityCapture, 'rule: 50000 property: 25687.5', [
295+
{ name: 'rule', value: 50000 },
296+
{ name: 'property', value: 25687.5 },
297+
]),
298+
new UserInvestResult(InvestmentReasons.DoesNotMeetUserRuleEquityCapture, 'rule: 50000 property: 188000', [
299+
{ name: 'rule', value: 50000 },
300+
{ name: 'property', value: 188000 },
301+
]),
296302
],
297303
};
298304

0 commit comments

Comments
 (0)