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

Pricing improvements and bug fixes #379

Merged
merged 31 commits into from
Jul 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
fefbbf4
Don't make use of transaction.atomic in handle_subscription_updated
nikochiko Jun 6, 2024
e35d78f
paypal.Subscription.cancel: ignore when sub is already cancelled / ex…
nikochiko Jun 6, 2024
46e34b1
Fix paypal next invoice time when it is None
nikochiko Jun 6, 2024
965b040
Payment processing page: run paypal subscription update in thread
nikochiko Jun 7, 2024
c42d12d
Merge branch 'pricing-v2' of github.com:GooeyAI/gooey-server into pri…
nikochiko Jun 7, 2024
375e11e
Merge branch 'master' into pricing-v2
nikochiko Jun 7, 2024
8fea44b
Merge branch 'master' into pricing-v2-improvements
nikochiko Jun 9, 2024
43c250f
sentry_sdk: upgrade to 1.45, add loguru extra to capture logged info
nikochiko Jun 9, 2024
61e5da4
Refactor auto-recharge functionality with exceptions
nikochiko Jun 10, 2024
558505c
Refactor payments webhook handling code into payments/webhooks.py
nikochiko Jun 10, 2024
cdcc8ac
Add BasePage.run_with_auto_recharge
nikochiko Jun 10, 2024
c42ffae
recipe runner task: s/page.run/page.run_with_auto_recharge
nikochiko Jun 10, 2024
d231447
api: don't auto-recharge before the run (we do that afterwards)
nikochiko Jun 10, 2024
5f48995
Remove subscription-change logic from routers/account.py
nikochiko Jun 10, 2024
7f227db
billing_page: fix same-key bug in buttons, refactor for clarity
nikochiko Jun 10, 2024
b12dc0c
Fix Subscription model: allow null-values for auto-recharge config in DB
nikochiko Jun 10, 2024
83cb1cf
Add missing migration for blank=True on AppUser.subscription
nikochiko Jun 10, 2024
45a3019
stripe auto invoice: use default setting for AUTO_RECHARGE_COOLDOWN w…
nikochiko Jun 10, 2024
156a363
Remove unnecessary cosmetic changes
nikochiko Jun 10, 2024
bcbb02f
Fix bug: proration behavior
nikochiko Jun 11, 2024
c971f7a
Merge branch 'master' into pricing-v2-improvements
nikochiko Jun 14, 2024
fa6d9e5
Merge remote-tracking branch 'origin/master' into pricing-v2-improvem…
devxpy Jul 11, 2024
b5bc34b
rename gui_runner -> runner_task
devxpy Jul 11, 2024
4dbd145
fix tests
devxpy Jul 12, 2024
9b012e1
record extra data about transactions
devxpy Jul 14, 2024
9ede7d8
fix: record the correct txn id for paypal
devxpy Jul 14, 2024
1edae2a
re-enable one time payment buttons
devxpy Jul 14, 2024
2de5fc5
fix: dont charge the user when downgrading the subscription
devxpy Jul 14, 2024
6bb0ac2
refactor
devxpy Jul 14, 2024
b9df617
allow saved payment options in stripe addon checkout
devxpy Jul 14, 2024
e6b9955
remove network calls inside transaction.atomic()
devxpy Jul 14, 2024
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
19 changes: 16 additions & 3 deletions app_users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,26 +192,39 @@ class AppUserTransactionAdmin(admin.ModelAdmin):
"invoice_id",
"user",
"amount",
"dollar_amount",
"end_balance",
"payment_provider",
"dollar_amount",
"reason",
"plan",
"created_at",
]
readonly_fields = ["created_at"]
readonly_fields = ["view_payment_provider_url", "created_at"]
list_filter = [
"created_at",
"reason",
("payment_provider", admin.EmptyFieldListFilter),
"payment_provider",
"plan",
"created_at",
]
inlines = [SavedRunInline]
ordering = ["-created_at"]
search_fields = ["invoice_id"]

@admin.display(description="Charged Amount")
def dollar_amount(self, obj: models.AppUserTransaction):
if not obj.payment_provider:
return
return f"${obj.charged_amount / 100}"

@admin.display(description="Payment Provider URL")
def view_payment_provider_url(self, txn: models.AppUserTransaction):
url = txn.payment_provider_url()
if url:
return open_in_new_tab(url, label=url)
else:
raise txn.DoesNotExist


@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 4.2.7 on 2024-07-14 20:51

from django.db import migrations, models


def forwards_func(apps, schema_editor):
from payments.plans import PricingPlan
from app_users.models import TransactionReason

# We get the model from the versioned app registry;
# if we directly import it, it'll be the wrong version
AppUserTransaction = apps.get_model("app_users", "AppUserTransaction")
db_alias = schema_editor.connection.alias
objects = AppUserTransaction.objects.using(db_alias)

for transaction in objects.all():
if transaction.amount <= 0:
transaction.reason = TransactionReason.DEDUCT
else:
# For old transactions, we didn't have a subscription field.
# It just so happened that all monthly subscriptions we offered had
# different amounts from the one-time purchases.
# This uses that heuristic to determine whether a transaction
# was a subscription payment or a one-time purchase.
transaction.reason = TransactionReason.ADDON
for plan in PricingPlan:
if (
transaction.amount == plan.credits
and transaction.charged_amount == plan.monthly_charge * 100
):
transaction.plan = plan.db_value
transaction.reason = TransactionReason.SUBSCRIBE
transaction.save(update_fields=["reason", "plan"])


class Migration(migrations.Migration):

dependencies = [
('app_users', '0017_alter_appuser_subscription'),
]

operations = [
migrations.AddField(
model_name='appusertransaction',
name='plan',
field=models.IntegerField(blank=True, choices=[(1, 'Basic Plan'), (2, 'Premium Plan'), (3, 'Starter'), (4, 'Creator'), (5, 'Business'), (6, 'Enterprise / Agency')], default=None, help_text="User's plan at the time of this transaction.", null=True),
),
migrations.AddField(
model_name='appusertransaction',
name='reason',
field=models.IntegerField(choices=[(1, 'Deduct'), (2, 'Addon'), (3, 'Subscribe'), (4, 'Sub-Create'), (5, 'Sub-Cycle'), (6, 'Sub-Update'), (7, 'Auto-Recharge')], default=0, help_text='The reason for this transaction.<br><br>Deduct: Credits deducted due to a run.<br>Addon: User purchased an add-on.<br>Subscribe: Applies to subscriptions where no distinction was made between create, update and cycle.<br>Sub-Create: A subscription was created.<br>Sub-Cycle: A subscription advanced into a new period.<br>Sub-Update: A subscription was updated.<br>Auto-Recharge: Credits auto-recharged due to low balance.'),
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
migrations.AlterField(
model_name='appusertransaction',
name='reason',
field=models.IntegerField(choices=[(1, 'Deduct'), (2, 'Addon'), (3, 'Subscribe'), (4, 'Sub-Create'), (5, 'Sub-Cycle'), (6, 'Sub-Update'), (7, 'Auto-Recharge')], help_text='The reason for this transaction.<br><br>Deduct: Credits deducted due to a run.<br>Addon: User purchased an add-on.<br>Subscribe: Applies to subscriptions where no distinction was made between create, update and cycle.<br>Sub-Create: A subscription was created.<br>Sub-Cycle: A subscription advanced into a new period.<br>Sub-Update: A subscription was updated.<br>Auto-Recharge: Credits auto-recharged due to low balance.'),
),
]
98 changes: 70 additions & 28 deletions app_users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.db.models import Sum
from django.utils import timezone
from firebase_admin import auth
from furl import furl
from phonenumber_field.modelfields import PhoneNumberField

from bots.custom_fields import CustomURLField, StrippedTextField
Expand Down Expand Up @@ -172,6 +173,7 @@ def add_balance(
user: AppUser = AppUser.objects.select_for_update().get(pk=self.pk)
user.balance += amount
user.save(update_fields=["balance"])
kwargs.setdefault("plan", user.subscription and user.subscription.plan)
return AppUserTransaction.objects.create(
user=self,
invoice_id=invoice_id,
Expand Down Expand Up @@ -273,6 +275,18 @@ def get_dollars_spent_this_month(self) -> float:
return (cents_spent or 0) / 100


class TransactionReason(models.IntegerChoices):
DEDUCT = 1, "Deduct"
ADDON = 2, "Addon"

SUBSCRIBE = 3, "Subscribe"
SUBSCRIPTION_CREATE = 4, "Sub-Create"
SUBSCRIPTION_CYCLE = 5, "Sub-Cycle"
SUBSCRIPTION_UPDATE = 6, "Sub-Update"

AUTO_RECHARGE = 7, "Auto-Recharge"


class AppUserTransaction(models.Model):
user = models.ForeignKey(
"AppUser", on_delete=models.CASCADE, related_name="transactions"
Expand Down Expand Up @@ -307,6 +321,25 @@ class AppUserTransaction(models.Model):
default=0,
)

reason = models.IntegerField(
choices=TransactionReason.choices,
help_text="The reason for this transaction.<br><br>"
f"{TransactionReason.DEDUCT.label}: Credits deducted due to a run.<br>"
f"{TransactionReason.ADDON.label}: User purchased an add-on.<br>"
f"{TransactionReason.SUBSCRIBE.label}: Applies to subscriptions where no distinction was made between create, update and cycle.<br>"
f"{TransactionReason.SUBSCRIPTION_CREATE.label}: A subscription was created.<br>"
f"{TransactionReason.SUBSCRIPTION_CYCLE.label}: A subscription advanced into a new period.<br>"
f"{TransactionReason.SUBSCRIPTION_UPDATE.label}: A subscription was updated.<br>"
f"{TransactionReason.AUTO_RECHARGE.label}: Credits auto-recharged due to low balance.",
)
plan = models.IntegerField(
choices=PricingPlan.db_choices(),
help_text="User's plan at the time of this transaction.",
null=True,
blank=True,
default=None,
)

created_at = models.DateTimeField(editable=False, blank=True, default=timezone.now)

class Meta:
Expand All @@ -320,32 +353,41 @@ class Meta:
def __str__(self):
return f"{self.invoice_id} ({self.amount})"

def get_subscription_plan(self) -> PricingPlan | None:
"""
It just so happened that all monthly subscriptions we offered had
different amounts from the one-time purchases.
This uses that heuristic to determine whether a transaction
was a subscription payment or a one-time purchase.

TODO: Implement this more robustly
"""
if self.amount <= 0:
# credits deducted
return None

for plan in PricingPlan:
if (
self.amount == plan.credits
and self.charged_amount == plan.monthly_charge * 100
def save(self, *args, **kwargs):
if self.reason is None:
if self.amount <= 0:
self.reason = TransactionReason.DEDUCT
else:
self.reason = TransactionReason.ADDON
super().save(*args, **kwargs)

def reason_note(self) -> str:
match self.reason:
case (
TransactionReason.SUBSCRIPTION_CREATE
| TransactionReason.SUBSCRIPTION_CYCLE
| TransactionReason.SUBSCRIPTION_UPDATE
| TransactionReason.SUBSCRIBE
):
return plan

return None

def note(self) -> str:
if self.amount <= 0:
return ""
elif plan := self.get_subscription_plan():
return f"Subscription payment: {plan.title} (+{self.amount:,} credits)"
else:
return f"Addon purchase (+{self.amount:,} credits)"
ret = "Subscription payment"
if self.plan:
ret += f": {PricingPlan.from_db_value(self.plan).title}"
return ret
case TransactionReason.AUTO_RECHARGE:
return "Auto recharge"
case TransactionReason.ADDON:
return "Addon purchase"
case TransactionReason.DEDUCT:
return "Run deduction"

def payment_provider_url(self) -> str | None:
match self.payment_provider:
case PaymentProvider.STRIPE:
return str(
furl("https://dashboard.stripe.com/invoices/") / self.invoice_id
)
case PaymentProvider.PAYPAL:
return str(
furl("https://www.paypal.com/unifiedtransactions/details/payment/")
/ self.invoice_id
)
2 changes: 1 addition & 1 deletion bots/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
Workflow,
)
from bots.tasks import create_personal_channels_for_all_members
from celeryapp.tasks import gui_runner
from celeryapp.tasks import runner_task
from daras_ai_v2.fastapi_tricks import get_route_url
from gooeysite.custom_actions import export_to_excel, export_to_csv
from gooeysite.custom_filters import (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.7 on 2024-07-12 19:30

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('bots', '0076_alter_workflowmetadata_default_image_and_more'),
]

operations = [
migrations.AddField(
model_name='savedrun',
name='error_code',
field=models.IntegerField(blank=True, default=None, help_text='The HTTP status code of the error. If this is not set, 500 is assumed.', null=True),
),
migrations.AddField(
model_name='savedrun',
name='error_type',
field=models.TextField(blank=True, default='', help_text='The exception type'),
),
migrations.AlterField(
model_name='savedrun',
name='error_msg',
field=models.TextField(blank=True, default='', help_text='The error message. If this is not set, the run is deemed successful.'),
),
]
27 changes: 22 additions & 5 deletions bots/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,24 @@ class SavedRun(models.Model):

state = models.JSONField(default=dict, blank=True, encoder=PostgresJSONEncoder)

error_msg = models.TextField(default="", blank=True)
error_msg = models.TextField(
default="",
blank=True,
help_text="The error message. If this is not set, the run is deemed successful.",
)
run_time = models.DurationField(default=datetime.timedelta, blank=True)
run_status = models.TextField(default="", blank=True)

error_code = models.IntegerField(
null=True,
default=None,
blank=True,
help_text="The HTTP status code of the error. If this is not set, 500 is assumed.",
)
error_type = models.TextField(
default="", blank=True, help_text="The exception type"
)

hidden = models.BooleanField(default=False)
is_flagged = models.BooleanField(default=False)

Expand Down Expand Up @@ -282,9 +296,12 @@ def __str__(self):
def parent_published_run(self) -> typing.Optional["PublishedRun"]:
return self.parent_version and self.parent_version.published_run

def get_app_url(self):
def get_app_url(self, query_params: dict = None):
return Workflow(self.workflow).page_cls.app_url(
example_id=self.example_id, run_id=self.run_id, uid=self.uid
example_id=self.example_id,
run_id=self.run_id,
uid=self.uid,
query_params=query_params,
)

def to_dict(self) -> dict:
Expand Down Expand Up @@ -1624,9 +1641,9 @@ def duplicate(
visibility=visibility,
)

def get_app_url(self):
def get_app_url(self, query_params: dict = None):
return Workflow(self.workflow).page_cls.app_url(
example_id=self.published_run_id
example_id=self.published_run_id, query_params=query_params
)

def add_version(
Expand Down
Loading