Skip to content

Commit

Permalink
Merge pull request #860 from MikeSoft007/feat/stripe_checkout
Browse files Browse the repository at this point in the history
feat: integrate stripe payment and add endpoint for fetching user plans in organisation
  • Loading branch information
joboy-dev authored Aug 11, 2024
2 parents a9738c3 + f847364 commit 37f63e3
Show file tree
Hide file tree
Showing 13 changed files with 479 additions and 12 deletions.
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@ TWILIO_PHONE_NUMBER="TWILIO_PHONE_NUMBER"
FLUTTERWAVE_SECRET=""
PAYSTACK_SECRET=""

STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""

MAILJET_API_KEY='MAIL JET API KEY'
MAILJET_API_SECRET='SECRET KEY'
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""updated billing model ensuing that plan name is unique
Revision ID: 9a4e3d412f8e
Revises: af8459ffc616
Create Date: 2024-08-11 21:16:54.902038
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '9a4e3d412f8e'
down_revision: Union[str, None] = 'af8459ffc616'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_subscriptions',
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('plan_id', sa.String(), nullable=False),
sa.Column('organisation_id', sa.String(), nullable=False),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('start_date', sa.String(), nullable=False),
sa.Column('end_date', sa.String(), nullable=True),
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['plan_id'], ['billing_plans.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_subscriptions_id'), 'user_subscriptions', ['id'], unique=False)
op.create_unique_constraint(None, 'billing_plans', ['name'])
op.add_column('user_organisation_roles', sa.Column('status', sa.String(length=20), nullable=False))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_organisation_roles', 'status')
op.drop_constraint(None, 'billing_plans', type_='unique')
op.drop_index(op.f('ix_user_subscriptions_id'), table_name='user_subscriptions')
op.drop_table('user_subscriptions')
# ### end Alembic commands ###
20 changes: 18 additions & 2 deletions api/v1/models/billing_plan.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# app/models/billing_plan.py
from sqlalchemy import Column, String, ARRAY, ForeignKey, Numeric
from sqlalchemy import Column, String, ARRAY, ForeignKey, Numeric, Boolean
from sqlalchemy.orm import relationship
from api.v1.models.base_model import BaseTableModel

Expand All @@ -10,11 +10,27 @@ class BillingPlan(BaseTableModel):
organisation_id = Column(
String, ForeignKey("organisations.id", ondelete="CASCADE"), nullable=False
)
name = Column(String, nullable=False)
name = Column(String, nullable=False, unique=True)
price = Column(Numeric, nullable=False)
currency = Column(String, nullable=False)
duration = Column(String, nullable=False)
description = Column(String, nullable=True)
features = Column(ARRAY(String), nullable=False)

organisation = relationship("Organisation", back_populates="billing_plans")
user_subscriptions = relationship("UserSubscription", back_populates="billing_plan", cascade="all, delete-orphan")


class UserSubscription(BaseTableModel):
__tablename__ = "user_subscriptions"

user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
plan_id = Column(String, ForeignKey("billing_plans.id", ondelete="CASCADE"), nullable=False)
organisation_id = Column(String, ForeignKey("organisations.id", ondelete="CASCADE"), nullable=False)
active = Column(Boolean, default=True)
start_date = Column(String, nullable=False)
end_date = Column(String, nullable=True)

user = relationship("User", back_populates="subscriptions")
billing_plan = relationship("BillingPlan", back_populates="user_subscriptions")
organisation = relationship("Organisation", back_populates="user_subscriptions")
10 changes: 1 addition & 9 deletions api/v1/models/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,7 @@ class Organisation(BaseTableModel):
products = relationship("Product", back_populates="organisation", cascade="all, delete-orphan")
contact_us = relationship("ContactUs", back_populates="organisation", cascade="all, delete-orphan")

billing_plans = relationship(
"BillingPlan", back_populates="organisation", cascade="all, delete-orphan"
)
invitations = relationship(
"Invitation", back_populates="organisation", cascade="all, delete-orphan"
)
products = relationship(
"Product", back_populates="organisation", cascade="all, delete-orphan"
)
user_subscriptions = relationship("UserSubscription", back_populates="organisation", cascade="all, delete-orphan")
sales = relationship('Sales', back_populates='organisation', cascade='all, delete-orphan')

def __str__(self):
Expand Down
3 changes: 2 additions & 1 deletion api/v1/models/permissions/user_org_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
Column("user_id", String, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("organisation_id", String, ForeignKey("organisations.id", ondelete="CASCADE"), primary_key=True),
Column('role_id', String, ForeignKey('roles.id', ondelete='CASCADE'), nullable=True),
Column('is_owner', Boolean, server_default='false')
Column('is_owner', Boolean, server_default='false'),
Column('status', String(20), nullable=False, default="active")
)
4 changes: 4 additions & 0 deletions api/v1/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ class User(BaseTableModel):
)
product_comments = relationship("ProductComment", back_populates="user", cascade="all, delete-orphan")

subscriptions = relationship(
"UserSubscription", back_populates="user", cascade="all, delete-orphan"
)

def to_dict(self):
obj_dict = super().to_dict()
obj_dict.pop("password")
Expand Down
2 changes: 2 additions & 0 deletions api/v1/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from api.v1.routes.privacy import privacies
from api.v1.routes.settings import settings
from api.v1.routes.terms_and_conditions import terms_and_conditions
from api.v1.routes.stripe import subscription_

api_version_one = APIRouter(prefix="/api/v1")

Expand Down Expand Up @@ -89,3 +90,4 @@
api_version_one.include_router(team)
api_version_one.include_router(terms_and_conditions)
api_version_one.include_router(product_comment)
api_version_one.include_router(subscription_)
85 changes: 85 additions & 0 deletions api/v1/routes/stripe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
import stripe
from api.v1.services.stripe_payment import stripe_payment_request, update_user_plan, fetch_all_organisations_with_users_and_plans
import json
from api.v1.schemas.stripe import PlanUpgradeRequest
from typing import List
from api.db.database import get_db
import os
from api.utils.success_response import success_response
from api.v1.models.user import User
from api.v1.services.user import user_service
from dotenv import load_dotenv, find_dotenv

load_dotenv(find_dotenv())

stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
endpoint_secret = os.getenv('STRIPE_WEBHOOK_SECRET')

subscription_ = APIRouter(prefix="/payment", tags=["subscribe-plan"])

@subscription_.post("/stripe/upgrade-plan")
def stripe_payment(
plan_upgrade_request: PlanUpgradeRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user)
):
return stripe_payment_request(db, plan_upgrade_request.user_id, request, plan_upgrade_request.plan_name)

@subscription_.get("/stripe/success")
def success_upgrade():
return {"message" : "Payment successful"}

@subscription_.get("/stripe/cancel")
def cancel_upgrade():
return {"message" : "Payment canceled"}

@subscription_.post("/webhook")
async def webhook_received(
request: Request,
db: Session = Depends(get_db)
):

payload = await request.body()
event = None

try:
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except ValueError as e:
print("Invalid payload")
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError as e:
print("Invalid signature")
raise HTTPException(status_code=400, detail="Invalid signature")

if event["type"] == "checkout.session.completed":
payment = event["data"]["object"]
response_details = {
"amount": payment["amount_total"],
"currency": payment["currency"],
"user_id": payment["metadata"]["user_id"],
"user_email": payment["customer_details"]["email"],
"user_name": payment["customer_details"]["name"],
"order_id": payment["id"]
}
# Save to DB
# Send email in background task
await update_user_plan(db, payment["metadata"]["user_id"], payment["metadata"]["plan_name"])
return {"message": response_details}


@subscription_.get("/organisations/users/plans")
async def get_organisations_with_users_and_plans(db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_super_admin)):
try:
data = fetch_all_organisations_with_users_and_plans(db)
if not data:
return {"status_code": 404, "success": False, "message": "No data found"}
return success_response(
status_code=status.HTTP_302_FOUND,
message='billing details successfully retrieved',
data=data,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
30 changes: 30 additions & 0 deletions api/v1/schemas/stripe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import List, Optional

from pydantic import BaseModel, Field, validator


class PaymentInfo(BaseModel):
card_number: str = Field(..., min_length=16, max_length=16)
exp_month: int
exp_year: int
cvc: str = Field(..., min_length=3, max_length=4)

@validator('card_number')
def card_number_validator(cls, v):
if not v.isdigit() or len(v) != 16:
raise ValueError('Card number must be 16 digits')
return v

@validator('cvc')
def cvc_validator(cls, v):
if not v.isdigit() or not (3 <= len(v) <= 4):
raise ValueError('CVC must be 3 or 4 digits')
return v


class PlanUpgradeRequest(BaseModel):
user_id: str
plan_name: str
payment_info: Optional[PaymentInfo] = None


Loading

0 comments on commit 37f63e3

Please sign in to comment.