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

Show storage add-ons in usage limits #4660

Merged
merged 17 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
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> = {};
LMNTL marked this conversation as resolved.
Show resolved Hide resolved
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 = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Possibly out of scope. If there was ever a good case for type hints, this is it. Lacking type hints, a DRF serializer on the Environments endpoint would help ensure correctness. But the environments endpoint doesn't have one. IMO this should be considered in a refactor. You could ponder if the added complexity here merits adding a serializer. While not as powerful as real type hints, the serializer would provide a runtime guarantee that data is returned in the expected format. Currently it's a untyped dict that is returned, which cannot provide proof that the API consistently returns the expected format the Frontend needs to accept.

'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