- Replace dropdown tier selection with attractive visual buttons - Add tier-button CSS with hover effects and selection states - Remove 'or pay by card' divider from subscription form for cleaner UI - Update JavaScript to handle tier button selection events - Fix Stripe module import conflict by renaming stripe directory to stripe_config - Add responsive grid layout for tier buttons on mobile devices
308 lines
10 KiB
Python
308 lines
10 KiB
Python
import os
|
|
from flask import Flask, request, jsonify, render_template
|
|
from dotenv import load_dotenv
|
|
import stripe
|
|
import json
|
|
from datetime import datetime
|
|
|
|
load_dotenv('stripe_config/.env')
|
|
|
|
app = Flask(__name__)
|
|
|
|
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
|
PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY")
|
|
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "GBP")
|
|
|
|
# In-memory supporter storage (in production, use a proper database)
|
|
supporters = []
|
|
|
|
# Subscription tiers - UPDATE THESE WITH YOUR REAL STRIPE PRICE IDs
|
|
SUBSCRIPTION_TIERS = {
|
|
"bronze": {
|
|
"price_id": "price_1SFdA1H7p78X3gVbFafKga1f", # Bronze tier price ID from Stripe Dashboard
|
|
"amount": 2.00,
|
|
"currency": "GBP",
|
|
"interval": "month",
|
|
"name": "Bronze",
|
|
"description": "Essential support that makes a difference"
|
|
},
|
|
"silver": {
|
|
"price_id": "price_1SFdD4H7p78X3gVb0ENAfHNZ", # Silver tier price ID from Stripe Dashboard
|
|
"amount": 5.00,
|
|
"currency": "GBP",
|
|
"interval": "month",
|
|
"name": "Silver",
|
|
"description": "Enhanced support with greater impact"
|
|
},
|
|
"gold": {
|
|
"price_id": "price_1SFdFkH7p78X3gVbLfjZvKIW", # Gold tier price ID from Stripe Dashboard
|
|
"amount": 10.00,
|
|
"currency": "GBP",
|
|
"interval": "month",
|
|
"name": "Gold",
|
|
"description": "Premium support for maximum impact"
|
|
},
|
|
"diamond": {
|
|
"price_id": "price_1SFdHFH7p78X3gVbN7UdxkPa", # Diamond tier price ID from Stripe Dashboard
|
|
"amount": 25.00,
|
|
"currency": "GBP",
|
|
"interval": "month",
|
|
"name": "Diamond",
|
|
"description": "Ultimate support for our biggest champions"
|
|
}
|
|
}
|
|
|
|
# In-memory customer storage (in production, use a proper database)
|
|
customers = []
|
|
subscriptions = []
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template("index.html")
|
|
|
|
@app.route("/config")
|
|
def config():
|
|
return jsonify({"publishableKey": PUBLISHABLE_KEY})
|
|
|
|
@app.route("/create-payment-intent", methods=["POST"])
|
|
def create_payment():
|
|
data = request.get_json()
|
|
amount = int(float(data["amount"]) * 100) # convert to cents/pence
|
|
currency = data.get("currency", DEFAULT_CURRENCY).lower()
|
|
supporter_name = data.get("supporterName", "").strip()
|
|
|
|
intent = stripe.PaymentIntent.create(
|
|
amount=amount,
|
|
currency=currency,
|
|
automatic_payment_methods={
|
|
"enabled": True,
|
|
"allow_redirects": "never" # Keep users on the page
|
|
},
|
|
metadata={
|
|
"source": "donation_page",
|
|
"currency": currency,
|
|
"supporter_name": supporter_name if supporter_name else "Anonymous"
|
|
}
|
|
)
|
|
return jsonify({"clientSecret": intent.client_secret})
|
|
|
|
@app.route("/add-supporter", methods=["POST"])
|
|
def add_supporter():
|
|
data = request.get_json()
|
|
supporter_name = data.get("name", "Anonymous").strip()
|
|
amount = data.get("amount", 0)
|
|
currency = data.get("currency", DEFAULT_CURRENCY)
|
|
|
|
if supporter_name and supporter_name != "Anonymous":
|
|
# Limit name length and sanitize
|
|
supporter_name = supporter_name[:30]
|
|
|
|
supporter = {
|
|
"name": supporter_name,
|
|
"amount": amount,
|
|
"currency": currency,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"time_ago": "just now"
|
|
}
|
|
|
|
# Add to beginning of list and keep only last 50
|
|
supporters.insert(0, supporter)
|
|
supporters[:] = supporters[:50]
|
|
|
|
return jsonify({"success": True})
|
|
|
|
return jsonify({"success": False})
|
|
|
|
@app.route("/supporters", methods=["GET"])
|
|
def get_supporters():
|
|
# Update time_ago for each supporter
|
|
now = datetime.now()
|
|
for supporter in supporters:
|
|
try:
|
|
timestamp = datetime.fromisoformat(supporter["timestamp"])
|
|
diff = now - timestamp
|
|
|
|
if diff.days > 0:
|
|
supporter["time_ago"] = f"{diff.days}d ago"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
supporter["time_ago"] = f"{hours}h ago"
|
|
elif diff.seconds > 60:
|
|
minutes = diff.seconds // 60
|
|
supporter["time_ago"] = f"{minutes}m ago"
|
|
else:
|
|
supporter["time_ago"] = "just now"
|
|
except:
|
|
supporter["time_ago"] = "recently"
|
|
|
|
return jsonify({"supporters": supporters[:20]}) # Return last 20
|
|
|
|
@app.route("/subscription-tiers", methods=["GET"])
|
|
def get_subscription_tiers():
|
|
"""Get available subscription tiers"""
|
|
return jsonify({"tiers": SUBSCRIPTION_TIERS})
|
|
|
|
@app.route("/create-subscription", methods=["POST"])
|
|
def create_subscription():
|
|
"""Create a new subscription - NOTE: Requires real Stripe Price IDs"""
|
|
data = request.get_json()
|
|
tier_id = data.get("tier_id")
|
|
email = data.get("email")
|
|
name = data.get("name", "").strip()
|
|
|
|
if tier_id not in SUBSCRIPTION_TIERS:
|
|
return jsonify({"error": "Invalid subscription tier"}), 400
|
|
|
|
if not email:
|
|
return jsonify({"error": "Email is required for subscriptions"}), 400
|
|
|
|
# Check if price IDs are still placeholders
|
|
price_id = SUBSCRIPTION_TIERS[tier_id]["price_id"]
|
|
if price_id.startswith("price_1234567890"):
|
|
return jsonify({
|
|
"error": "Subscription functionality requires setting up real Stripe Price IDs. "
|
|
"Please create products and prices in your Stripe Dashboard first."
|
|
}), 400
|
|
|
|
try:
|
|
# Create or retrieve customer
|
|
customer = stripe.Customer.create(
|
|
email=email,
|
|
name=name if name else None,
|
|
metadata={
|
|
"source": "donation_subscription"
|
|
}
|
|
)
|
|
|
|
# Create subscription
|
|
subscription = stripe.Subscription.create(
|
|
customer=customer.id,
|
|
items=[{"price": price_id}],
|
|
payment_behavior="default_incomplete",
|
|
payment_settings={"save_default_payment_method": "on_subscription"},
|
|
expand=["latest_invoice.payment_intent"],
|
|
)
|
|
|
|
# Store customer and subscription data
|
|
customer_data = {
|
|
"id": customer.id,
|
|
"email": email,
|
|
"name": name,
|
|
"created": datetime.now().isoformat()
|
|
}
|
|
customers.append(customer_data)
|
|
|
|
subscription_data = {
|
|
"id": subscription.id,
|
|
"customer_id": customer.id,
|
|
"tier_id": tier_id,
|
|
"status": subscription.status,
|
|
"created": datetime.now().isoformat()
|
|
}
|
|
subscriptions.append(subscription_data)
|
|
|
|
return jsonify({
|
|
"subscriptionId": subscription.id,
|
|
"clientSecret": subscription.latest_invoice.payment_intent.client_secret
|
|
})
|
|
|
|
except Exception as e:
|
|
# Catch all exceptions since IDE might not recognize stripe error classes
|
|
error_msg = str(e)
|
|
if "No such price" in error_msg:
|
|
return jsonify({"error": "Invalid price ID. Please check your Stripe Dashboard."}), 400
|
|
return jsonify({"error": f"Subscription creation failed: {error_msg}"}), 400
|
|
|
|
@app.route("/cancel-subscription", methods=["POST"])
|
|
def cancel_subscription():
|
|
"""Cancel a subscription"""
|
|
data = request.get_json()
|
|
subscription_id = data.get("subscription_id")
|
|
|
|
if not subscription_id:
|
|
return jsonify({"error": "Subscription ID is required"}), 400
|
|
|
|
try:
|
|
# Cancel the subscription at period end
|
|
subscription = stripe.Subscription.modify(
|
|
subscription_id,
|
|
cancel_at_period_end=True
|
|
)
|
|
|
|
# Update local storage
|
|
for sub in subscriptions:
|
|
if sub["id"] == subscription_id:
|
|
sub["status"] = "cancel_at_period_end"
|
|
sub["cancelled_at"] = datetime.now().isoformat()
|
|
break
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"message": "Subscription will be cancelled at the end of the current billing period"
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({"error": f"Cancellation failed: {str(e)}"}), 400
|
|
|
|
@app.route("/webhook", methods=["POST"])
|
|
def stripe_webhook():
|
|
"""Handle Stripe webhooks"""
|
|
payload = request.data
|
|
sig_header = request.headers.get('Stripe-Signature')
|
|
endpoint_secret = os.getenv('STRIPE_WEBHOOK_SECRET')
|
|
|
|
if not endpoint_secret:
|
|
return jsonify({"error": "Webhook secret not configured"}), 400
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
|
|
except ValueError:
|
|
return jsonify({"error": "Invalid payload"}), 400
|
|
except Exception as signature_error:
|
|
if "signature" in str(signature_error).lower():
|
|
return jsonify({"error": "Invalid signature"}), 400
|
|
return jsonify({"error": "Webhook processing failed"}), 400
|
|
|
|
# Handle the event
|
|
if event['type'] == 'invoice.payment_succeeded':
|
|
subscription = event['data']['object']['subscription']
|
|
customer_id = event['data']['object']['customer']
|
|
|
|
# Update subscription status
|
|
for sub in subscriptions:
|
|
if sub["id"] == subscription:
|
|
sub["status"] = "active"
|
|
sub["last_payment"] = datetime.now().isoformat()
|
|
break
|
|
|
|
print(f"Payment succeeded for subscription {subscription}")
|
|
|
|
elif event['type'] == 'invoice.payment_failed':
|
|
subscription = event['data']['object']['subscription']
|
|
|
|
# Update subscription status
|
|
for sub in subscriptions:
|
|
if sub["id"] == subscription:
|
|
sub["status"] = "past_due"
|
|
sub["last_failed_payment"] = datetime.now().isoformat()
|
|
break
|
|
|
|
print(f"Payment failed for subscription {subscription}")
|
|
|
|
elif event['type'] == 'customer.subscription.deleted':
|
|
subscription_id = event['data']['object']['id']
|
|
|
|
# Update subscription status
|
|
for sub in subscriptions:
|
|
if sub["id"] == subscription_id:
|
|
sub["status"] = "cancelled"
|
|
sub["cancelled_at"] = datetime.now().isoformat()
|
|
break
|
|
|
|
print(f"Subscription {subscription_id} was cancelled")
|
|
|
|
return jsonify({"success": True})
|
|
|
|
if __name__ == "__main__":
|
|
app.run(port=4242, debug=True)
|