Skip to content

Commit

Permalink
Merge pull request #4660 from kobotoolbox/storage-addons-frontend
Browse files Browse the repository at this point in the history
Show storage add-ons in usage limits
  • Loading branch information
LMNTL committed Oct 12, 2023
2 parents 1109451 + ca0732e commit 5ed6a45
Show file tree
Hide file tree
Showing 10 changed files with 291 additions and 72 deletions.
178 changes: 143 additions & 35 deletions jsapp/js/account/stripe.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,17 @@ export interface Organization {
slug: string;
}

enum Limits {
'unlimited' = 'unlimited',
}

type LimitAmount = number | Limits.unlimited;

export interface AccountLimit {
submission_limit: 'unlimited' | number;
nlp_seconds_limit: 'unlimited' | number;
nlp_character_limit: 'unlimited' | number;
storage_bytes_limit: 'unlimited' | number;
submission_limit: LimitAmount | number;
nlp_seconds_limit: LimitAmount | number;
nlp_character_limit: LimitAmount | number;
storage_bytes_limit: LimitAmount | number;
}

export interface Product extends BaseProduct {
Expand All @@ -73,6 +79,13 @@ export interface Portal {
url: string;
}

const DEFAULT_LIMITS: AccountLimit = Object.freeze({
submission_limit: Limits.unlimited,
nlp_seconds_limit: Limits.unlimited,
nlp_character_limit: Limits.unlimited,
storage_bytes_limit: Limits.unlimited,
});

export async function getProducts() {
return fetchGet<PaginatedResponse<Product>>(endpoints.PRODUCTS_URL);
}
Expand All @@ -87,29 +100,38 @@ export async function getOrganization() {
return fetchGet<PaginatedResponse<Organization>>(endpoints.ORGANIZATION_URL);
}

/**
* Start a checkout session for the given price and organization. Response contains the checkout URL.
*/
export async function postCheckout(priceId: string, organizationId: string) {
return fetchPost<Checkout>(
`${endpoints.CHECKOUT_URL}?price_id=${priceId}&organization_id=${organizationId}`,
{}
);
}

/**
* Get the URL of the Stripe customer portal for an organization.
*/
export async function postCustomerPortal(organizationId: string) {
return fetchPost<Portal>(
`${endpoints.PORTAL_URL}?organization_id=${organizationId}`,
{}
);
}

/**
* Get the subscription interval (`'month'` or `'year'`) for the logged-in user.
* Returns `'month'` for users on the free plan.
*/
export async function getSubscriptionInterval() {
await when(() => envStore.isReady);
if (envStore.data.stripe_public_key) {
if (!subscriptionStore.isPending && !subscriptionStore.isInitialised) {
subscriptionStore.fetchSubscriptionInfo();
}
await when(() => subscriptionStore.isInitialised);
const subscriptionList: SubscriptionInfo[] =
subscriptionStore.subscriptionResponse;
const subscriptionList: SubscriptionInfo[] = subscriptionStore.planResponse;
const activeSubscription = subscriptionList.find((sub) =>
ACTIVE_STRIPE_STATUSES.includes(sub.status)
);
Expand All @@ -120,19 +142,104 @@ export async function getSubscriptionInterval() {
return 'month';
}

export async function getAccountLimits() {
/**
* Extract the limits from Stripe product/price metadata and convert their values from string to number (if necessary.)
* Will only return limits that exceed the ones in `limitsToCompare`, or all limits if `limitsToCompare` is not present.
*/
function getLimitsForMetadata(
metadata: {[key: string]: string},
limitsToCompare: false | AccountLimit = false
) {
const limits: Partial<AccountLimit> = {};
for (const [key, value] of Object.entries(metadata)) {
// if we need to compare limits, make sure we're not overwriting a higher limit from somewhere else
if (limitsToCompare) {
if (!(key in limitsToCompare)) {
continue;
}
if (
key in limitsToCompare &&
value !== Limits.unlimited &&
value <= limitsToCompare[key as keyof AccountLimit]
) {
continue;
}
}
// only use metadata needed for limit calculations
if (key in DEFAULT_LIMITS) {
limits[key as keyof AccountLimit] =
value === Limits.unlimited ? Limits.unlimited : parseInt(value);
}
}
return limits;
}

/**
* Get limits for the custom free tier (from `FREE_TIER_THRESHOLDS`), and merges them with the user's limits.
* The `/environment/` endpoint handles checking whether the logged-in user registered before `FREE_TIER_CUTOFF_DATE`.
*/
const getFreeTierLimits = async (limits: AccountLimit) => {
await when(() => envStore.isReady);
const thresholds = envStore.data.free_tier_thresholds;
const newLimits: AccountLimit = {...limits};
if (thresholds.storage) {
newLimits['storage_bytes_limit'] = thresholds.storage;
}
if (thresholds.data) {
newLimits['submission_limit'] = thresholds.data;
}
if (thresholds.translation_chars) {
newLimits['nlp_character_limit'] = thresholds.translation_chars;
}
if (thresholds.transcription_minutes) {
newLimits['nlp_seconds_limit'] = thresholds.transcription_minutes * 60;
}
return newLimits;
};

/**
* Get limits for any recurring add-ons the user has, merged with the rest of their limits.
*/
const getRecurringAddOnLimits = (limits: AccountLimit) => {
let newLimits = {...limits};
let activeAddOns = [...subscriptionStore.addOnsResponse];
let metadata = {};
// only check active add-ons
activeAddOns = activeAddOns.filter((subscription) =>
ACTIVE_STRIPE_STATUSES.includes(subscription.status)
);
if (activeAddOns.length) {
activeAddOns.forEach((addOn) => {
metadata = {
...addOn.items[0].price.product.metadata,
...addOn.items[0].price.metadata,
};
newLimits = {...newLimits, ...getLimitsForMetadata(metadata, newLimits)};
});
}
return newLimits;
};

/**
* Get all metadata keys for the logged-in user's plan, or from the free tier if they have no plan.
*/
const getStripeMetadataAndFreeTierStatus = async () => {
await when(() => subscriptionStore.isInitialised);
const subscriptions = [...subscriptionStore.subscriptionResponse];
const activeSubscriptions = subscriptions.filter((subscription) =>
const plans = [...subscriptionStore.planResponse];
// only use metadata for active subscriptions
const activeSubscriptions = plans.filter((subscription) =>
ACTIVE_STRIPE_STATUSES.includes(subscription.status)
);
let metadata;
let hasFreeTier = false;
if (activeSubscriptions.length) {
// get metadata from the user's subscription
metadata = activeSubscriptions[0].items[0].price.product.metadata;
// get metadata from the user's subscription (prioritize price metadata over product metadata)
metadata = {
...activeSubscriptions[0].items[0].price.product.metadata,
...activeSubscriptions[0].items[0].price.metadata,
};
} else {
// the user has no subscription, so get limits from the free monthly price
// the user has no subscription, so get limits from the free monthly product
hasFreeTier = true;
try {
const products = await getProducts();
Expand All @@ -141,40 +248,41 @@ export async function getAccountLimits() {
(price: BasePrice) =>
price.unit_amount === 0 && price.recurring?.interval === 'month'
)
);
)[0];
metadata = {
...freeProduct[0].metadata,
...freeProduct[0].prices[0].metadata,
...freeProduct.metadata,
...freeProduct.prices[0].metadata,
};
} catch (error) {
// couldn't find the free monthly product, continue in case we have limits to display from the free tier override
metadata = {};
}
}
return {metadata, hasFreeTier};
};

/**
* Get the complete account limits for the logged-in user.
* Checks (in descending order of priority):
* - the user's recurring add-ons
* - the `FREE_TIER_THRESHOLDS` override
* - the user's subscription limits
*/
export async function getAccountLimits() {
const {metadata, hasFreeTier} = await getStripeMetadataAndFreeTierStatus();

// initialize to unlimited
const limits: AccountLimit = {
submission_limit: 'unlimited',
nlp_seconds_limit: 'unlimited',
nlp_character_limit: 'unlimited',
storage_bytes_limit: 'unlimited',
};
let limits: AccountLimit = {...DEFAULT_LIMITS};

// get the limits from the metadata
for (const [key, value] of Object.entries(metadata)) {
if (Object.keys(limits).includes(key)) {
limits[key as keyof AccountLimit] =
value === 'unlimited' ? value : parseInt(value);
}
}
// apply any limits from the metadata
limits = {...limits, ...getLimitsForMetadata(metadata)};

// if the user is on the free tier, overwrite their limits with whatever free tier limits exist
if (hasFreeTier) {
await when(() => envStore.isReady);
const thresholds = envStore.data.free_tier_thresholds;
thresholds.storage && (limits['storage_bytes_limit'] = thresholds.storage);
thresholds.data && (limits['submission_limit'] = thresholds.data);
thresholds.translation_chars && (limits['nlp_character_limit'] = thresholds.translation_chars);
thresholds.transcription_minutes && (limits['nlp_seconds_limit'] = thresholds.transcription_minutes * 60);
// if the user is on the free tier, overwrite their limits with whatever free tier limits exist
limits = await getFreeTierLimits(limits);

// if the user has active recurring add-ons, use those as the final say on their limits
limits = getRecurringAddOnLimits(limits);
}

return limits;
Expand Down
6 changes: 3 additions & 3 deletions jsapp/js/account/stripe.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ export async function hasActiveSubscription() {
}

await when(() => subscriptionStore.isInitialised);
const subscriptions = subscriptionStore.subscriptionResponse;
if (!subscriptions.length) {
const plans = subscriptionStore.planResponse;
if (!plans.length) {
return false;
}

return (
subscriptions.filter(
plans.filter(
(sub) =>
ACTIVE_STRIPE_STATUSES.includes(sub.status) &&
sub.items?.[0].price.unit_amount > 0
Expand Down
16 changes: 12 additions & 4 deletions jsapp/js/account/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ export async function fetchProducts() {
}

class SubscriptionStore {
public subscriptionResponse: SubscriptionInfo[] = [];
public planResponse: SubscriptionInfo[] = [];
public addOnsResponse: SubscriptionInfo[] = [];
public subscribedProduct: BaseProduct | null = null;
public productsResponse: Product[] | null = null;
public isPending = false;
public isInitialised = false;

Expand Down Expand Up @@ -121,8 +121,16 @@ class SubscriptionStore {
private onFetchSubscriptionInfoDone(
response: PaginatedResponse<SubscriptionInfo>
) {
this.subscriptionResponse = response.results;
this.subscribedProduct = response.results[0]?.plan?.product || null;
// get any plan subscriptions for the user
this.planResponse = response.results.filter(
(sub) => sub.items[0]?.price.product.metadata?.product_type == 'plan'
);
// get any recurring add-on subscriptions for the user
this.addOnsResponse = response.results.filter(
(sub) => sub.items[0]?.price.product.metadata?.product_type == 'addon'
);
this.subscribedProduct =
this.planResponse[0]?.items[0]?.price.product || null;
this.isPending = false;
this.isInitialised = true;
}
Expand Down
2 changes: 1 addition & 1 deletion jsapp/js/components/usageLimits/useExceedingLimits.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const useExceedingLimits = () => {
() => subscriptionStore.isInitialised,
() => {
dispatch({
prodData: subscriptionStore.subscriptionResponse,
prodData: subscriptionStore.planResponse,
});
}
),
Expand Down
12 changes: 12 additions & 0 deletions kobo/apps/stripe/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,15 @@
'past_due',
'trialing',
]

FREE_TIER_NO_THRESHOLDS = {
'storage': None,
'data': None,
'transcription_minutes': None,
'translation_chars': None,
}

FREE_TIER_EMPTY_DISPLAY = {
'name': None,
'feature_list': [],
}
13 changes: 3 additions & 10 deletions kobo/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pymongo import MongoClient

from kpi.utils.json import LazyJSONSerializable
from kobo.apps.stripe.constants import FREE_TIER_NO_THRESHOLDS, FREE_TIER_EMPTY_DISPLAY
from ..static_lists import EXTRA_LANG_INFO, SECTOR_CHOICE_DEFAULTS

env = environ.Env()
Expand Down Expand Up @@ -339,12 +340,7 @@
'positive_int'
),
'FREE_TIER_THRESHOLDS': (
LazyJSONSerializable({
'storage': None,
'data': None,
'transcription_minutes': None,
'translation_chars': None,
}),
LazyJSONSerializable(FREE_TIER_NO_THRESHOLDS),
'Free tier thresholds: storage in kilobytes, '
'data (number of submissions), '
'minutes of transcription, '
Expand All @@ -353,10 +349,7 @@
'free_tier_threshold_jsonschema',
),
'FREE_TIER_DISPLAY': (
LazyJSONSerializable({
'name': None,
'feature_list': [],
}),
LazyJSONSerializable(FREE_TIER_EMPTY_DISPLAY),
'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',
Expand Down
9 changes: 2 additions & 7 deletions kpi/migrations/0051_set_free_tier_thresholds_to_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,13 @@
from constance import config
from django.db import migrations

from kobo.apps.stripe.constants import FREE_TIER_NO_THRESHOLDS
from kpi.utils.json import LazyJSONSerializable


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', LazyJSONSerializable(thresholds))
setattr(config, 'FREE_TIER_THRESHOLDS', LazyJSONSerializable(FREE_TIER_NO_THRESHOLDS))


class Migration(migrations.Migration):
Expand Down

0 comments on commit 5ed6a45

Please sign in to comment.