Skip to content
Open
Show file tree
Hide file tree
Changes from all 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=
8 changes: 6 additions & 2 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ on:
push:
branches: [ '*' ]

env:
STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_PUBLIC_KEY }}
STRIPE_PRIVATE_KEY: ${{ secrets.STRIPE_PRIVATE_KEY }}

jobs:
build:

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', '>=5']

steps:
- uses: actions/checkout@v2
Expand Down
85 changes: 84 additions & 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
Expand Down Expand Up @@ -176,6 +176,89 @@ class TestExceptionsViewSet(APIView):
raise SnackbarError("Something went wrong")
```

### Payment helpers ([dj-stripe](https://dj-stripe.dev/))
#### env vars
```bash
STRIPE_PUBLIC_KEY=sk_test_...
STRIPE_PRIVATE_KEY=pk_test_...
```

#### Create and charge a payment intent

```py
from ckc.stripe.payments import create_payment_intent, confirm_payment_intent

# for manual control
intent = create_payment_intent(payment_method.id, customer.id, 2000, confirmation_method="manual")
response_data, status_code = confirm_payment_intent(intent.id)
# alternatively, you can have stripe auto charge the intent
intent = create_payment_intent(payment_method.id, customer.id, 2000, confirmation_method="automatic")
```

#### setting up a subscription plan
A subscription plan is a product with a recurring price. We will create a price and supply it with product info. the product will be auto created. You can create a plan with the following code:

```py
from ckc.stripe.subscriptions import create_price

price = create_price(2000, "month", product_name="Sample Product Name: 0", currency="usd")
```
#### setting up signal handlers
there are two signals that can be used. `post_subscribe` and `post_cancel`. you can use them like so:

in signal_handlers.py
```py

from django.dispatch import receiver
from ckc.stripe.signals import post_subscribe
from ckc.stripe.views import SubscribeViewSet


@receiver(post_subscribe, sender=SubscribeViewSet)
def subscribe_signal_handler(sender, **kwargs):
# your custom logic.
# kwargs will contain the following:
# user: the user that was subscribed
# subscription: the subscription object
pass
```
in apps.py
```py
from django.apps import AppConfig
class YourAppConfig(AppConfig):
name = "your_app"
def ready(self):
import your_app.signal_handlers
```

[//]: # (#### subscribing a user to a subscription using a Price object)

[//]: # (using the `subsciptions` endpoint you a user can be subscribed to a plan.)

[//]: # ()
[//]: # (note: you will need to setup a payment method for the user before subscribing them to a plan. see below for more info )

[//]: # (```js)

[//]: # (// REQUEST from a signed in user that wishes to subscribe to a plan)

[//]: # (axios.post&#40;"/subscriptions/subscribe/", { price_id: price.id }&#41;)

[//]: # (```)

[//]: # ()
[//]: # (#### Creating a payment method)

[//]: # (using the stripe card element on the frontend, obtain a payment method id. and pass it up to the frontend to attach to a customer)

[//]: # (```js)

[//]: # (// REQUEST from a signed in user that wishes to create a payment method)

[//]: # (axios.post&#40;"/payment-methods/", { pm_id: pm.id }&#41;)

[//]: # (```)

#### `./manage.py` commands

| command | description|
Expand Down
11 changes: 7 additions & 4 deletions ckc/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ def create(self, validated_data):
# get name of the user field we'll be writing request.user to, default created_by
user_field = getattr(self.Meta, 'user_field', 'created_by')

assert hasattr(self.Meta.model, user_field), f"{self.Meta.model} needs to have field {user_field} so " \
f"DefaultCreatedByMixin can write to it"
assert \
hasattr(self.Meta.model, user_field), \
f"{self.Meta.model} needs to have field {user_field} so " f"DefaultCreatedByMixin can write to it"

if user_field not in validated_data:
if 'request' not in self.context:
raise Exception('self.context does not contain "request". Have you overwritten get_serializer_context '
'and overwrote context?')
raise Exception(
'self.context does not contain "request". '
'Have you overwritten get_serializer_context and overwrote context?'
)
validated_data[user_field] = self.context['request'].user
return super().create(validated_data)
Empty file added ckc/stripe/__init__.py
Empty file.
123 changes: 123 additions & 0 deletions ckc/stripe/payments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import json

import stripe
from djstripe.models import Customer

from django.conf import settings
from rest_framework.exceptions import ValidationError


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:
raise ValidationError("Error encountered while creating payment intent")
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
93 changes: 93 additions & 0 deletions ckc/stripe/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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',
'nickname',
)
read_only_fields = (
'id',
'unit_amount',
'currency',
'recurring',
'nickname',
)


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

class Meta:
fields = (
'price_id'
)
7 changes: 7 additions & 0 deletions ckc/stripe/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.dispatch import Signal

# Define a signal for post-subscription
post_subscribe = Signal()

# Define a signal for post-cancellation
post_cancel = Signal()
Loading