Skip to content

Commit

Permalink
feat: add stripe subscription webhook handlers (#476)
Browse files Browse the repository at this point in the history
  • Loading branch information
vncsna authored Nov 12, 2023
1 parent 8e4c144 commit fd2c8dc
Show file tree
Hide file tree
Showing 12 changed files with 260 additions and 28 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/cd-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ jobs:
value: "https://development.basedosdados.org"
- name: "BASE_URL_API"
value: "https://development.api.basedosdados.org"
- name: "GOOGLE_DIRECTORY_SUBJECT"
value: "${{ secrets.GOOGLE_DIRECTORY_SUBJECT }}"
- name: "GOOGLE_DIRECTORY_GROUP_KEY"
value: "${{ secrets.GOOGLE_DIRECTORY_GROUP_KEY }}"
- name: "REDIS_DB"
value: "0"
- name: "STRIPE_LIVE_MODE"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/cd-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ jobs:
value: "https://basedosdados.org"
- name: "BASE_URL_API"
value: "https://api.basedosdados.org"
- name: "GOOGLE_DIRECTORY_SUBJECT"
value: "${{ secrets.GOOGLE_DIRECTORY_SUBJECT }}"
- name: "GOOGLE_DIRECTORY_GROUP_KEY"
value: "${{ secrets.GOOGLE_DIRECTORY_GROUP_KEY }}"
- name: "REDIS_DB"
value: "0"
- name: "STRIPE_LIVE_MODE"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/cd-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ jobs:
value: "https://staging.basedosdados.org"
- name: "BASE_URL_API"
value: "https://staging.api.basedosdados.org"
- name: "GOOGLE_DIRECTORY_SUBJECT"
value: "${{ secrets.GOOGLE_DIRECTORY_SUBJECT }}"
- name: "GOOGLE_DIRECTORY_GROUP_KEY"
value: "${{ secrets.GOOGLE_DIRECTORY_GROUP_KEY }}"
- name: "REDIS_DB"
value: "0"
- name: "STRIPE_LIVE_MODE"
Expand Down
47 changes: 47 additions & 0 deletions bd_api/apps/account/migrations/0011_subscription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Generated by Django 4.2.6 on 2023-11-12 19:43

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("account", "0010_alter_account_is_admin"),
]

operations = [
migrations.CreateModel(
name="Subscription",
fields=[
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
(
"is_active",
models.BooleanField(
default=False,
help_text="Indica se a inscrição está ativa",
verbose_name="Ativo",
),
),
(
"admin",
models.OneToOneField(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="admin_subscription",
to=settings.AUTH_USER_MODEL,
),
),
("subscribers", models.ManyToManyField(to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Subscription",
"verbose_name_plural": "Subscriptions",
},
),
]
23 changes: 23 additions & 0 deletions bd_api/apps/account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,26 @@ def get_team(self):
return self.team

get_team.short_description = "Equipe"


class Subscription(BdmModel):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

id = models.UUIDField(primary_key=True, default=uuid4)
is_active = models.BooleanField(
"Ativo",
default=False,
help_text="Indica se a inscrição está ativa",
)

admin = models.OneToOneField(
"Account",
on_delete=models.DO_NOTHING,
related_name="admin_subscription",
)
subscribers = models.ManyToManyField(Account)

class Meta:
verbose_name = "Subscription"
verbose_name_plural = "Subscriptions"
1 change: 1 addition & 0 deletions bd_api/apps/payment/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ class PaymentsConfig(DjstripeAppConfig):
def ready(self):
super().ready()
import bd_api.apps.payment.signals # noqa
import bd_api.apps.payment.webhooks # noqa
68 changes: 65 additions & 3 deletions bd_api/apps/payment/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
from djstripe.models import Customer as DJStripeCustomer
from djstripe.models import Price as DJStripePrice
from djstripe.models import Subscription as DJStripeSubscription
from graphene import ID, Field, Float, InputObjectType, List, Mutation, ObjectType, String
from graphene import ID, Boolean, Field, Float, InputObjectType, List, Mutation, ObjectType, String
from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField
from graphql_jwt.decorators import login_required
from loguru import logger
from stripe import Customer as StripeCustomer

from bd_api.apps.account.models import Account
from bd_api.apps.account.models import Account, Subscription
from bd_api.apps.payment.webhooks import add_user, remove_user
from bd_api.custom.graphql_base import CountableConnection, PlainTextNode


Expand Down Expand Up @@ -146,7 +147,7 @@ def mutate(cls, root, info, input):
return cls(customer=customer)
except Exception as e:
logger.error(e)
return cls(errors=str(e))
return cls(errors=[str(e)])


class StripeCustomerMutation(ObjectType):
Expand Down Expand Up @@ -189,6 +190,7 @@ def mutate(cls, root, info, price_id):
payment_behaviour="default_incomplete",
payment_settings={"save_default_payment_method": "on_subscription"},
)
# Criar inscrição interna
return cls(subscription=subscription)
except Exception as e:
logger.error(e)
Expand Down Expand Up @@ -221,5 +223,65 @@ class StripeSubscriptionMutation(ObjectType):
delete_stripe_subscription = StripeSubscriptionDeleteMutation.Field()


class StripeSubscriptionCustomerInput(InputObjectType):
account_id = ID(required=True)
subscription_id = ID(required=True)


class StripeSubscriptionCustomerCreateMutation(Mutation):
"""Add account to subscription"""

ok = Boolean()
errors = List(String)

class Arguments:
input = StripeSubscriptionCustomerInput()

@classmethod
@login_required
def mutate(cls, root, info, input):
try:
admin = info.context.user
admin = Account.objects.get(id=admin.id)
account = Account.objects.get(id=input.account_id).first()
subscription = Subscription.objects.get(id=input.subscription_id).first()
assert admin.id == subscription.admin.id
add_user(account.email)
return cls(ok=True)
except Exception as e:
logger.error(e)
return cls(errors=[str(e)])


class StripeSubscriptionCustomerDeleteMutation(Mutation):
"""Remove account from subscription"""

ok = Boolean()
errors = List(String)

class Arguments:
input = StripeSubscriptionCustomerInput()

@classmethod
@login_required
def mutate(cls, root, info, input):
try:
admin = info.context.user
admin = Account.objects.get(id=admin.id)
account = Account.objects.get(id=input.account_id).first()
subscription = Subscription.objects.get(id=input.subscription_id).first()
assert admin.id == subscription.admin.id
remove_user(account.email)
return cls(ok=True)
except Exception as e:
logger.error(e)
return cls(errors=[str(e)])


class StripeSubscriptionCustomerMutation(ObjectType):
create_stripe_customer_subscription = StripeSubscriptionCustomerCreateMutation.Field()
update_stripe_customer_subscription = StripeSubscriptionCustomerDeleteMutation.Field()


# Reference
# https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=elementsf
22 changes: 0 additions & 22 deletions bd_api/apps/payment/views.py
Original file line number Diff line number Diff line change
@@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt


@method_decorator(csrf_exempt, "dispatch")
@method_decorator(login_required, "dispatch")
class StripeCustomerSubscriptionView(View):
def post(self, request: HttpRequest, account_id: str, subscription_id: str):
"""Add customer to a stripe subscription"""
...

def delete(self, request: HttpRequest, account_id: str, subscription_id: str):
"""Remove customer from a stripe subscription"""
...


# Reference
# https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=elementsf
91 changes: 91 additions & 0 deletions bd_api/apps/payment/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
from django.conf import settings
from djstripe import webhooks
from djstripe.models import Event
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from loguru import logger

from bd_api.apps.account.models import Account, Subscription

logger = logger.bind(codename="payment_webhook")


def get_credentials(scopes: list[str] = None, impersonate: str = None):
"""Get google credentials with scope or subject"""
cred = Credentials.from_service_account_file(
settings.GOOGLE_APPLICATION_CREDENTIALS,
)
if scopes:
cred = cred.with_scopes(scopes)
if impersonate:
cred = cred.with_subject(impersonate)
return cred


def get_service() -> Resource:
"""Get google directory service"""
credentials = get_credentials(
settings.GOOGLE_DIRECTORY_SCOPES,
settings.GOOGLE_DIRECTORY_SUBJECT,
)
return build("admin", "directory_v1", credentials=credentials)


def add_user(
email: str,
role: str = "MEMBER",
group_key: str = settings.GOOGLE_DIRECTORY_GROUP_KEY,
):
"""Add user to google group"""
try:
service = get_service()
service.members().insert(
groupKey=group_key,
body={"email": email, "role": role},
).execute()
except HttpError as e:
if e.resp.status == 490:
logger.warning(f"{email} already exists")
else:
logger.error(e)
raise e


def remove_user(
email: str,
group_key: str = settings.GOOGLE_DIRECTORY_GROUP_KEY,
) -> None:
"""Remove user from google group"""
try:
service = get_service()
service.members().delete(
groupKey=group_key,
memberKey=email,
).execute()
except Exception as e:
logger.error(e)
raise e


@webhooks.handler("customer.subscription.created")
def subscribe(event: Event, **kwargs):
"""Add customer to allowed google groups"""
add_user(event.customer.email)
admin = Account.objects.get(email=event.customer.email).first()
Subscription.objects.create(admin=admin, is_active=True)


@webhooks.handler("customer.subscription.deleted")
def unsubscribe(event: Event, **kwargs):
"""Remove customer from allowed google groups"""
remove_user(event.customer.email)
admin = Account.objects.get(email=event.customer.email).first()
admin.subscription.is_active = False
admin.subscription.save()


# Reference
# https://developers.google.com/admin-sdk/directory/v1/guides/troubleshoot-error-codes
# https://developers.google.com/admin-sdk/reseller/v1/support/directory_api_common_errors
2 changes: 2 additions & 0 deletions bd_api/apps/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from bd_api.apps.payment.graphql import (
StripeCustomerMutation,
StripePriceQuery,
StripeSubscriptionCustomerMutation,
StripeSubscriptionMutation,
)
from bd_api.custom.graphql_auto import build_schema
Expand All @@ -14,5 +15,6 @@
extra_mutations=[
StripeCustomerMutation,
StripeSubscriptionMutation,
StripeSubscriptionCustomerMutation,
],
)
10 changes: 9 additions & 1 deletion bd_api/settings/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@
# Logging
setup_logger(level="DEBUG", ignore=["faker"], serialize=False)

# Google Application Credentials
# Google Auth
GOOGLE_APPLICATION_CREDENTIALS = getenv("GOOGLE_APPLICATION_CREDENTIALS")

# Google Directory
GOOGLE_DIRECTORY_SCOPES = [
"https://www.googleapis.com/auth/admin.directory.user",
"https://www.googleapis.com/auth/admin.directory.group",
]
GOOGLE_DIRECTORY_SUBJECT = getenv("GOOGLE_DIRECTORY_SUBJECT")
GOOGLE_DIRECTORY_GROUP_KEY = getenv("GOOGLE_DIRECTORY_GROUP_KEY")

# Google Cloud Storage
...

Expand Down
12 changes: 10 additions & 2 deletions bd_api/settings/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,16 @@
# Logging
setup_logger(level="INFO", ignore=["faker"], serialize=True)

# Google Application Credentials
GOOGLE_APPLICATION_CREDENTIALS = getenvp("GOOGLE_APPLICATION_CREDENTIALS", "")
# Google Auth
GOOGLE_APPLICATION_CREDENTIALS = getenvp("GOOGLE_APPLICATION_CREDENTIALS")

# Google Directory
GOOGLE_DIRECTORY_SCOPES = [
"https://www.googleapis.com/auth/admin.directory.user",
"https://www.googleapis.com/auth/admin.directory.group",
]
GOOGLE_DIRECTORY_SUBJECT = getenvp("GOOGLE_DIRECTORY_SUBJECT")
GOOGLE_DIRECTORY_GROUP_KEY = getenvp("GOOGLE_DIRECTORY_GROUP_KEY")

# Google Cloud Storage
GS_SERVICE_ACCOUNT = getenvp("GCP_SA")
Expand Down

0 comments on commit fd2c8dc

Please sign in to comment.