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

fix(billing): remove quantity from backend for nlp add-ons TASK-1482 #5490

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
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
16 changes: 6 additions & 10 deletions jsapp/js/account/addOns/addOnList.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,10 @@ const AddOnList = (props: {
<tr className={styles.row} key={oneTimeAddOn.id}>
<td className={styles.product}>
<span className={styles.productName}>
{t('##name## x ##quantity##')
.replace(
'##name##',
oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.name || label,
)
.replace('##quantity##', oneTimeAddOn.quantity.toString())}
{t('##name##').replace(
'##name##',
oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.name || label,
)}
</span>
<Badge color={color} size={'s'} label={badgeLabel} />
<p className={styles.addonDescription}>
Expand All @@ -119,10 +117,8 @@ const AddOnList = (props: {
{'$##price##'.replace(
'##price##',
(
(oneTimeAddOn.quantity *
(oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.prices[0]
.unit_amount || 0)) /
100
(oneTimeAddOnProducts.find((product) => product.id === oneTimeAddOn.product)?.prices[0]
.unit_amount || 0) / 100
).toFixed(2),
)}
</td>
Expand Down
4 changes: 4 additions & 0 deletions jsapp/js/account/addOns/addOnList.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@

.oneTime {
justify-content: center;

:last-child {
margin-left: 8px;
}
}

.productName {
Expand Down
30 changes: 4 additions & 26 deletions jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,6 @@ interface OneTimeAddOnRowProps {
organization: Organization
}

const MAX_ONE_TIME_ADDON_PURCHASE_QUANTITY = 10

const quantityOptions = Array.from({ length: MAX_ONE_TIME_ADDON_PURCHASE_QUANTITY }, (_, zeroBasedIndex) => {
const index = (zeroBasedIndex + 1).toString()
return { value: index, label: index }
})

export const OneTimeAddOnRow = ({
products,
isBusy,
Expand All @@ -33,9 +26,8 @@ export const OneTimeAddOnRow = ({
organization,
}: OneTimeAddOnRowProps) => {
const [selectedProduct, setSelectedProduct] = useState(products[0])
const [quantity, setQuantity] = useState('1')
const [selectedPrice, setSelectedPrice] = useState<Product['prices'][0]>(selectedProduct.prices[0])
const displayPrice = useDisplayPrice(selectedPrice, parseInt(quantity))
const displayPrice = useDisplayPrice(selectedPrice)
const priceOptions = useMemo(
() =>
selectedProduct.prices.map((price) => {
Expand Down Expand Up @@ -79,12 +71,6 @@ export const OneTimeAddOnRow = ({
}
}

const onChangeQuantity = (quantity: string | null) => {
if (quantity) {
setQuantity(quantity)
}
}

// TODO: Merge functionality of onClickBuy and onClickManage so we can unduplicate
// the billing button in priceTableCells
const onClickBuy = () => {
Expand All @@ -93,7 +79,7 @@ export const OneTimeAddOnRow = ({
}
setIsBusy(true)
if (selectedPrice) {
postCheckout(selectedPrice.id, organization.id, parseInt(quantity))
postCheckout(selectedPrice.id, organization.id)
.then((response) => window.location.assign(response.url))
.catch(() => setIsBusy(false))
}
Expand Down Expand Up @@ -147,30 +133,22 @@ export const OneTimeAddOnRow = ({
<td className={styles.price}>
<div className={styles.oneTime}>
<KoboSelect3
size='m'
size={'fit'}
name='products'
options={products.map((product) => {
return { value: product.id, label: product.name }
})}
onChange={(productId) => onChangeProduct(productId as string)}
value={selectedProduct.id}
/>
{displayName === 'File Storage' ? (
{displayName === 'File Storage' && (
<KoboSelect3
size={'fit'}
name={t('prices')}
options={priceOptions}
onChange={onChangePrice}
value={selectedPrice.id}
/>
) : (
<KoboSelect3
size={'fit'}
name={t('quantity')}
options={quantityOptions}
onChange={onChangeQuantity}
value={quantity}
/>
)}
</div>
</td>
Expand Down
1 change: 0 additions & 1 deletion jsapp/js/account/security/securityRoute.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ h2.securityHeaderText {

.securityHeaderActions {
@include mixins.centerRowFlex;
padding-right: 15px;
}

// Shared styles for sections
Expand Down
1 change: 0 additions & 1 deletion jsapp/js/account/stripe.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ export interface OneTimeAddOn {
limits_remaining: Partial<OneTimeUsageLimits>
organization: string
product: string
quantity: number
}

export interface OneTimeUsageLimits {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ function OneTimeAddOnList(props: OneTimeAddOnList) {
return {
productName,
remainingLimit,
quantity: addon.quantity,
}
}),
[props.oneTimeAddOns, props.type, productsContext.isLoaded],
Expand All @@ -49,7 +48,6 @@ function OneTimeAddOnList(props: OneTimeAddOnList) {
<div className={styles.oneTimeAddOnListEntry} key={i}>
<label className={styles.productName}>
<span>{addon.productName}</span>
{addon.quantity > 1 && <span>&nbsp;x {addon.quantity}</span>}
</label>
<div>
{t('##REMAINING## remaining').replace('##REMAINING##', limitDisplay(props.type, addon.remainingLimit))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ $input-color: colors.$kobo-gray-800;
width: 100%;
position: relative;
font-size: 12px;
padding-left: 5px;
padding-right: 5px;
}

.m {
Expand Down
1 change: 0 additions & 1 deletion kobo/apps/stripe/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ class PlanAddOnAdmin(ModelAdmin):
list_display = (
'organization',
'product',
'quantity',
'is_available',
'created',
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 4.2.15 on 2025-02-04 21:15

from django.db import migrations, models
import kobo.apps.stripe.utils


class Migration(migrations.Migration):

dependencies = [
('stripe', '0001_initial'),
]

operations = [
migrations.RemoveField(
model_name='planaddon',
name='quantity',
),
migrations.AlterField(
model_name='planaddon',
name='limits_remaining',
field=models.JSONField(
default=kobo.apps.stripe.utils.get_default_add_on_limits,
help_text=(
"The amount of each of the add-on's individual limits "
'left to use.'
),
),
),
migrations.AlterField(
model_name='planaddon',
name='usage_limits',
field=models.JSONField(
default=kobo.apps.stripe.utils.get_default_add_on_limits,
help_text=(
'The historical usage limits when the add-on was purchased.\n'
'Possible keys:\n'
'\"submission_limit\", \"asr_seconds_limit\", '
'and/or \"mt_characters_limit\"'
),

),
),
]
16 changes: 5 additions & 11 deletions kobo/apps/stripe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

from django.contrib import admin
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import F, IntegerField, Sum
from django.db.models import IntegerField, Sum
from django.db.models.functions import Cast, Coalesce
from django.db.models.signals import post_save
from django.dispatch import receiver
Expand All @@ -29,11 +28,9 @@ class PlanAddOn(models.Model):
null=True,
blank=True,
)
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)])
usage_limits = models.JSONField(
default=get_default_add_on_limits,
help_text='''The historical usage limits when the add-on was purchased.
Multiply this value by `quantity` to get the total limits for this add-on.
Possible keys:
"submission_limit", "asr_seconds_limit", and/or "mt_characters_limit"''',
)
Expand Down Expand Up @@ -70,7 +67,6 @@ def create_or_update_one_time_add_on(charge: Charge):
if (
charge.payment_intent.status != PaymentIntentStatus.succeeded
or not charge.metadata.get('price_id', None)
or not charge.metadata.get('quantity', None)
):
# make sure the charge is for a successful addon purchase
return False
Expand Down Expand Up @@ -101,14 +97,13 @@ def create_or_update_one_time_add_on(charge: Charge):
# this user doesn't have the subscription level they need for this addon
return False

quantity = int(charge.metadata['quantity'])
usage_limits = {}
limits_remaining = {}
for limit_type in get_default_add_on_limits().keys():
limit_value = charge.metadata.get(limit_type, None)
if limit_value is not None:
usage_limits[limit_type] = int(limit_value)
limits_remaining[limit_type] = int(limit_value) * quantity
limits_remaining[limit_type] = int(limit_value)

if not len(usage_limits):
# not a valid plan add-on
Expand All @@ -119,7 +114,6 @@ def create_or_update_one_time_add_on(charge: Charge):
)
if add_on_created:
add_on.product = product
add_on.quantity = int(charge.metadata['quantity'])
add_on.organization = organization
add_on.usage_limits = usage_limits
add_on.limits_remaining = limits_remaining
Expand All @@ -145,7 +139,7 @@ def get_organization_totals(
charge__refunded=False,
).aggregate(
total_usage_limit=Coalesce(
Sum(Cast(usage_field, output_field=IntegerField()) * F('quantity')),
Sum(Cast(usage_field, output_field=IntegerField())),
0,
output_field=IntegerField(),
),
Expand Down Expand Up @@ -234,9 +228,9 @@ def deduct_add_ons_for_organization(
def total_usage_limits(self):
"""
The total usage limits for this add-on, based on the usage_limits for a single
add-on and the quantity.
add-on.
"""
return {key: value * self.quantity for key, value in self.usage_limits.items()}
return {key: value for key, value in self.usage_limits.items()}

@property
def valid_tags(self) -> List:
Expand Down
1 change: 0 additions & 1 deletion kobo/apps/stripe/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ class Meta:
'id',
'created',
'is_available',
'quantity',
'usage_limits',
'total_usage_limits',
'limits_remaining',
Expand Down
2 changes: 1 addition & 1 deletion kobo/apps/stripe/tests/test_customer_portal_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def test_gets_portal_configuration_for_price(self, create_config, list_config, s
'livemode': False,
'features': {
'subscription_update': {
'default_allowed_updates': ['quantity'],
'default_allowed_updates': [],
'products': [],
'prices': [],
},
Expand Down
35 changes: 7 additions & 28 deletions kobo/apps/stripe/tests/test_one_time_addons_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def _create_product(self, metadata=None):
)
self.product.save()

def _create_payment(self, payment_status='succeeded', refunded=False, quantity=1):
payment_total = quantity * 2000
def _create_payment(self, payment_status='succeeded', refunded=False):
payment_total = 2000
self.payment_intent = baker.make(
PaymentIntent,
customer=self.customer,
Expand All @@ -81,7 +81,6 @@ def _create_payment(self, payment_status='succeeded', refunded=False, quantity=1
self.charge.metadata = {
'price_id': self.price.id,
'organization_id': self.organization.id,
'quantity': quantity,
**(self.product.metadata or {}),
}
self.charge.save()
Expand Down Expand Up @@ -144,25 +143,6 @@ def test_no_addon_for_cancelled_charge(self):
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 0

def test_total_limits_reflect_addon_quantity(self):
limit = 2000
quantity = 9
self._create_product(
metadata={
'product_type': 'addon_onetime',
'asr_seconds_limit': limit,
'valid_tags': 'all',
}
)
self._create_payment(quantity=quantity)
response = self.client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
asr_seconds = response.data['results'][0]['total_usage_limits'][
'asr_seconds_limit'
]
assert asr_seconds == limit * quantity

def test_anonymous_user(self):
self._create_product()
self._create_payment()
Expand All @@ -181,7 +161,6 @@ def test_not_own_addon(self):
@data('characters', 'seconds')
def test_get_user_totals(self, usage_type):
limit = 2000
quantity = 5
usage_limit_key = f'{USAGE_LIMIT_MAP[usage_type]}_limit'
self._create_product(
metadata={
Expand All @@ -192,19 +171,19 @@ def test_get_user_totals(self, usage_type):
)
self._create_payment()
self._create_payment()
self._create_payment(quantity=quantity)
self._create_payment()

total_limit, remaining = PlanAddOn.get_organization_totals(
self.organization, usage_type
)
assert total_limit == limit * (quantity + 2)
assert remaining == limit * (quantity + 2)
assert total_limit == limit * 3
assert remaining == limit * 3

PlanAddOn.deduct_add_ons_for_organization(
self.organization, usage_type, limit * quantity
self.organization, usage_type, limit
)
total_limit, remaining = PlanAddOn.get_organization_totals(
self.organization, usage_type
)
assert total_limit == limit * (quantity + 2)
assert total_limit == limit * 3
assert remaining == limit * 2
Loading