Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add paused property for incentives [WIP] #631

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"@fastify/cors": "^9.0.1",
"@fastify/sensible": "^5.6.0",
"@fastify/swagger": "^8.12.0",
"@js-joda/core": "^5.6.1",
"@js-joda/timezone": "^2.21.1",
"@types/cheerio": "^0.22.35",
"axios": "^1.7.7",
"axios-retry": "^4.0.0",
Expand All @@ -30,7 +32,6 @@
},
"devDependencies": {
"@fastify/type-provider-json-schema-to-ts": "^2.2.2",
"@js-joda/core": "^5.6.1",
"@js-joda/locale_en-us": "^4.14.0",
"@types/glob": "^8.1.0",
"@types/lodash": "^4.17.13",
Expand Down
5 changes: 5 additions & 0 deletions src/data/state_incentives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type StateIncentive = {
bonus_available?: boolean;
low_income?: string;
more_info_url?: LocalizableString;
active?: boolean;
};

const incentivePropertySchema = {
Expand All @@ -44,6 +45,10 @@ const incentivePropertySchema = {
pattern: START_END_DATE_REGEX.source,
nullable: true,
},
active: {
type: 'boolean',
nullable: true,
},
payment_methods: {
type: 'array',
items: { type: 'string', enum: Object.values(PaymentMethod) },
Expand Down
4 changes: 4 additions & 0 deletions src/data/types/incentive-status-to-include.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum IncentiveStatusToInclude {
Active = 'active',
All = 'all',
}
80 changes: 80 additions & 0 deletions src/lib/dates.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LocalDate, YearMonth } from '@js-joda/core';

/**
* This allows representing the start or end date of an incentive at the
* granularity of:
Expand All @@ -14,3 +16,81 @@
*/
export const START_END_DATE_REGEX =
/^\d{4}(H[12]|Q[1-4]|-(0[1-9]|1[0-2])(-(0[1-9]|1[0-9]|2[0-9]|3[01]))?)?$/;

/**
* Returns a LocalDate representing the last possible day of the period denoted
* by the given start/end date string. Assumes that the given string matches the
* regex above.
*/
export function lastDayOf(incentiveDate: string): LocalDate {
const quarterHalfMatch = incentiveDate.match(/(\d{4})(Q[1-4]|H[1-2])/);
if (quarterHalfMatch) {
const year = parseInt(quarterHalfMatch[1]);
switch (quarterHalfMatch[2]) {
case 'Q1':
return LocalDate.of(year, 3, 31);
case 'Q2':
case 'H1':
return LocalDate.of(year, 6, 30);
case 'Q3':
return LocalDate.of(year, 9, 30);
case 'Q4':
case 'H2':
return LocalDate.of(year, 12, 31);
}
}

// Now it must be a normal, partial ISO 8601 date
const components = incentiveDate.split('-').map(n => parseInt(n));

switch (components.length) {
case 1:
return LocalDate.of(components[0], 12, 31);
case 2:
return YearMonth.of(components[0], components[1]).atEndOfMonth();
case 3:
return LocalDate.of(components[0], components[1], components[2]);
}

// Should not get here if START_END_DATE_REGEX precondition holds
throw new Error(`Invalid end date ${incentiveDate}`);
}

/**
* Returns a LocalDate representing the first possible day of the period denoted
* by the given start/end date string. Assumes that the given string matches the
* regex above.
*/
export function firstDayOf(incentiveDate: string): LocalDate {
const quarterHalfMatch = incentiveDate.match(/(\d{4})(Q[1-4]|H[1-2])/);
if (quarterHalfMatch) {
const year = parseInt(quarterHalfMatch[1]);
switch (quarterHalfMatch[2]) {
case 'Q1':
case 'H1':
return LocalDate.of(year, 1, 1);
case 'Q2':
return LocalDate.of(year, 4, 1);
case 'Q3':
case 'H2':
return LocalDate.of(year, 7, 1);
case 'Q4':
return LocalDate.of(year, 10, 1);
}
}

// Now it must be a normal, partial ISO 8601 date
const components = incentiveDate.split('-').map(n => parseInt(n));

switch (components.length) {
case 1:
return LocalDate.of(components[0], 1, 1);
case 2:
return YearMonth.of(components[0], components[1]).atDay(1);
case 3:
return LocalDate.of(components[0], components[1], components[2]);
}

// Should not get here if START_END_DATE_REGEX precondition holds
throw new Error(`Invalid start date ${incentiveDate}`);
}
40 changes: 40 additions & 0 deletions src/lib/state-incentives-calculation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LocalDate, ZoneId } from '@js-joda/core';
import '@js-joda/timezone';
import { min } from 'lodash';
import { AuthoritiesByType, AuthorityType } from '../data/authorities';
import { DATA_PARTNERS_BY_STATE } from '../data/data_partners';
Expand All @@ -14,9 +16,11 @@ import {
} from '../data/state_incentives';
import { AmountType } from '../data/types/amount';
import { APICoverage } from '../data/types/coverage';
import { IncentiveStatusToInclude } from '../data/types/incentive-status-to-include';
import { OwnerStatus } from '../data/types/owner-status';
import { APISavings, zeroSavings } from '../schemas/v1/savings';
import { AMIAndEVCreditEligibility } from './ami-evcredit-calculation';
import { firstDayOf, lastDayOf } from './dates';
import {
CombinedValue,
RelationshipMaps,
Expand Down Expand Up @@ -132,6 +136,42 @@ export function calculateStateIncentivesAndSavings(
}
}

// skip if incentive is explicity marked as inactive and the request
// specifies only active incentives
if (
request.status_to_include === IncentiveStatusToInclude.Active &&
item.active === false
) {
eligible = false;
}

if (item.end_date) {
const lastValidDay = lastDayOf(item.end_date);

// Use the current day in Eastern time, the earliest timezone of the
// mainland US. This is conservative: when it's 2025-01-01 at 1am in
// Eastern time, it will still be 2024 in Pacific time, but incentives
// whose validity ends on 2024-12-31 will be counted as ineligible
// everywhere.
//
// One possible improvement would be to infer the user's timezone from
// their passed-in location, but that would be a lot of effort for fairly
// marginal gain.
if (LocalDate.now(ZoneId.of('America/New_York')).isAfter(lastValidDay)) {
eligible = false;
}
}

if (item.start_date) {
const firstValidDay = firstDayOf(item.start_date);

if (
LocalDate.now(ZoneId.of('America/New_York')).isBefore(firstValidDay)
) {
eligible = false;
}
}

if (eligible) {
eligibleIncentives.set(item.id, item);
} else {
Expand Down
8 changes: 8 additions & 0 deletions src/schemas/v1/calculator-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { API_DATA_PARTNER_SCHEMA } from '../../data/data_partners';
import { FilingStatus } from '../../data/tax_brackets';
import { API_COVERAGE_SCHEMA } from '../../data/types/coverage';
import { IncentiveStatusToInclude } from '../../data/types/incentive-status-to-include';
import { ALL_ITEMS } from '../../data/types/items';
import { OwnerStatus } from '../../data/types/owner-status';
import { API_INCENTIVE_SCHEMA } from './incentive';
Expand Down Expand Up @@ -123,6 +124,13 @@ taxes, and their spouse if they file taxes jointly.`,
'Option to include states which are in development and not fully launched.',
default: 'false',
},
status_to_include: {
type: 'string',
description: `Option to exclude incentives that has expired in the \
past, have yet to start, or are no longer active for another reason.`,
enum: Object.values(IncentiveStatusToInclude),
default: IncentiveStatusToInclude.All, // TODO: switch to active once unrelated tests are passing
},
},
additionalProperties: false,
oneOf: [
Expand Down
9 changes: 9 additions & 0 deletions src/schemas/v1/incentive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ modifications and additions. Examples:
description: `The date when the incentive stopped, or will stop, being \
available. Format is the same as for \`start_date\`.`,
},
active: {
type: 'boolean',
description: `Whether the incentive is currently being offered. This \
field is independent of the \`start_date\` and \`end_date\` fields, which \
are about the time period when the incentive is available to consumers. \
For example, this field may be \`false\` if the funding source for an \
incentive has run out, even if the incentive is still in the middle of its \
availability period.`,
},
short_description: {
type: 'string',
description: `A short display description for the incentive, localized \
Expand Down
29 changes: 28 additions & 1 deletion test/lib/dates.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { LocalDate } from '@js-joda/core';
import { test } from 'tap';
import { START_END_DATE_REGEX } from '../../src/lib/dates';
import {
firstDayOf,
lastDayOf,
START_END_DATE_REGEX,
} from '../../src/lib/dates';

const VALID_DATES = [
'2024',
Expand Down Expand Up @@ -36,3 +41,25 @@ test('invalid dates do not match the regex', async t => {
t.notOk(START_END_DATE_REGEX.test(date), `${date} should be invalid`);
});
});

test('lastDayOf is correct', async t => {
t.same(lastDayOf('2024'), LocalDate.of(2024, 12, 31));
t.same(lastDayOf('2024-11'), LocalDate.of(2024, 11, 30));
t.same(lastDayOf('2024-06-07'), LocalDate.of(2024, 6, 7));

// Leap days
t.same(lastDayOf('2024-02'), LocalDate.of(2024, 2, 29));
t.same(lastDayOf('2025-02'), LocalDate.of(2025, 2, 28));

t.same(lastDayOf('2024Q1'), LocalDate.of(2024, 3, 31));
t.same(lastDayOf('2025H2'), LocalDate.of(2025, 12, 31));
});

test('firstDayOf is correct', async t => {
t.same(firstDayOf('2024'), LocalDate.of(2024, 1, 1));
t.same(firstDayOf('2024-11'), LocalDate.of(2024, 11, 1));
t.same(firstDayOf('2024-06-07'), LocalDate.of(2024, 6, 7));

t.same(firstDayOf('2024Q3'), LocalDate.of(2024, 7, 1));
t.same(firstDayOf('2025H2'), LocalDate.of(2025, 7, 1));
});
71 changes: 71 additions & 0 deletions test/lib/incentives-calculation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Programs } from '../../src/data/programs';
import { StateIncentive } from '../../src/data/state_incentives';
import { FilingStatus } from '../../src/data/tax_brackets';
import { AmountType } from '../../src/data/types/amount';
import { IncentiveStatusToInclude } from '../../src/data/types/incentive-status-to-include';
import { PaymentMethod } from '../../src/data/types/incentive-types';
import { OwnerStatus } from '../../src/data/types/owner-status';
import { BETA_STATES, LAUNCHED_STATES } from '../../src/data/types/states';
Expand Down Expand Up @@ -793,3 +794,73 @@ test('correctly matches geo groups', async t => {
});
t.equal(withNoMassSave.incentives.filter(massSaveFilter).length, 0);
});

test('correctly filters out inactive incentive savings', async t => {
const inactiveIncentive: StateIncentive = {
active: false,
id: 'CO',
payment_methods: [PaymentMethod.TaxCredit],
program: 'co_hvacAndWaterHeaterIncentives',
amount: {
type: AmountType.DollarAmount,
number: 5000,
},
owner_status: [
OwnerStatus.Homeowner,
],
items: ['ducted_heat_pump'],
short_description: {
en: 'This is an inactive incentive that is only to be used for testing.',
},
};

const programs: Programs = {
co_hvacAndWaterHeaterIncentives: {
name: { en: '' },
url: { en: '' },
authority: 'mock-state-authority',
authority_type: AuthorityType.State,
},
};

const authorities: AuthoritiesByType = {
state: {},
utility: {},
city: {
'mock-state-authority': {
name: 'Colorado Mock Department of Energy',
city: 'Colorado Springs',
county_fips: '11111',
},
},
};

const request = {
include_beta_states: false,
owner_status: OwnerStatus.Homeowner,
household_income: 100000,
tax_filing: FilingStatus.Joint,
household_size: 4,
authority_types: [AuthorityType.State],
status_to_include: IncentiveStatusToInclude.Active,
};

const inactiveResult = calculateStateIncentivesAndSavings(
{
state: 'CO',
city: 'Colorado Springs',
county_fips: '11111',
zcta: '80903',
},
request,
[inactiveIncentive],
{},
authorities,
programs,
{ computedAMI80: 80000, computedAMI150: 150000, evCreditEligible: false },
);

t.ok(inactiveResult);
t.equal(inactiveResult.stateIncentives[0].eligible, false);
t.equal(inactiveResult.savings.tax_credit, 0);
});
Loading