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

upgrade and add dj stripe #40

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 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
2 changes: 2 additions & 0 deletions .env_sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
STRIPE_PUBLIC_KEY=
STRIPE_PRIVATE_KEY=
4 changes: 2 additions & 2 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.9.x, 3.10.x, 3.11.x]
django-version: ['<4', '>=4']
python-version: [3.10.x, 3.11.x, 3.12.x]
django-version: ['<4', '>=4', '>=5']
hhartwell marked this conversation as resolved.
Show resolved Hide resolved

steps:
- uses: actions/checkout@v2
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ INSTALLED_APPS = (
## tests

```bash
$ docker build -t django-ckc . && docker run django-ckc pytest
$ docker build -t django-ckc . && docker run --env-file .env django-ckc pytest
```

## what's in this
hhartwell marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Empty file added ckc/stripe/__init__.py
Empty file.
90 changes: 90 additions & 0 deletions ckc/stripe/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import stripe
from djstripe.models import PaymentMethod, Customer, Price, Product

from rest_framework import serializers

class PaymentMethodSerializer(serializers.ModelSerializer):
pm_id = serializers.CharField(write_only=True)

class Meta:
model = PaymentMethod
fields = (
'pm_id',
'id',
'type',

# 'customer',
# 'stripe_id',
# 'card_brand',
# 'card_last4',
# 'card_exp_month',
# 'card_exp_year',
# 'is_default',
# 'created',
# 'modified',
Comment on lines +17 to +25
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pull, or useful?

)
read_only_fields = (
'id',
'type',
# 'customer',
# 'stripe_id',
# 'card_brand',
# 'card_last4',
# 'card_exp_month',
# 'card_exp_year',
# 'is_default',
# 'created',
# 'modified',
)

def create(self, validated_data):
customer, created = Customer.get_or_create(subscriber=self.context['request'].user)
try:
payment_method = customer.add_payment_method(validated_data['pm_id'])
except (stripe.error.InvalidRequestError) as e:
raise serializers.ValidationError(e)

return payment_method


class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = (
'id',
'name',
'description',
'type',
)
read_only_fields = (
'id',
'name',
'description',
'type',
)


class PriceSerializer(serializers.ModelSerializer):
class Meta:
model = Price
fields = (
'id',
'unit_amount',
'currency',
'recurring',
)
read_only_fields = (
'id',
'unit_amount',
'currency',
'recurring',
)


class SubscribeSerializer(serializers.Serializer):
price_id = serializers.CharField()

class Meta:
fields = (
'price_id'
)
Empty file added ckc/stripe/utils/__init__.py
Empty file.
122 changes: 122 additions & 0 deletions ckc/stripe/utils/payments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import json

import stripe
from djstripe.models import Customer

from django.conf import settings


def create_checkout_session(user, success_url, cancel_url, line_items, metadata=None, payment_method_types=None):
"""
create and return a stripe checkout session

@param user: the user to associate the session with
@param success_url: the url to redirect to after a successful payment
@param cancel_url: the url to redirect to after a cancelled payment
@param line_items: a list of line items to add to the session
@param metadata: optional metadata to add to the session
@param payment_method_types: optional payment method types to accept. defaults to ["card"]


metadata = {},
success_url = "https://example.com/success",
cancel_url = "https://example.com/cancel",
line_items = [{
"quantity": 1,
"price_data": {
"currency": "usd",
"unit_amount": 2000,
"product_data": {
"name": "Sample Product Name",
"images": ["https://i.imgur.com/EHyR2nP.png"],
"description": "Sample Description",
},
},
}]

@returns stripe.checkout.Session
"""
if not metadata:
metadata = {}
if not payment_method_types:
payment_method_types = ["card"]

customer, created = Customer.get_or_create(subscriber=user)
session = stripe.checkout.Session.create(
payment_method_types=payment_method_types,
customer=customer.id,
payment_intent_data={
"setup_future_usage": "off_session",
# so that the metadata gets copied to the associated Payment Intent and Charge Objects
"metadata": metadata
},
line_items=line_items,
mode="payment",
success_url=success_url,
cancel_url=cancel_url,
metadata=metadata,
)
return session


def create_payment_intent(payment_method_id, customer_id, amount, currency="usd", confirmation_method="automatic"):
"""
create and return a stripe payment intent
@param payment_method_id: the id of the payment method to use
@param amount: the amount to charge
@param currency: the currency to charge in. defaults to "usd"
@param confirmation_method: the confirmation method to use. choices are "manual" and "automatic". defaults to "automatic"
if set to manual, you must call confirm_payment_intent to confirm the payment intent
@returns stripe.PaymentIntent
"""
if not payment_method_id:
raise ValueError("payment_method_id must be set")

intent = None
try:
# Create the PaymentIntent
intent = stripe.PaymentIntent.create(
customer=customer_id,
payment_method=payment_method_id,
amount=amount,
currency=currency,
# confirmation_method=confirmation_method,
confirm=confirmation_method == "automatic",
api_key=settings.STRIPE_PRIVATE_KEY,
automatic_payment_methods={
"enabled": True,
"allow_redirects": 'never'
},

)
except stripe.error.CardError:
pass
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oo, should we really let this pass? I think we should let this be raised, and handle it with ValidationError wrappers at a certain point?

try:
    create_payment_intent(...)
except stripe.error.CardError:
    raise ValidationError("There was a problem with your card? Please try a different card.")

return intent


def confirm_payment_intent(payment_intent_id):
"""
confirm a stripe payment intent
@param payment_intent_id: the id of the payment intent to confirm
@returns a tuple of (data, status_code)
"""
intent = stripe.PaymentIntent.confirm(
payment_intent_id,
api_key=settings.STRIPE_PRIVATE_KEY,
)

if intent.status == "requires_action" and intent.next_action.type == "use_stripe_sdk":
# Tell the client to handle the action
return_data = json.dumps({
"requires_action": True,
"payment_intent_client_secret": intent.client_secret
}), 200
pass
elif intent.status == "succeeded":
# The payment did not need any additional actions and completed!
# Handle post-payment fulfillment
return_data = json.dumps({"success": True}), 200
else:
# Invalid status
return_data = json.dumps({"error": "Invalid PaymentIntent status"}), 500
return return_data
39 changes: 39 additions & 0 deletions ckc/stripe/utils/subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import stripe
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think we don't need this utils/ subdir? payments.py, and subscriptions.py could live right in ckc.stripe

from djstripe.models import Price, Product


def create_price(amount, interval, interval_count=1, currency="usd", product_name="Sample Product Name", **kwargs):
"""
create and return a stripe price object
@param amount: the amount to charge
@param interval: the interval to charge at
@param interval_count: the number of intervals to charge at
@param currency: the currency to charge in
@param product_name: the name of the product to create
@param kwargs: additional arguments to pass to the stripe.Product.create method
@returns stripe.Price

"""
stripe_product = stripe.Product.create(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any error handling needed in this function? we can have some fun soon trying to cause all kinds of problems and handle them nicely .. then never have to handle them again!

name=product_name,
description="Sample Description",
)
product = Product.sync_from_stripe_data(stripe_product)
recurring = kwargs.pop("recurring", {})
recurring.update({
"interval": interval,
"interval_count": interval_count,
})
price = Price.create(
unit_amount=amount,
currency=currency,
recurring={
"interval": interval,
"interval_count": interval_count,
},
product=product,
active=True,
**kwargs
)

return price
53 changes: 53 additions & 0 deletions ckc/stripe/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from djstripe.models import PaymentMethod, Price, Plan, Customer
from rest_framework import viewsets, mixins
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response

from ckc.stripe.serializers import PaymentMethodSerializer, PriceSerializer, SubscribeSerializer


class PaymentMethodViewSet(viewsets.ModelViewSet):
serializer_class = PaymentMethodSerializer
permission_classes = [IsAuthenticated]

def get_queryset(self):
qs = PaymentMethod.objects.filter(customer__subscriber=self.request.user)
return qs


class PriceViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.ListModelMixin):
serializer_class = PriceSerializer
permission_classes = [AllowAny]

def get_queryset(self):
qs = Price.objects.all()
return qs


class SubscribeViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]

def get_serialzer_class(self):
if self.action == 'subscribe':
return SubscribeSerializer

@action(methods=['post'], detail=False)
def subscribe(self, request):
# get stripe customer
customer, created = Customer.get_or_create(subscriber=request.user)
if customer.subscription:
return Response(status=400, data={'error': 'already subscribed'})

serializer = SubscribeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

customer.subscribe(price=serializer.data['price_id'])
return Response(status=204)

@action(methods=['post'], detail=False)
def cancel(self, request):
# get stripe customer
customer, created = Customer.get_or_create(subscriber=request.user)
customer.subscription.cancel()
return Response(status=204)
Empty file added ckc/utils/__init__.py
Empty file.
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
twine==4.0.2

# django stuff
Django==4.2.2
Django==5.0
hhartwell marked this conversation as resolved.
Show resolved Hide resolved
djangorestframework==3.14.0
pytz==2023.3

Expand All @@ -15,3 +15,6 @@ factory-boy==3.2.1
pytest==7.3.2
pytest-django==4.5.2
flake8==6.0.0

# payment processing
dj-stripe==2.8.3
5 changes: 5 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ classifiers =
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
Topic :: Software Development
long_description = file: README.md
long_description_content_type = text/markdown
Expand All @@ -60,3 +61,7 @@ license_files =
python_requires = >= 3.6
packages = find:
zip_safe: False

[options.extras_require]
stripe =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make this optional, lots of other projects don't use stripe

So we can install django-ckc without also installing stripe, unless we do django-ckc[stripe] or something

djstripe>=2.8.3
19 changes: 19 additions & 0 deletions testproject/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os

import stripe

DEBUG = True

Expand Down Expand Up @@ -40,6 +41,8 @@
"ckc",

"testapp",

"djstripe",
)

STATIC_URL = "/static/"
Expand All @@ -52,3 +55,19 @@
"APP_DIRS": True
}
]

# =============================================================================
# Stripe
# =============================================================================
STRIPE_PUBLIC_KEY = os.environ.get('STRIPE_PUBLIC_KEY')
STRIPE_PRIVATE_KEY = os.environ.get('STRIPE_PRIVATE_KEY')
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
stripe.api_key = STRIPE_PRIVATE_KEY

# =============================================================================
# DJStripe
# =============================================================================
STRIPE_LIVE_SECRET_KEY = STRIPE_PRIVATE_KEY
STRIPE_TEST_SECRET_KEY = STRIPE_PRIVATE_KEY
DJSTRIPE_USE_NATIVE_JSONFIELD = True
STRIPE_LIVE_MODE = False # Change to True in production
Loading
Loading