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

Remove free tier threshold defaults, make Plans page respect custom free tiers #4531

Merged
merged 15 commits into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
79 changes: 63 additions & 16 deletions jsapp/js/account/plan.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import Button from 'js/components/common/button';
import classnames from 'classnames';
import LoadingSpinner from 'js/components/common/loadingSpinner';
import {notify} from 'js/utils';
import {BaseProduct} from "js/account/subscriptionStore";
import {BaseProduct} from 'js/account/subscriptionStore';
import EnvStore, {FreeTierThresholds, FreeTierDisplay} from 'js/envStore';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnvStore doesn't appear to be used. If this unused import is important for some reason, it should be documented.

import envStore from 'js/envStore';

interface PlanState {
subscribedProduct: null | BaseSubscription;
Expand All @@ -44,6 +46,11 @@ interface DataUpdates {
prodData?: any;
}

interface FreeTierOverride extends FreeTierThresholds{
name: string | null;
[key: `feature_list_${number}`]: string | null;
}

const initialState = {
subscribedProduct: null,
intervalFilter: 'year',
Expand Down Expand Up @@ -100,8 +107,28 @@ export default function Plan() {
[state.products, state.organization, state.subscribedProduct]
);

const hasManageableStatus = useCallback((subscription: BaseSubscription) =>
activeSubscriptionStatuses.includes(subscription.status), []);
const freeTierOverride = useMemo((): FreeTierOverride | null => {
if (envStore.isReady) {
const thresholds = envStore.data.free_tier_thresholds as FreeTierThresholds;
const display = envStore.data.free_tier_display as FreeTierDisplay;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need the as part. Without FreeTierDisplay it will error because it seems to think it could also be {} but there would be cleaner ways to address this. Either a conditional or better yet figure out why it sometimes is {} (but the later may be out of scope)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might either define it as public free_tier_display: FreeTierDisplay | null = null; if you want it to be in some null-ish state. Or else make your name optional if you consider {} to be a valid Free Tier Display.

The usage of "as" overrides type checking, making it less useful. It would be better to review how the nullish state should actually be presented.

const featureList : {[key: string]: string | null} = {};
display.feature_list.forEach((feature, key) => {
featureList[`feature_list_${key+1}`] = feature;
});
return {
name: display.name,
...thresholds,
...featureList,
};
}
return null;
}, [envStore.isReady]);

const hasManageableStatus = useCallback(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a useCallback because you want it to be a function that accepts various subscriptions right? Just checking my own understanding, I don't think there is a change to make.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep!

(subscription: BaseSubscription) =>
activeSubscriptionStatuses.includes(subscription.status),
[]
);

const hasActiveSubscription = useMemo(() => {
if (state.subscribedProduct) {
Expand All @@ -113,9 +140,7 @@ export default function Plan() {
}, [state.subscribedProduct]);

useMemo(() => {
if (
state.subscribedProduct?.length > 0
) {
if (state.subscribedProduct?.length > 0) {
const subscribedFilter =
state.subscribedProduct?.[0].items[0].price.recurring?.interval;
if (!hasManageableStatus(state.subscribedProduct)) {
Expand Down Expand Up @@ -207,7 +232,10 @@ export default function Plan() {
const filterAmount = state.products.map((product: Product) => {
const filteredPrices = product.prices.filter((price: BasePrice) => {
const interval = price.recurring?.interval;
return interval === state.intervalFilter && product.metadata.product_type === 'plan';
return (
interval === state.intervalFilter &&
product.metadata.product_type === 'plan'
);
});

return {
Expand All @@ -216,8 +244,12 @@ export default function Plan() {
};
});

return filterAmount.filter((product: Product) => product.prices)
.sort((priceA: Price, priceB: Price) => priceA.prices.unit_amount > priceB.prices.unit_amount);
return filterAmount
.filter((product: Product) => product.prices)
.sort(
(priceA: Price, priceB: Price) =>
priceA.prices.unit_amount > priceB.prices.unit_amount
);
}
return [];
}, [state.products, state.intervalFilter]);
Expand All @@ -244,9 +276,10 @@ export default function Plan() {
const subscriptions = getSubscriptionsForProductId(product.id);

if (subscriptions.length > 0) {
return subscriptions.some((subscription: BaseSubscription) =>
subscription.items[0].price.id === product.prices.id &&
hasManageableStatus(subscription)
return subscriptions.some(
(subscription: BaseSubscription) =>
subscription.items[0].price.id === product.prices.id &&
hasManageableStatus(subscription)
);
}
return false;
Expand All @@ -262,7 +295,7 @@ export default function Plan() {
}

return subscriptions.some((subscription: BaseSubscription) =>
hasManageableStatus(subscription)
hasManageableStatus(subscription)
);
},
[state.subscribedProduct]
Expand Down Expand Up @@ -347,6 +380,17 @@ export default function Plan() {
return expandBool;
};

const getFeatureMetadata = (price: Price, featureItem: string) => {
if (
price.prices.unit_amount === 0 &&
freeTierOverride &&
freeTierOverride.hasOwnProperty(featureItem)
) {
return freeTierOverride[featureItem as keyof FreeTierOverride];
}
return price.prices.metadata?.[featureItem] || price.metadata[featureItem];
};

useEffect(() => {
hasMetaFeatures();
}, [state.products]);
Expand Down Expand Up @@ -453,7 +497,11 @@ export default function Plan() {
[styles.planContainer]: true,
})}
>
<h1 className={styles.priceName}> {price.name} </h1>
<h1 className={styles.priceName}>
{price.prices?.unit_amount
? price.name
: freeTierOverride?.name || price.name}
</h1>
<div className={styles.priceTitle}>
{!price.prices?.unit_amount
? t('Free')
Expand All @@ -476,8 +524,7 @@ export default function Plan() {
}
/>
</div>
{price.prices.metadata?.[featureItem] ||
price.metadata[featureItem]}
{getFeatureMetadata(price, featureItem)}
</li>
)
)}
Expand Down
7 changes: 5 additions & 2 deletions jsapp/js/dataInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ROOT_URL,
COMMON_QUERIES,
} from './constants';
import type {EnvStoreFieldItem, SocialApp} from 'js/envStore';
import type {EnvStoreFieldItem, FreeTierDisplay, SocialApp} from 'js/envStore';
import type {LanguageCode} from 'js/components/languages/languagesStore';
import type {
AssetTypeName,
Expand All @@ -20,6 +20,7 @@ import type {
} from 'js/constants';
import type {Json} from './components/common/common.interfaces';
import type {ProjectViewsSettings} from './projects/customViewStore';
import {FreeTierThresholds} from "js/envStore";

interface AssetsRequestData {
q?: string;
Expand Down Expand Up @@ -723,11 +724,13 @@ export interface EnvironmentResponse {
frontend_min_retry_time: number;
frontend_max_retry_time: number;
asr_mt_features_enabled: boolean;
mfa_localized_help_text: {[name: string]: string};
mfa_localized_help_text: { [name: string]: string };
mfa_enabled: boolean;
mfa_code_length: number;
stripe_public_key: string | null;
social_apps: SocialApp[];
free_tier_thresholds: FreeTierThresholds;
free_tier_display: FreeTierDisplay;
}

export interface AssetSubscriptionsResponse {
Expand Down
19 changes: 18 additions & 1 deletion jsapp/js/envStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ export interface SocialApp {
client_id: string;
}

export interface FreeTierThresholds {
storage?: number | null;
data?: number | null;
transcription_minutes?: number | null;
translation_chars?: number | null;
}

export interface FreeTierDisplay {
name: string | null;
feature_list: [string];
}

class EnvStoreData {
public terms_of_service_url = '';
public privacy_policy_url = '';
Expand All @@ -45,11 +57,13 @@ class EnvStoreData {
public translation_languages: TransxLanguages = {};
public submission_placeholder = '';
public asr_mt_features_enabled = false;
public mfa_localized_help_text: {[name: string]: string} = {};
public mfa_localized_help_text: { [name: string]: string } = {};
public mfa_enabled = false;
public mfa_code_length = 6;
public stripe_public_key: string | null = null;
public social_apps: SocialApp[] = [];
public free_tier_thresholds: FreeTierThresholds = {};
public free_tier_display: FreeTierDisplay | {} = {};

getProjectMetadataField(fieldName: string): EnvStoreFieldItem | boolean {
for (const f of this.project_metadata_fields) {
Expand All @@ -59,6 +73,7 @@ class EnvStoreData {
}
return false;
}

public getUserMetadataField(fieldName: string): EnvStoreFieldItem | boolean {
for (const f of this.user_metadata_fields) {
if (f.name === fieldName) {
Expand Down Expand Up @@ -109,6 +124,8 @@ class EnvStore {
this.data.mfa_code_length = response.mfa_code_length;
this.data.stripe_public_key = response.stripe_public_key;
this.data.social_apps = response.social_apps;
this.data.free_tier_thresholds = response.free_tier_thresholds;
this.data.free_tier_display = response.free_tier_display;

if (response.sector_choices) {
this.data.sector_choices = response.sector_choices.map(this.nestedArrToChoiceObjs);
Expand Down
28 changes: 23 additions & 5 deletions kobo/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,10 @@
),
'FREE_TIER_THRESHOLDS': (
json.dumps({
'storage': int(1 * 1024 * 1024 * 1024), # 1 GB
'data': 1000,
'transcription_minutes': 10,
'translation_chars': 6000,
'storage': None,
'data': None,
'transcription_minutes': None,
'translation_chars': None,
}),
'Free tier thresholds: storage in kilobytes, '
'data (number of submissions), '
Expand All @@ -334,6 +334,17 @@
# Use custom field for schema validation
'free_tier_threshold_jsonschema'
),
'FREE_TIER_DISPLAY': (
json.dumps(
{
'name': None,
'feature_list': [],
}
),
'Free tier frontend settings: name to use for the free tier, '
'array of text strings to display on the feature list of the Plans page',
'free_tier_display_jsonschema',
),
'PROJECT_TRASH_GRACE_PERIOD': (
7,
'Number of days to keep projects in trash after users (soft-)deleted '
Expand All @@ -355,6 +366,10 @@
'kpi.fields.jsonschema_form_field.FreeTierThresholdField',
{'widget': 'django.forms.Textarea'},
],
'free_tier_display_jsonschema': [
'kpi.fields.jsonschema_form_field.FreeTierDisplayField',
{'widget': 'django.forms.Textarea'},
],
'metadata_fields_jsonschema': [
'kpi.fields.jsonschema_form_field.MetadataFieldsListField',
{'widget': 'django.forms.Textarea'},
Expand Down Expand Up @@ -386,7 +401,6 @@
'EXPOSE_GIT_REV',
'FRONTEND_MIN_RETRY_TIME',
'FRONTEND_MAX_RETRY_TIME',
'FREE_TIER_THRESHOLDS',
),
'Rest Services': (
'ALLOW_UNSECURED_HOOK_ENDPOINTS',
Expand Down Expand Up @@ -414,6 +428,10 @@
'ACCOUNT_TRASH_GRACE_PERIOD',
'PROJECT_TRASH_GRACE_PERIOD',
),
'Tier settings': (
'FREE_TIER_THRESHOLDS',
'FREE_TIER_DISPLAY',
),
}

# Tell django-constance to use a database model instead of Redis
Expand Down
33 changes: 29 additions & 4 deletions kpi/fields/jsonschema_form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,16 @@ class FreeTierThresholdField(JsonSchemaFormField):
"""
Validates that the input has required properties with expected types
"""

def __init__(self, *args, **kwargs):
schema = {
'type': 'object',
'uniqueItems': True,
'properties': {
'storage': {'type': 'integer'},
'data': {'type': 'integer'},
'transcription_minutes': {'type': 'integer'},
'translation_chars': {'type': 'integer'},
'storage': {'type': ['integer', 'null']},
'data': {'type': ['integer', 'null']},
'transcription_minutes': {'type': ['integer', 'null']},
'translation_chars': {'type': ['integer', 'null']},
},
'required': [
'storage',
Expand All @@ -50,6 +51,28 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, schema=schema, **kwargs)


class FreeTierDisplayField(JsonSchemaFormField):
"""
Validates that the input has required properties with expected types
"""

def __init__(self, *args, **kwargs):
schema = {
'type': 'object',
'uniqueItems': True,
'properties': {
'name': {'type': ['string', 'null']},
'feature_list': {
'type': 'array',
'items': {'type': 'string'},
},
},
'required': ['name', 'feature_list'],
'additionalProperties': False,
}
super().__init__(*args, schema=schema, **kwargs)


class MetadataFieldsListField(JsonSchemaFormField):
"""
Validates that the input is an array of objects with "name" and "required"
Expand All @@ -60,6 +83,7 @@ class MetadataFieldsListField(JsonSchemaFormField):
]
"""

def __init__(self, *args, **kwargs):
schema = {
'type': 'array',
Expand All @@ -82,6 +106,7 @@ class MfaHelpTextField(JsonSchemaFormField):
Validates that the input is an object which contains at least the 'default'
key.
"""

def __init__(self, *args, **kwargs):
schema = {
'type': 'object',
Expand Down
29 changes: 29 additions & 0 deletions kpi/migrations/0051_set_free_tier_thresholds_to_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import json

from constance import config
from django.db import migrations


def reset_free_tier_thresholds(apps, schema_editor):
# The constance defaults for FREE_TIER_THRESHOLDS changed, so we set existing config to the new default value
thresholds = {
'storage': None,
'data': None,
'transcription_minutes': None,
'translation_chars': None,
}
setattr(config, 'FREE_TIER_THRESHOLDS', json.dumps(thresholds))


class Migration(migrations.Migration):

dependencies = [
('kpi', '0050_add_indexes_to_import_and_export_tasks'),
]

operations = [
migrations.RunPython(
reset_free_tier_thresholds,
migrations.RunPython.noop,
)
]