Skip to content

Commit

Permalink
Merge pull request #4531 from kobotoolbox/free-tier-thresholds
Browse files Browse the repository at this point in the history
Remove free tier threshold defaults, make Plans page respect custom free tiers
  • Loading branch information
LMNTL committed Jul 12, 2023
2 parents b4e66ee + 2521a5a commit a7bd9c8
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 35 deletions.
80 changes: 64 additions & 16 deletions jsapp/js/account/plan.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ 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';

interface PlanState {
subscribedProduct: null | BaseSubscription;
Expand All @@ -44,6 +45,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 +106,30 @@ 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;
const display = envStore.data.free_tier_display;
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(
(subscription: BaseSubscription) =>
activeSubscriptionStatuses.includes(subscription.status),
[]
);

const hasActiveSubscription = useMemo(() => {
if (state.subscribedProduct) {
Expand All @@ -113,9 +141,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 +233,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 +245,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 +277,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 +296,7 @@ export default function Plan() {
}

return subscriptions.some((subscription: BaseSubscription) =>
hasManageableStatus(subscription)
hasManageableStatus(subscription)
);
},
[state.subscribedProduct]
Expand Down Expand Up @@ -347,6 +381,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 +498,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 +525,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
24 changes: 23 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,18 @@ 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 = {
storage: null,
data: null,
transcription_minutes: null,
translation_chars: null
};
public free_tier_display: FreeTierDisplay = {name: null, feature_list: []};

getProjectMetadataField(fieldName: string): EnvStoreFieldItem | boolean {
for (const f of this.project_metadata_fields) {
Expand All @@ -59,6 +78,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 +129,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

0 comments on commit a7bd9c8

Please sign in to comment.