Skip to content

Commit

Permalink
Update Customer Detail + Event Stream View (#173)
Browse files Browse the repository at this point in the history
* new plan + cancel subscription

* edit plan scaffolding

* added new customer summary endpoints

* event preview redesign

* fixes for customer summary series

* changed customer with revenue view calculations

* add number active subscriptions to plans

* update customer with revenue view to pass floats

* customers table fix

* improve auth settings

* codestyle and test fixes

* update get access to support free qty checking

* changed to total

* lay framework for accepting payment providers apart from stripe

Co-authored-by: Diego Escobedo <[email protected]>
  • Loading branch information
mnida and diego-escobedo authored Oct 1, 2022
1 parent 41bbfe7 commit f5cb2c6
Show file tree
Hide file tree
Showing 42 changed files with 1,374 additions and 343 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ posthog = "*"
python-decouple = "*"
pytest-cov = "*"
celery = {extras = ["redis"], version = "*"}
social-auth-app-django = "*"

[dev-packages]
pylint = ">=2.14.5,<2.15"
Expand Down
179 changes: 117 additions & 62 deletions Pipfile.lock

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions lotus/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import re
import ssl
from pathlib import Path
from telnetlib import AUTHENTICATION

import dj_database_url
import django_heroku
Expand Down Expand Up @@ -91,6 +92,7 @@
"django.contrib.staticfiles",
"rest_framework",
"metering_billing",
"social_django",
"djmoney",
"django_extensions",
"django_celery_beat",
Expand Down Expand Up @@ -143,6 +145,12 @@
WSGI_APPLICATION = "lotus.wsgi.application"

AUTH_USER_MODEL = "metering_billing.User"
AUTHENTICATION_BACKENDS = ["metering_billing.auth_utils.EmailOrUsernameModelBackend"]
SOCIAL_AUTH_JSONFIELD_ENABLED = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_AGE = 2 * 60 * 60 # set just 10 seconds to test
SESSION_SAVE_EVERY_REQUEST = True

# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases

Expand Down Expand Up @@ -300,6 +308,9 @@
"DEFAULT_PERMISSION_CLASSES": [
"metering_billing.permissions.HasUserAPIKey",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"COERCE_DECIMAL_TO_STRING": False,
}
Expand Down
16 changes: 14 additions & 2 deletions lotus/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
from metering_billing.views.views import (
APIKeyCreate,
CancelSubscriptionView,
CustomerWithRevenueView,
CustomerDetailView,
CustomersSummaryView,
CustomersWithRevenueView,
DraftInvoiceView,
EventPreviewView,
GetCustomerAccessView,
Expand Down Expand Up @@ -63,9 +65,19 @@
path("api/track/", csrf_exempt(track.track_event), name="track_event"),
path(
"api/customer_summary/",
CustomerWithRevenueView.as_view(),
CustomersSummaryView.as_view(),
name="customer_summary",
),
path(
"api/customer_detail/",
CustomerDetailView.as_view(),
name="customer_detail",
),
path(
"api/customer_totals/",
CustomersWithRevenueView.as_view(),
name="customer_totals",
),
path(
"api/period_metric_usage/",
PeriodMetricUsageView.as_view(),
Expand Down
39 changes: 39 additions & 0 deletions metering_billing/auth_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from curses import keyname

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q

from metering_billing.exceptions import (
NoMatchingAPIKey,
OrganizationMismatch,
Expand All @@ -9,6 +13,41 @@
from metering_billing.permissions import HasUserAPIKey


class EmailOrUsernameModelBackend(ModelBackend):
"""
Authentication backend which allows users to authenticate using either their
username or email address
Source: https://stackoverflow.com/a/35836674/59984
"""

def authenticate(self, request, username=None, password=None, **kwargs):
# n.b. Django <2.1 does not pass the `request`

user_model = get_user_model()

if username is None:
username = kwargs.get(user_model.USERNAME_FIELD)

# The `username` field is allows to contain `@` characters so
# technically a given email address could be present in either field,
# possibly even for different users, so we'll query for all matching
# records and test each one.
users = user_model._default_manager.filter(
Q(**{user_model.USERNAME_FIELD: username}) | Q(email__iexact=username)
)

# Test whether any matched user has the provided password:
for user in users:
if user.check_password(password):
return user
if not users:
# Run the default password hasher once to reduce the timing
# difference between an existing and a non-existing user (see
# https://code.djangoproject.com/ticket/20760)
user_model().set_password(password)


# AUTH METHODS
def get_organization_from_key(key):
try:
Expand Down
50 changes: 25 additions & 25 deletions metering_billing/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Organization,
Subscription,
)
from metering_billing.serializers.model_serializers import InvoiceSerializer
from metering_billing.utils import (
calculate_sub_pc_usage_revenue,
make_all_decimals_floats,
Expand Down Expand Up @@ -59,7 +60,7 @@ class InvoiceSubscriptionSerializer(serializers.ModelSerializer):

class Meta:
model = Subscription
fields = ("start_date", "end_date", "billing_plan")
fields = ("start_date", "end_date", "billing_plan", "subscription_uid")


def generate_invoice(subscription, draft=False, issue_date=None):
Expand Down Expand Up @@ -97,13 +98,23 @@ def generate_invoice(subscription, draft=False, issue_date=None):
amount_cents = int(
amount.quantize(Decimal(".01"), rounding=ROUND_DOWN) * Decimal(100)
)

customer_connected_to_pp = customer.payment_provider_id != ""
org_pp_id = None
if customer_connected_to_pp:
cust_pp_type = customer.payment_provider
org_pps = organization.payment_provider_ids
if cust_pp_type in org_pps:
org_pp_id = org_pps[cust_pp_type]

status = "unpaid"
if draft:
status = "draft"
payment_intent_id = None
elif organization.stripe_id is not None or (
external_payment_obj_id = None
elif (customer_connected_to_pp and org_pp_id) or (
SELF_HOSTED and STRIPE_SECRET_KEY != ""
):
if customer.payment_provider_id is not None:
if customer.payment_provider == "stripe":
payment_intent_kwargs = {
"amount": amount_cents,
"currency": billing_plan.currency,
Expand All @@ -112,16 +123,14 @@ def generate_invoice(subscription, draft=False, issue_date=None):
"description": f"Invoice for {organization.company_name}",
}
if not SELF_HOSTED:
payment_intent_kwargs["stripe_account"] = organization.stripe_id
payment_intent_kwargs["stripe_account"] = org_pp_id
payment_intent = stripe.PaymentIntent.create(**payment_intent_kwargs)
status = payment_intent.status
payment_intent_id = payment_intent.id
external_payment_obj_id = payment_intent.id
# can be extensible by adding an elif depending on payment provider workflow
else:
status = "customer_not_connected_to_stripe"
payment_intent_id = None
external_payment_obj_id = None
else:
status = "organization_not_connected_to_stripe"
payment_intent_id = None
external_payment_obj_id = None

# Create the invoice
org_serializer = InvoiceOrganizationSerializer(organization)
Expand All @@ -132,27 +141,18 @@ def generate_invoice(subscription, draft=False, issue_date=None):
invoice = Invoice.objects.create(
cost_due=amount_cents / 100,
issue_date=issue_date,
org_connected_to_cust_payment_provider=org_pp_id != None,
cust_connected_to_payment_provider=customer_connected_to_pp,
organization=org_serializer.data,
customer=customer_serializer.data,
subscription=subscription_serializer.data,
status=status,
payment_intent_id=payment_intent_id,
payment_status=status,
external_payment_obj_id=external_payment_obj_id,
line_items=usage_dict,
)

if not draft:
invoice_data = {
invoice: {
"cost_due": amount_cents / 100,
"issue_date": issue_date,
"organization": org_serializer.data,
"customer": customer_serializer.data,
"subscription": subscription_serializer.data,
"status": status,
"payment_intent_id": payment_intent_id,
"line_items": usage_dict,
}
}
invoice_data = InvoiceSerializer(invoice).data
invoice_created_webhook(invoice_data, organization)

return invoice
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.0.5 on 2022-09-30 19:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("metering_billing", "0024_alter_billingplan_components_and_more"),
]

operations = [
migrations.AddField(
model_name="customer",
name="billing_address",
field=models.CharField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="customer",
name="email",
field=models.EmailField(blank=True, max_length=100, null=True),
),
]
18 changes: 18 additions & 0 deletions metering_billing/migrations/0026_alter_user_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.0.5 on 2022-10-01 01:51

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("metering_billing", "0025_customer_billing_address_customer_email"),
]

operations = [
migrations.AlterField(
model_name="user",
name="email",
field=models.EmailField(max_length=254, unique=True),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Generated by Django 4.0.5 on 2022-10-01 16:29

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("metering_billing", "0026_alter_user_email"),
]

operations = [
migrations.RenameField(
model_name="invoice",
old_name="payment_intent_id",
new_name="external_payment_obj_id",
),
migrations.RemoveField(
model_name="invoice",
name="status",
),
migrations.AddField(
model_name="customer",
name="payment_provider",
field=models.CharField(
blank=True, choices=[("stripe", "Stripe")], max_length=100, null=True
),
),
migrations.AddField(
model_name="invoice",
name="cust_connected_to_payment_provider",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="invoice",
name="org_connected_to_cust_payment_provider",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="invoice",
name="payment_status",
field=models.CharField(
choices=[("draft", "Draft"), ("paid", "Paid"), ("unpaid", "Unpaid")],
default="unpaid",
max_length=40,
),
preserve_default=False,
),
migrations.AddField(
model_name="organization",
name="payment_provider_ids",
field=models.JSONField(blank=True, default=dict, null=True),
),
]
Loading

0 comments on commit f5cb2c6

Please sign in to comment.