Skip to content

Commit

Permalink
feat: billing dashboard (#399)
Browse files Browse the repository at this point in the history
* feat: add query and mutation resolvers for various billing tasks

* feat: add client gqls

* chore: regenrate graphql schema and types

* feat: add neutral spinner type for secondary and outline buttons

* feat: add subscription and payment management to org settings page

* fix: close form modal after adding payment method

* feat: misc updates to subscription and payment method display

* fix: staging docker compose - move OAUTH_REDIRECT_URI to backend

* feat: update card display

* fix: refetch orgs when cancelling subscription

* feat: cancel subscription at end of current billing period

* feat: allow resuming cancelled subscription, misc UI improvements

* fix: enforce client side permission checks for billing actions

* fix: misc fixes to post-checkout ux

* fix: copy

* fix: correctly update subscription id to free tier on cancellation

* feat: misc fixes and updates

* fix: misc fixes

* Fix code scanning alert no. 11: Information exposure through an exception

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: misc updates to stripe intents

* fix: update permissions and conditions for card management

* refaactor: use stripe payment element

* fix: dont render manage payment dialog when not required

* fix: clear search params after checkout success

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Nimish <[email protected]>
  • Loading branch information
3 people authored Dec 7, 2024
1 parent a893197 commit 563b383
Show file tree
Hide file tree
Showing 25 changed files with 1,301 additions and 28 deletions.
21 changes: 20 additions & 1 deletion backend/backend/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,18 @@
)
from ee.billing.graphene.queries.stripe import (
StripeCheckoutDetails,
StripeSubscriptionDetails,
resolve_stripe_checkout_details,
resolve_stripe_subscription_details,
)
from ee.billing.graphene.mutations.stripe import (
CancelSubscriptionMutation,
CreateProUpgradeCheckoutSession,
CreateSetupIntentMutation,
DeletePaymentMethodMutation,
ResumeSubscriptionMutation,
SetDefaultPaymentMethodMutation,
)
from ee.billing.graphene.mutations.stripe import CreateProUpgradeCheckoutSession
from .graphene.mutations.lockbox import CreateLockboxMutation
from .graphene.queries.syncing import (
resolve_aws_secret_manager_secrets,
Expand Down Expand Up @@ -321,6 +330,10 @@ class Query(graphene.ObjectType):
StripeCheckoutDetails, stripe_session_id=graphene.String(required=True)
)

stripe_subscription_details = graphene.Field(
StripeSubscriptionDetails, organisation_id=graphene.ID()
)

# --------------------------------------------------------------------

resolve_server_public_key = resolve_server_public_key
Expand Down Expand Up @@ -774,6 +787,7 @@ def resolve_app_activity_chart(root, info, app_id, period=TimeRange.DAY):
return time_series_logs

resolve_stripe_checkout_details = resolve_stripe_checkout_details
resolve_stripe_subscription_details = resolve_stripe_subscription_details


class Mutation(graphene.ObjectType):
Expand Down Expand Up @@ -878,6 +892,11 @@ class Mutation(graphene.ObjectType):

# Billing
create_pro_upgrade_checkout_session = CreateProUpgradeCheckoutSession.Field()
delete_payment_method = DeletePaymentMethodMutation.Field()
cancel_subscription = CancelSubscriptionMutation.Field()
resume_subscription = ResumeSubscriptionMutation.Field()
create_setup_intent = CreateSetupIntentMutation.Field()
set_default_payment_method = SetDefaultPaymentMethodMutation.Field()


schema = graphene.Schema(query=Query, mutation=Mutation)
212 changes: 210 additions & 2 deletions backend/ee/billing/graphene/mutations/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
from api.utils.access.permissions import user_has_permission
import stripe
from django.conf import settings
from graphene import Mutation, ID, String
from graphene import Mutation, ID, String, Boolean, ObjectType, Mutation
from graphql import GraphQLError


class UpdateSubscriptionResponse(ObjectType):
success = Boolean()
message = String()
canceled_at = String()
status = String()


class CreateProUpgradeCheckoutSession(Mutation):
class Arguments:
organisation_id = ID(required=True)
Expand All @@ -21,7 +28,9 @@ def mutate(self, info, organisation_id, billing_period):

organisation = Organisation.objects.get(id=organisation_id)

if not user_has_permission(info.context.user, "update", "Billing", organisation):
if not user_has_permission(
info.context.user, "update", "Billing", organisation
):
raise GraphQLError("You don't have permission to update Billing")

seats = get_organisation_seats(organisation)
Expand Down Expand Up @@ -52,6 +61,9 @@ def mutate(self, info, organisation_id, billing_period):
"trial_period_days": 30,
},
return_url=f"{settings.OAUTH_REDIRECT_URI}/{organisation.name}/settings?stripe_session_id={{CHECKOUT_SESSION_ID}}",
saved_payment_method_options={
"allow_redisplay_filters": ["always", "limited", "unspecified"],
},
)
return CreateProUpgradeCheckoutSession(client_secret=session.client_secret)

Expand All @@ -61,3 +73,199 @@ def mutate(self, info, organisation_id, billing_period):
raise GraphQLError(
f"Something went wrong during checkout. Please try again."
)


class DeletePaymentMethodMutation(Mutation):
class Arguments:
organisation_id = ID()
payment_method_id = String()

ok = Boolean()

def mutate(self, info, organisation_id, payment_method_id):
org = Organisation.objects.get(id=organisation_id)

if not user_has_permission(info.context.user, "update", "Billing", org):
raise GraphQLError(
"You don't have the permissions required to update Billing information in this Organisation."
)

try:
stripe.api_key = settings.STRIPE["secret_key"]
stripe.PaymentMethod.detach(payment_method_id)

return DeletePaymentMethodMutation(ok=True)
except Exception as e:
raise GraphQLError("Something went wrong. Please try again.")


class CancelSubscriptionMutation(Mutation):
class Arguments:
organisation_id = ID()
subscription_id = String(required=True)

Output = UpdateSubscriptionResponse

def mutate(self, info, organisation_id, subscription_id):
stripe.api_key = settings.STRIPE["secret_key"]

org = Organisation.objects.get(id=organisation_id)

if not user_has_permission(info.context.user, "update", "Billing", org):
raise GraphQLError(
"You don't have the permissions required to update Billing information in this Organisation."
)

if org.stripe_subscription_id != subscription_id:
raise GraphQLError("The subscription ID provided is not valid.")

try:
# Retrieve the subscription
subscription = stripe.Subscription.retrieve(subscription_id)

# Cancel at the end of the current billing cycle
updated_subscription = stripe.Subscription.modify(
subscription_id, cancel_at_period_end=True
)

return UpdateSubscriptionResponse(
success=True,
message="Subscription set to cancel at the end of the current billing cycle.",
canceled_at=None, # The subscription is not yet canceled
status=updated_subscription["status"],
)
except stripe.error.InvalidRequestError as e:
return UpdateSubscriptionResponse(
success=False,
message=f"Error: {str(e)}",
canceled_at=None,
status=None,
)
except Exception as e:
return UpdateSubscriptionResponse(
success=False,
message=f"An unexpected error occurred: {str(e)}",
canceled_at=None,
status=None,
)


class ResumeSubscriptionMutation(Mutation):
class Arguments:
organisation_id = ID()
subscription_id = String(required=True)

Output = UpdateSubscriptionResponse # Reuse the response class for consistency

def mutate(self, info, organisation_id, subscription_id):
stripe.api_key = settings.STRIPE["secret_key"]

try:
org = Organisation.objects.get(id=organisation_id)

if not user_has_permission(info.context.user, "update", "Billing", org):
raise GraphQLError(
"You don't have the permissions required to update Billing information in this Organisation."
)

if org.stripe_subscription_id != subscription_id:
raise GraphQLError("The subscription ID provided is not valid.")

# Retrieve the subscription
subscription = stripe.Subscription.retrieve(subscription_id)

if not subscription.get("cancel_at_period_end"):
raise GraphQLError("The subscription is not marked for cancellation.")

# Resume the subscription by updating cancel_at_period_end to False
updated_subscription = stripe.Subscription.modify(
subscription_id, cancel_at_period_end=False
)

return UpdateSubscriptionResponse(
success=True,
message="Subscription resumed successfully.",
canceled_at=None, # Reset canceled_at since the subscription is active
status=updated_subscription["status"],
)
except stripe.error.InvalidRequestError as e:
return UpdateSubscriptionResponse(
success=False,
message=f"Error: {str(e)}",
canceled_at=None,
status=None,
)
except Exception as e:
return UpdateSubscriptionResponse(
success=False,
message=f"An unexpected error occurred: {str(e)}",
canceled_at=None,
status=None,
)


class CreateSetupIntentMutation(Mutation):
class Arguments:
organisation_id = ID()

client_secret = String()

def mutate(self, info, organisation_id):
stripe.api_key = settings.STRIPE["secret_key"]

org = Organisation.objects.get(id=organisation_id)

if not user_has_permission(info.context.user, "update", "Billing", org):
raise GraphQLError(
"You don't have the permissions required to update Billing information in this Organisation."
)

# Create a SetupIntent for the customer
setup_intent = stripe.SetupIntent.create(
customer=org.stripe_customer_id,
usage="off_session",
automatic_payment_methods={
"enabled": False,
},
payment_method_types=["card"],
)

return CreateSetupIntentMutation(client_secret=setup_intent.client_secret)


class SetDefaultPaymentMethodMutation(Mutation):
class Arguments:
# Arguments passed to the mutation
organisation_id = ID()
payment_method_id = String(
required=True, description="Payment Method ID to set as default"
)

# Define the return type
ok = Boolean()

def mutate(root, info, organisation_id, payment_method_id):

org = Organisation.objects.get(id=organisation_id)

if not user_has_permission(info.context.user, "update", "Billing", org):
raise GraphQLError(
"You don't have the permissions required to update Billing information in this Organisation."
)

try:
# Retrieve the customer from Stripe
stripe.Customer.modify(
org.stripe_customer_id,
invoice_settings={"default_payment_method": payment_method_id},
)

stripe.PaymentMethod.modify(payment_method_id, allow_redisplay="limited")

return SetDefaultPaymentMethodMutation(ok=True)
except stripe.error.StripeError as e:
# Handle Stripe errors
raise GraphQLError(f"Stripe error: {str(e)}")
except Exception as e:
# Handle other potential exceptions
raise GraphQLError(f"An error occurred: {str(e)}")
85 changes: 84 additions & 1 deletion backend/ee/billing/graphene/queries/stripe.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from api.models import Organisation
from api.utils.access.permissions import user_has_permission
import graphene
from graphene import ObjectType, String, Field
from graphene import ObjectType, String, Boolean, List, Int
import stripe
from django.conf import settings
from graphql import GraphQLError


class StripeCheckoutDetails(graphene.ObjectType):
Expand All @@ -13,6 +16,27 @@ class StripeCheckoutDetails(graphene.ObjectType):
plan_name = graphene.String()


class PaymentMethodDetails(graphene.ObjectType):
id = graphene.String()
brand = graphene.String()
last4 = graphene.String()
exp_month = graphene.Int()
exp_year = graphene.Int()
is_default = graphene.Boolean()


class StripeSubscriptionDetails(ObjectType):
subscription_id = String()
plan_name = String()
status = String()
current_period_start = Int()
current_period_end = Int()
renewal_date = Int()
cancel_at = Int()
cancel_at_period_end = Boolean()
payment_methods = List(PaymentMethodDetails)


def resolve_stripe_checkout_details(self, info, stripe_session_id):
stripe.api_key = settings.STRIPE["secret_key"]

Expand Down Expand Up @@ -40,3 +64,62 @@ def resolve_stripe_checkout_details(self, info, stripe_session_id):
)
except stripe.error.StripeError as e:
return None


def resolve_stripe_subscription_details(self, info, organisation_id):
stripe.api_key = settings.STRIPE["secret_key"]

try:
org = Organisation.objects.get(id=organisation_id)
if not user_has_permission(info.context.user, "read", "Billing", org):
raise GraphQLError("You don't have permission to view Tokens in this App")

# Retrieve subscription details
subscription = stripe.Subscription.retrieve(org.stripe_subscription_id)

plan_name = subscription["items"]["data"][0]["plan"]["nickname"]
current_period_start = subscription["current_period_start"]
current_period_end = subscription["current_period_end"]
renewal_date = subscription["current_period_end"]
status = subscription["status"]

# Retrieve cancellation details
cancel_at = subscription.get("cancel_at") # Timestamp of cancellation, if set
cancel_at_period_end = subscription.get("cancel_at_period_end") # Boolean

customer = stripe.Customer.retrieve(org.stripe_customer_id)

default_payment_method_id = customer.get("invoice_settings", {}).get(
"default_payment_method"
)

# Retrieve payment methods for the customer
payment_methods = stripe.PaymentMethod.list(
customer=org.stripe_customer_id, type="card"
)

payment_methods_list = [
PaymentMethodDetails(
id=pm["id"],
brand=pm["card"]["brand"],
last4=pm["card"]["last4"],
is_default=(pm["id"] == default_payment_method_id), # Check if default
exp_month=pm["card"]["exp_month"],
exp_year=pm["card"]["exp_year"],
)
for pm in payment_methods["data"]
]

return StripeSubscriptionDetails(
subscription_id=org.stripe_subscription_id,
plan_name=plan_name,
status=status,
current_period_start=str(current_period_start),
current_period_end=str(current_period_end),
renewal_date=str(renewal_date),
cancel_at=str(cancel_at) if cancel_at else None,
cancel_at_period_end=cancel_at_period_end,
payment_methods=payment_methods_list,
)
except stripe.error.StripeError as e:
return None
Loading

0 comments on commit 563b383

Please sign in to comment.