Merge branch 'stripe-neo' into 'main'
Replace deprecated Stripe Sources API with Stripe Checkout See merge request engineering/remarkbox/remarkbox!28
This commit is contained in:
commit
f4baebedc6
25 changed files with 1057 additions and 380 deletions
|
|
@ -173,9 +173,9 @@ To list paying customers, execute:
|
|||
|
||||
.. code-block:: sql
|
||||
|
||||
SELECT * FROM rb_pay_what_you_can
|
||||
INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id
|
||||
WHERE amount > 0 AND rb_user.stripe_id IS NOT NULL;
|
||||
SELECT * FROM rb_payment
|
||||
INNER JOIN rb_user ON rb_user.id = rb_payment.user_id
|
||||
WHERE status = 'completed';
|
||||
|
||||
Python Pyramid Shell
|
||||
==============================================
|
||||
|
|
|
|||
|
|
@ -89,10 +89,11 @@ app.theme = meta
|
|||
# choices enabled or disabled. defaults to disabled.
|
||||
#app.stand_alone_mode = disabled
|
||||
|
||||
# stripe: credit card storage and processing.
|
||||
# stripe: Stripe Checkout payment processing.
|
||||
# This syntax will automatically expand an ENV var of the same name.
|
||||
app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET}
|
||||
app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC}
|
||||
app.stripe.webhook_secret = ${REMARKBOX_APP_STRIPE_WEBHOOK_SECRET:-}
|
||||
|
||||
# slack: bot notifications.
|
||||
app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET}
|
||||
|
|
|
|||
|
|
@ -225,12 +225,12 @@ We should build a Remarkbox to matrix bridge. I bet it is a lot like working wit
|
|||
Sat Apr 3 10:40:05 PM EDT 2021
|
||||
=====================================
|
||||
|
||||
this is a useful SQL query to SELECT users who want to pay and also gave a credit card.
|
||||
this is a useful SQL query to SELECT users who have paid.
|
||||
|
||||
::
|
||||
SELECT * FROM rb_pay_what_you_can
|
||||
INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id
|
||||
WHERE amount > 0 and rb_user.stripe_id is not null;
|
||||
SELECT * FROM rb_payment
|
||||
INNER JOIN rb_user ON rb_user.id = rb_payment.user_id
|
||||
WHERE status = 'completed';
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -407,36 +407,6 @@ def main(global_config, **settings):
|
|||
and request.app_domain == request.namespace.name
|
||||
)
|
||||
|
||||
def add_stripe(request):
|
||||
"""Attach a stripe object with creds to request."""
|
||||
import stripe
|
||||
|
||||
stripe.api_key = request.app.get("stripe.secret")
|
||||
return stripe
|
||||
|
||||
def add_stripe_customer(request):
|
||||
if request.user:
|
||||
if not request.user.stripe_id:
|
||||
# create a new stripe customer.
|
||||
customer = request.stripe.Customer.create(email=request.user.email)
|
||||
request.user.stripe_id = customer.id
|
||||
request.dbsession.add(request.user)
|
||||
request.dbsession.flush()
|
||||
return request.stripe.Customer.retrieve(request.user.stripe_id)
|
||||
return None
|
||||
|
||||
def add_stripe_saved_cards(request):
|
||||
if request.user and request.user.stripe_id:
|
||||
return request.stripe_customer.sources
|
||||
return []
|
||||
|
||||
def add_stripe_active_card(request):
|
||||
if request.user and request.user.stripe_id:
|
||||
if request.stripe_customer.default_source:
|
||||
return request.stripe_customer.sources.retrieve(
|
||||
request.stripe_customer.default_source
|
||||
)
|
||||
return None
|
||||
|
||||
def add_avatar_size(request):
|
||||
"""Attach avatar size or default."""
|
||||
|
|
@ -579,10 +549,6 @@ def main(global_config, **settings):
|
|||
config.add_request_method(add_marketing_domain, "marketing_domain", reify=True)
|
||||
config.add_request_method(add_faq_home, "faq_home", reify=True)
|
||||
config.add_request_method(add_saas_home, "saas_home", reify=True)
|
||||
config.add_request_method(add_stripe, "stripe", reify=True)
|
||||
config.add_request_method(add_stripe_customer, "stripe_customer", reify=True)
|
||||
config.add_request_method(add_stripe_saved_cards, "stripe_saved_cards", reify=True)
|
||||
config.add_request_method(add_stripe_active_card, "stripe_active_card", reify=True)
|
||||
config.add_request_method(add_stand_alone_mode, "stand_alone_mode", reify=True)
|
||||
config.add_request_method(add_avatar_size, "avatar_size", reify=True)
|
||||
config.add_request_method(add_theme, "theme", reify=True)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ def timestamp_to_date_string(timestamp):
|
|||
return timestamp_to_datetime(timestamp).strftime("%b %d, %Y %I:%M %P")
|
||||
|
||||
|
||||
def timestamp_to_date(timestamp):
|
||||
"""Accepts a timestamp and returns a short date string (e.g., 'Dec 19, 2024')"""
|
||||
return timestamp_to_datetime(timestamp).strftime("%b %d, %Y")
|
||||
|
||||
|
||||
def timestamp_to_ago_string(timestamp):
|
||||
"""Accepts a timestamp and returns a human readable string"""
|
||||
return human(timestamp_to_datetime(timestamp), 2, abbreviate=True)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .watcher import *
|
|||
from .event import *
|
||||
from .notification import *
|
||||
from .pay_what_you_can import *
|
||||
from .payment import *
|
||||
|
||||
# run configure_mappers after defining all of the models to ensure
|
||||
# all relationships can be setup
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ CLASS_TO_TABLE = {
|
|||
"NodeEvent": "rb_node_event",
|
||||
"NodeEventNotification": "rb_node_event_notification",
|
||||
"PayWhatYouCan": "rb_pay_what_you_can",
|
||||
"Payment": "rb_payment",
|
||||
}
|
||||
|
||||
# node (threads), namespace (forum)
|
||||
|
|
|
|||
109
remarkbox/models/payment.py
Normal file
109
remarkbox/models/payment.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Payment model for tracking Stripe payments."""
|
||||
|
||||
from sqlalchemy import BigInteger, Column, Unicode, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
|
||||
|
||||
from remarkbox.lib import timestamp_to_date
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
class Payment(RBase, Base):
|
||||
"""
|
||||
Track payments made through Stripe Checkout.
|
||||
|
||||
Each payment record corresponds to a completed Stripe Checkout session.
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
|
||||
# Link to user who made the payment
|
||||
user_id = Column(UUIDType, foreign_key("User", "id"), index=True, nullable=False)
|
||||
|
||||
# Stripe session ID for reference
|
||||
stripe_session_id = Column(Unicode(128), unique=True, nullable=False, index=True)
|
||||
|
||||
# Payment type: pay_what_you_want, annual, top_up
|
||||
payment_type = Column(
|
||||
Enum("pay_what_you_want", "annual", "top_up", name="payment_type_enum"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Amount in cents
|
||||
amount_cents = Column(BigInteger, nullable=False)
|
||||
|
||||
# Duration in months (for annual/top_up payments)
|
||||
duration_months = Column(BigInteger, default=0, nullable=False)
|
||||
|
||||
# Payment status: pending, completed, failed, refunded
|
||||
status = Column(
|
||||
Enum("pending", "completed", "failed", "refunded", name="payment_status_enum"),
|
||||
default="pending",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
completed_timestamp = Column(BigInteger, nullable=True)
|
||||
|
||||
# Relationship to user
|
||||
user = relationship(
|
||||
argument="User",
|
||||
uselist=False,
|
||||
lazy="joined",
|
||||
back_populates="payments",
|
||||
)
|
||||
|
||||
def __init__(self, user, stripe_session_id, payment_type, amount_cents, duration_months=0):
|
||||
self.id = uuid.uuid1()
|
||||
self.user_id = user.id
|
||||
self.stripe_session_id = stripe_session_id
|
||||
self.payment_type = payment_type
|
||||
self.amount_cents = amount_cents
|
||||
self.duration_months = duration_months
|
||||
self.status = "pending"
|
||||
self.created_timestamp = now_timestamp()
|
||||
|
||||
def mark_completed(self):
|
||||
"""Mark payment as completed."""
|
||||
self.status = "completed"
|
||||
self.completed_timestamp = now_timestamp()
|
||||
|
||||
def mark_failed(self):
|
||||
"""Mark payment as failed."""
|
||||
self.status = "failed"
|
||||
|
||||
@property
|
||||
def amount_dollars(self):
|
||||
"""Return amount in dollars."""
|
||||
return self.amount_cents / 100.0
|
||||
|
||||
@property
|
||||
def human_created_date(self):
|
||||
"""Return human-readable creation date."""
|
||||
return timestamp_to_date(self.created_timestamp)
|
||||
|
||||
|
||||
def get_payment_by_session_id(dbsession, session_id):
|
||||
"""Get payment by Stripe session ID."""
|
||||
return (
|
||||
dbsession.query(Payment)
|
||||
.filter(Payment.stripe_session_id == session_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def create_payment(dbsession, user, stripe_session_id, payment_type, amount_cents, duration_months=0):
|
||||
"""Create a new payment record."""
|
||||
payment = Payment(
|
||||
user=user,
|
||||
stripe_session_id=stripe_session_id,
|
||||
payment_type=payment_type,
|
||||
amount_cents=amount_cents,
|
||||
duration_months=duration_months,
|
||||
)
|
||||
dbsession.add(payment)
|
||||
dbsession.flush()
|
||||
return payment
|
||||
|
|
@ -120,9 +120,6 @@ class User(RBase, Base):
|
|||
default='auto',
|
||||
nullable=False,
|
||||
)
|
||||
# example: cus_12345678AbCdEF but may be null.
|
||||
stripe_id = Column(Unicode(18), unique=True, nullable=True)
|
||||
|
||||
votes = relationship(argument="Vote", backref="user", order_by="desc(Vote.created)")
|
||||
|
||||
# lazy='dynamic' returns a query object instead of collection.
|
||||
|
|
@ -157,6 +154,15 @@ class User(RBase, Base):
|
|||
# 1-to-1 relationships.
|
||||
pay_what_you_can = relationship(argument="PayWhatYouCan", uselist=False, lazy="joined")
|
||||
|
||||
# Payment history
|
||||
payments = relationship(
|
||||
argument="Payment",
|
||||
lazy="dynamic",
|
||||
back_populates="user",
|
||||
order_by="desc(Payment.created_timestamp)",
|
||||
cascade="save-update, merge, delete",
|
||||
)
|
||||
|
||||
@property
|
||||
def node_watchers(self):
|
||||
return self.watchers.filter(Watcher.type == "node")
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ def includeme(config):
|
|||
config.add_route("embed-iframe", "/embed-iframe.txt")
|
||||
config.add_route("embed-iframe-min", "/embed-iframe-min.txt")
|
||||
|
||||
# stripe: credit card storage and processing.
|
||||
# stripe: payment processing via Stripe Checkout
|
||||
config.add_route("billing", "/billing")
|
||||
config.add_route("add-card", "/billing/add-card")
|
||||
config.add_route(
|
||||
"confirm-update-card", "/billing/confirm-update-card/{action}/{card_id}"
|
||||
)
|
||||
config.add_route("update-card", "/billing/update-card")
|
||||
config.add_route("pay-what-you-can", "/pay-what-you-can")
|
||||
config.add_route("create-checkout", "/billing/checkout")
|
||||
config.add_route("billing-success", "/billing/success")
|
||||
config.add_route("stripe-webhook", "/webhook/stripe")
|
||||
|
||||
# slack: bot notifications and oauth.
|
||||
config.add_route("oauth-slack", "/oauth/slack")
|
||||
|
|
|
|||
1
remarkbox/stripe/__init__.py
Normal file
1
remarkbox/stripe/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Stripe integration module
|
||||
138
remarkbox/stripe/checkout.py
Normal file
138
remarkbox/stripe/checkout.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
Stripe Checkout integration for Remarkbox payments.
|
||||
|
||||
Supports:
|
||||
- Pay What You Want (custom amount, one-time payment)
|
||||
- Annual subscription (yearly recurring via one-time payment with duration)
|
||||
- Top-up payments (extend subscription)
|
||||
"""
|
||||
|
||||
import stripe
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_stripe(api_key):
|
||||
"""Configure Stripe with API key."""
|
||||
stripe.api_key = api_key
|
||||
|
||||
|
||||
def create_checkout_session(
|
||||
amount_cents,
|
||||
payment_type,
|
||||
duration_months=1,
|
||||
email=None,
|
||||
success_url=None,
|
||||
cancel_url=None,
|
||||
metadata=None,
|
||||
):
|
||||
"""
|
||||
Create a Stripe Checkout session for payment.
|
||||
|
||||
Parameters:
|
||||
- amount_cents: Payment amount in cents (USD)
|
||||
- payment_type: "pay_what_you_want", "annual", or "top_up"
|
||||
- duration_months: For annual/top_up, number of months (default 1)
|
||||
- email: Optional customer email
|
||||
- success_url: URL to redirect on success (must include {CHECKOUT_SESSION_ID})
|
||||
- cancel_url: URL to redirect on cancel
|
||||
- metadata: Additional metadata to store with the session
|
||||
|
||||
Returns:
|
||||
Tuple of (session, error) - session object or None, and error message or None
|
||||
"""
|
||||
if amount_cents < 100: # Minimum $1.00
|
||||
return None, "Minimum payment amount is $1.00"
|
||||
|
||||
# Build product description based on payment type
|
||||
if payment_type == "pay_what_you_want":
|
||||
product_name = "Remarkbox - Pay What You Want"
|
||||
description = "Thank you for supporting Remarkbox!"
|
||||
elif payment_type == "annual":
|
||||
product_name = f"Remarkbox - {duration_months} Month Subscription"
|
||||
description = f"Access to Remarkbox for {duration_months} month(s)"
|
||||
elif payment_type == "top_up":
|
||||
product_name = f"Remarkbox - Top Up ({duration_months} months)"
|
||||
description = f"Extend your subscription by {duration_months} month(s)"
|
||||
else:
|
||||
return None, f"Invalid payment type: {payment_type}"
|
||||
|
||||
# Build session metadata
|
||||
session_metadata = {
|
||||
"payment_type": payment_type,
|
||||
"duration_months": str(duration_months),
|
||||
"amount_cents": str(amount_cents),
|
||||
}
|
||||
if metadata:
|
||||
session_metadata.update(metadata)
|
||||
|
||||
# Build session parameters
|
||||
session_params = {
|
||||
"mode": "payment",
|
||||
"payment_method_types": ["card"],
|
||||
"line_items": [
|
||||
{
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"product_data": {
|
||||
"name": product_name,
|
||||
"description": description,
|
||||
},
|
||||
"unit_amount": amount_cents,
|
||||
},
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
"metadata": session_metadata,
|
||||
}
|
||||
|
||||
# Add customer email if provided
|
||||
if email:
|
||||
session_params["customer_email"] = email
|
||||
|
||||
try:
|
||||
session = stripe.checkout.Session.create(**session_params)
|
||||
log.info(
|
||||
f"Created Stripe checkout session: {session.id}, "
|
||||
f"type={payment_type}, amount=${amount_cents/100:.2f}"
|
||||
)
|
||||
return session, None
|
||||
except stripe.error.StripeError as e:
|
||||
log.error(f"Stripe checkout creation failed: {e}")
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def retrieve_checkout_session(session_id):
|
||||
"""
|
||||
Retrieve a Stripe Checkout session by ID.
|
||||
|
||||
Returns:
|
||||
Tuple of (session, error)
|
||||
"""
|
||||
try:
|
||||
session = stripe.checkout.Session.retrieve(session_id)
|
||||
return session, None
|
||||
except stripe.error.StripeError as e:
|
||||
log.error(f"Failed to retrieve session {session_id}: {e}")
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def verify_webhook_signature(payload, sig_header, webhook_secret):
|
||||
"""
|
||||
Verify Stripe webhook signature.
|
||||
|
||||
Returns:
|
||||
Tuple of (event, error)
|
||||
"""
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
return event, None
|
||||
except ValueError as e:
|
||||
log.error(f"Invalid webhook payload: {e}")
|
||||
return None, "Invalid payload"
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
log.error(f"Invalid webhook signature: {e}")
|
||||
return None, "Invalid signature"
|
||||
34
remarkbox/templates/billing-success.j2
Normal file
34
remarkbox/templates/billing-success.j2
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{% extends request.base_funnel_template -%}
|
||||
|
||||
{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%}
|
||||
{% block content -%}
|
||||
|
||||
<center>
|
||||
<h4>{{ the_title }}</h4>
|
||||
</center>
|
||||
|
||||
<br>
|
||||
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<div style="font-size: 48px; color: #4CAF50;">✓</div>
|
||||
<h3>Thank You!</h3>
|
||||
|
||||
{% if payment %}
|
||||
<p>Your payment of <strong>${{ "%.2f" | format(payment.amount_dollars) }}</strong> has been received.</p>
|
||||
|
||||
{% if payment.payment_type == "annual" %}
|
||||
<p>Your annual subscription is now active.</p>
|
||||
{% elif payment.payment_type == "top_up" %}
|
||||
<p>Your subscription has been extended by {{ payment.duration_months }} month(s).</p>
|
||||
{% else %}
|
||||
<p>Your contribution helps keep Remarkbox running. We truly appreciate your support!</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Your payment has been processed successfully.</p>
|
||||
{% endif %}
|
||||
|
||||
<br>
|
||||
<a href="/billing" class="button green-button">Back to Billing</a>
|
||||
</div>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{% extends request.base_funnel_template -%}
|
||||
{%- import 'snippets/stripe.j2' as stripe with context -%}
|
||||
{% import 'snippets/forms.j2' as forms with context %}
|
||||
|
||||
{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%}
|
||||
|
|
@ -9,36 +8,131 @@
|
|||
<h4>{{ the_title }}</h4>
|
||||
</center>
|
||||
|
||||
<p>Support Remarkbox with a contribution. Set your preferences below, then pay when you're ready.</p>
|
||||
|
||||
<br>
|
||||
|
||||
{# Pay What You Can Preferences #}
|
||||
{{ forms.pay_what_you_can() }}
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
<label>Active Card</label>
|
||||
{{ stripe.active_card() }}
|
||||
{# Pay Now Button - uses saved preferences or custom amount #}
|
||||
<form method="post" action="/billing/checkout">
|
||||
<fieldset>
|
||||
<legend>Pay Now</legend>
|
||||
{% if request.user.pay_what_you_can and request.user.pay_what_you_can.amount %}
|
||||
<p>Pay your configured amount of <strong>${{ request.user.pay_what_you_can.amount }}</strong> now.</p>
|
||||
<input type="hidden" name="payment_type" value="pay_what_you_want">
|
||||
<input type="hidden" name="amount" value="{{ request.user.pay_what_you_can.amount }}">
|
||||
<input type="hidden" name="duration_months" value="{% if request.user.pay_what_you_can.frequency == 'yearly' %}12{% else %}0{% endif %}">
|
||||
{% include 'snippets/csrf.j2' %}
|
||||
<button type="submit" class="green-button">Pay ${{ request.user.pay_what_you_can.amount }} Now</button>
|
||||
{% else %}
|
||||
<p>Set your contribution amount above first, or enter a custom amount:</p>
|
||||
<input type="hidden" name="payment_type" value="pay_what_you_want">
|
||||
<input type="hidden" name="duration_months" value="0">
|
||||
<label for="custom-amount">Amount (USD)</label>
|
||||
<input type="text"
|
||||
id="custom-amount"
|
||||
name="amount"
|
||||
placeholder="$10.00"
|
||||
required
|
||||
style="width: 100%; max-width: 200px;">
|
||||
<br><br>
|
||||
{% include 'snippets/csrf.j2' %}
|
||||
<button type="submit" class="green-button">Pay Now</button>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<br>
|
||||
|
||||
{{ stripe.saved_cards() }}
|
||||
{# Annual Subscription #}
|
||||
<form method="post" action="/billing/checkout">
|
||||
<fieldset>
|
||||
<legend>Annual Subscription</legend>
|
||||
<p>Support Remarkbox with a yearly contribution.</p>
|
||||
|
||||
<label>New credit or debit card</label>
|
||||
<section class="new-card">
|
||||
<br>
|
||||
{{ stripe.new_card() }}
|
||||
</section>
|
||||
<input type="hidden" name="payment_type" value="annual">
|
||||
<input type="hidden" name="duration_months" value="12">
|
||||
|
||||
<label for="annual-amount">Annual Amount (USD)</label>
|
||||
<select id="annual-amount" name="amount" style="width: 100%; max-width: 200px;">
|
||||
<option value="36">$36/year ($3/month)</option>
|
||||
<option value="60">$60/year ($5/month)</option>
|
||||
<option value="120" selected>$120/year ($10/month)</option>
|
||||
<option value="240">$240/year ($20/month)</option>
|
||||
<option value="360">$360/year ($30/month)</option>
|
||||
</select>
|
||||
|
||||
<br><br>
|
||||
{% include 'snippets/csrf.j2' %}
|
||||
<button type="submit" class="green-button">Subscribe Annually</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
Or PayPal <a href="https://www.paypal.me/russellbal/" target="_blank">@russellbal</a>
|
||||
{# Top Up #}
|
||||
<form method="post" action="/billing/checkout">
|
||||
<fieldset>
|
||||
<legend>Top Up</legend>
|
||||
<p>Make an additional contribution anytime.</p>
|
||||
|
||||
<input type="hidden" name="payment_type" value="top_up">
|
||||
<input type="hidden" name="duration_months" value="0">
|
||||
|
||||
<label for="topup-amount">Amount (USD)</label>
|
||||
<input type="text"
|
||||
id="topup-amount"
|
||||
name="amount"
|
||||
placeholder="$25.00"
|
||||
required
|
||||
style="width: 100%; max-width: 200px;">
|
||||
|
||||
<br><br>
|
||||
{% include 'snippets/csrf.j2' %}
|
||||
<button type="submit" class="green-button">Top Up</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
<b>Thank you so much!</b>
|
||||
{# Payment History #}
|
||||
{% if payments %}
|
||||
<fieldset>
|
||||
<legend>Payment History</legend>
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left;">Date</th>
|
||||
<th style="text-align: left;">Type</th>
|
||||
<th style="text-align: right;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for payment in payments %}
|
||||
<tr>
|
||||
<td>{{ payment.human_created_date }}</td>
|
||||
<td>{{ payment.payment_type | replace("_", " ") | title }}</td>
|
||||
<td style="text-align: right;">${{ "%.2f" | format(payment.amount_dollars) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
<br>
|
||||
{% endif %}
|
||||
|
||||
<center>
|
||||
<p>
|
||||
<small>
|
||||
Payments are securely processed by <a href="https://stripe.com" target="_blank">Stripe</a>.
|
||||
<br>
|
||||
Or PayPal <a href="https://www.paypal.me/russellbal/" target="_blank">@russellbal</a>
|
||||
</small>
|
||||
</p>
|
||||
</center>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{% extends request.base_funnel_template -%}
|
||||
{%- import 'snippets/stripe.j2' as stripe with context -%}
|
||||
{% import 'snippets/forms.j2' as forms with context %}
|
||||
|
||||
{% block title %}Let's Rock | {{ request.domain }}{%- endblock -%}
|
||||
|
|
@ -116,14 +115,9 @@ There's no obligation to pay anything. : )
|
|||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
{% if request.stripe_active_card %}
|
||||
{{ stripe.active_card(change_card=True) }}
|
||||
{% else %}
|
||||
{{ stripe.new_card() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ request.link_prefix }}/billing" class="button green-button">Go to Billing</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
Or PayPal <a href="https://www.paypal.me/russellbal/" target="_blank">@russellbal</a>
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@
|
|||
{%- if request.user.authenticated %}
|
||||
<span class="avoidwrap"><li>{{ snippets.user_link(request.user) }} <a href="/log-out">[log out]</a></li></span>
|
||||
<li><a href="{{ request.link_prefix }}/u/settings">account settings</a></li>
|
||||
{% if request.user.stripe_id %}
|
||||
<li><a href="{{ request.link_prefix }}/billing">payment preferences</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ request.link_prefix }}/billing">billing</a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ request.app_url }}/join-or-log-in?return-to={{ request.path_url }}">join-or-log-in</a></li>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
{% macro display_card(card, actions=True, change_card=False) %}
|
||||
{%
|
||||
set brand_logos = {
|
||||
"Visa" : "https://js.stripe.com/v3/fingerprinted/img/visa-d6c6e0a636f7373e06d5fb896ad49475.svg",
|
||||
"MasterCard" : "https://js.stripe.com/v3/fingerprinted/img/mastercard-a96ee3841a5e1e28d05ed3f0f4da62b8.svg",
|
||||
"American Express" : "https://js.stripe.com/v3/fingerprinted/img/amex-edf6011de255d8a4c22904795c9d8770.svg",
|
||||
"Discover" : "https://js.stripe.com/v3/fingerprinted/img/discover-8f3d8fc6ef836da1fcac12c095ee6fb8.svg",
|
||||
"Diners Club" : "https://js.stripe.com/v3/fingerprinted/img/diners-fced9e136fd8c25f40a3e7b37a51dc1d.svg",
|
||||
"JCB" : "https://js.stripe.com/v3/fingerprinted/img/jcb-1b12d588a1e9465d4d9fb84a610f9136.svg",
|
||||
"UnionPay" : "https://js.stripe.com/v3/fingerprinted/img/unionpay-en-099cb6671310a54f640ac16d5f2a825c.svg",
|
||||
}
|
||||
%}
|
||||
|
||||
<section class="payment-card">
|
||||
<img src="{{ brand_logos[card.brand] }}" style="width: 66px; float: left; margin-right: 20px;">
|
||||
<b>{{ card.brand }}</b>
|
||||
ending in
|
||||
<b>{{ card.last4 }}</b>
|
||||
<br/>
|
||||
<span class="opacity-60">
|
||||
expiring {{ card.exp_month }}/{{ card.exp_year }}
|
||||
</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div style="margin-top: -12px;">
|
||||
{% if actions %}
|
||||
<a href="/billing/confirm-update-card/delete-card/{{ card.id }}"><button type="button">delete</button></a>
|
||||
<a href="/billing/confirm-update-card/make-card-active/{{ card.id }}" class="button button-small button-white"><button type="button">make active</button></a>
|
||||
{% endif %}
|
||||
{% if change_card %}
|
||||
<a href="/billing"><button type="button">change card</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro active_card(change_card=False) %}
|
||||
{% if request.stripe_active_card %}
|
||||
{{ display_card(request.stripe_active_card, actions=False, change_card=change_card) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro saved_cards() %}
|
||||
{% if request.stripe_saved_cards|length > 1 %}
|
||||
<label>Saved Cards</label>
|
||||
{% for card in request.stripe_saved_cards %}
|
||||
{% if card != request.stripe_active_card %}
|
||||
{{ display_card(card) }}
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro new_card() %}
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<form action="/billing/add-card" method="post" id="payment-form">
|
||||
{% include 'snippets/csrf.j2' %}
|
||||
<input type="hidden" name="return-to" value="{{ request.path_url }}">
|
||||
<div class="form-row">
|
||||
|
||||
<center>
|
||||
<div id="card-element">
|
||||
<!-- A Stripe Element will be inserted here. -->
|
||||
</div>
|
||||
</center>
|
||||
|
||||
|
||||
<center>
|
||||
<button class="green-button button-max-width" style="margin-bottom: 0px; margin-top: 8px;">🔒 Save Card</button>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://stripe.com/docs/security/stripe" target="_blank"><img src="https://stripe.com/img/about/logos/badge/outline-dark.svg" style="margin-top: 8px;"></a>
|
||||
|
||||
<!-- Used to display Element errors. -->
|
||||
<div id="card-errors" role="alert"></div>
|
||||
</center>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function stripeTokenHandler(token) {
|
||||
// Insert the token ID into the form so it gets submitted to the server
|
||||
var form = document.getElementById('payment-form');
|
||||
var hiddenInput = document.createElement('input');
|
||||
hiddenInput.setAttribute('type', 'hidden');
|
||||
hiddenInput.setAttribute('name', 'stripeToken');
|
||||
hiddenInput.setAttribute('value', token.id);
|
||||
form.appendChild(hiddenInput);
|
||||
|
||||
// Submit the form
|
||||
form.submit();
|
||||
}
|
||||
|
||||
var stripe;
|
||||
var elements;
|
||||
|
||||
$(function () {
|
||||
stripe = Stripe("{{ request.app.get("stripe.public") }}");
|
||||
elements = stripe.elements();
|
||||
|
||||
var card = elements.create('card');
|
||||
card.mount('#card-element');
|
||||
|
||||
card.addEventListener('change', function(event) {
|
||||
var displayError = document.getElementById('card-errors');
|
||||
if (event.error) {
|
||||
displayError.textContent = event.error.message;
|
||||
} else {
|
||||
displayError.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
var form = document.getElementById('payment-form');
|
||||
form.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
stripe.createToken(card).then(function(result) {
|
||||
if (result.error) {
|
||||
// Inform the customer that there was an error.
|
||||
var errorElement = document.getElementById('card-errors');
|
||||
errorElement.textContent = result.error.message;
|
||||
} else {
|
||||
// Send the token to your server.
|
||||
stripeTokenHandler(result.token);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{% extends request.base_funnel_template -%}
|
||||
{%- import 'snippets/stripe.j2' as stripe with context -%}
|
||||
{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%}
|
||||
{% block content -%}
|
||||
<h3>{{ the_title }}</h3>
|
||||
|
||||
<label>Confirmation: <span style="font-weight: normal;">are you sure?</span></label>
|
||||
|
||||
{{ stripe.display_card(card, actions=False) }}
|
||||
|
||||
<br/>
|
||||
|
||||
<div>
|
||||
<form action="/billing/update-card" method="post">
|
||||
{% include 'snippets/csrf.j2' %}
|
||||
<input type="hidden" name="action" value="{{ action }}">
|
||||
<input type="hidden" name="card_id" value="{{ card_id }}">
|
||||
<a href="/billing"><button type="button">Cancel</button></a>
|
||||
<button type="submit" class="{{ button_class }}">Yes, {{ action_human }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{% extends request.base_template -%}
|
||||
{%- import 'snippets/stripe.j2' as stripe with context -%}
|
||||
{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%}
|
||||
{% block content -%}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import unittest
|
||||
|
||||
import mock
|
||||
from unittest import mock
|
||||
|
||||
from remarkbox.models.user import User, is_user_name_valid
|
||||
|
||||
|
|
|
|||
286
remarkbox/tests/test_stripe.py
Normal file
286
remarkbox/tests/test_stripe.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Tests for Stripe Checkout integration."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, PropertyMock
|
||||
|
||||
from remarkbox.stripe.checkout import (
|
||||
configure_stripe,
|
||||
create_checkout_session,
|
||||
retrieve_checkout_session,
|
||||
verify_webhook_signature,
|
||||
)
|
||||
|
||||
from remarkbox.models.payment import Payment, get_payment_by_session_id, create_payment
|
||||
from remarkbox.models.user import User
|
||||
|
||||
|
||||
class TestStripeCheckoutModule(unittest.TestCase):
|
||||
"""Unit tests for stripe/checkout.py"""
|
||||
|
||||
def test_configure_stripe(self):
|
||||
"""Test that configure_stripe sets the API key."""
|
||||
with patch('remarkbox.stripe.checkout.stripe') as mock_stripe:
|
||||
configure_stripe("sk_test_123")
|
||||
self.assertEqual(mock_stripe.api_key, "sk_test_123")
|
||||
|
||||
def test_create_checkout_session_minimum_amount(self):
|
||||
"""Test that minimum amount is enforced."""
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=50, # Less than $1.00
|
||||
payment_type="pay_what_you_want",
|
||||
)
|
||||
self.assertIsNone(session)
|
||||
self.assertEqual(error, "Minimum payment amount is $1.00")
|
||||
|
||||
def test_create_checkout_session_invalid_payment_type(self):
|
||||
"""Test that invalid payment type returns error."""
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=1000,
|
||||
payment_type="invalid_type",
|
||||
)
|
||||
self.assertIsNone(session)
|
||||
self.assertIn("Invalid payment type", error)
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.checkout.Session.create')
|
||||
def test_create_checkout_session_pay_what_you_want(self, mock_create):
|
||||
"""Test creating a pay-what-you-want checkout session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "cs_test_123"
|
||||
mock_session.url = "https://checkout.stripe.com/pay/cs_test_123"
|
||||
mock_create.return_value = mock_session
|
||||
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=1000,
|
||||
payment_type="pay_what_you_want",
|
||||
email="test@example.com",
|
||||
success_url="https://example.com/success",
|
||||
cancel_url="https://example.com/cancel",
|
||||
)
|
||||
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(session.id, "cs_test_123")
|
||||
|
||||
# Verify the call was made with correct parameters
|
||||
call_args = mock_create.call_args
|
||||
self.assertEqual(call_args.kwargs["mode"], "payment")
|
||||
self.assertEqual(call_args.kwargs["payment_method_types"], ["card"])
|
||||
self.assertEqual(call_args.kwargs["customer_email"], "test@example.com")
|
||||
self.assertEqual(call_args.kwargs["metadata"]["payment_type"], "pay_what_you_want")
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.checkout.Session.create')
|
||||
def test_create_checkout_session_annual(self, mock_create):
|
||||
"""Test creating an annual subscription checkout session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "cs_test_annual"
|
||||
mock_create.return_value = mock_session
|
||||
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=12000, # $120
|
||||
payment_type="annual",
|
||||
duration_months=12,
|
||||
email="test@example.com",
|
||||
success_url="https://example.com/success",
|
||||
cancel_url="https://example.com/cancel",
|
||||
)
|
||||
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(session.id, "cs_test_annual")
|
||||
|
||||
call_args = mock_create.call_args
|
||||
self.assertEqual(call_args.kwargs["metadata"]["payment_type"], "annual")
|
||||
self.assertEqual(call_args.kwargs["metadata"]["duration_months"], "12")
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.checkout.Session.create')
|
||||
def test_create_checkout_session_top_up(self, mock_create):
|
||||
"""Test creating a top-up checkout session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "cs_test_topup"
|
||||
mock_create.return_value = mock_session
|
||||
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=2500, # $25
|
||||
payment_type="top_up",
|
||||
duration_months=0,
|
||||
success_url="https://example.com/success",
|
||||
cancel_url="https://example.com/cancel",
|
||||
)
|
||||
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(session.id, "cs_test_topup")
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.checkout.Session.create')
|
||||
def test_create_checkout_session_stripe_error(self, mock_create):
|
||||
"""Test handling Stripe errors during session creation."""
|
||||
import stripe
|
||||
mock_create.side_effect = stripe.error.StripeError("API error")
|
||||
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=1000,
|
||||
payment_type="pay_what_you_want",
|
||||
success_url="https://example.com/success",
|
||||
cancel_url="https://example.com/cancel",
|
||||
)
|
||||
|
||||
self.assertIsNone(session)
|
||||
self.assertIn("API error", error)
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.checkout.Session.retrieve')
|
||||
def test_retrieve_checkout_session_success(self, mock_retrieve):
|
||||
"""Test retrieving a checkout session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "cs_test_123"
|
||||
mock_session.payment_status = "paid"
|
||||
mock_retrieve.return_value = mock_session
|
||||
|
||||
session, error = retrieve_checkout_session("cs_test_123")
|
||||
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(session.id, "cs_test_123")
|
||||
self.assertEqual(session.payment_status, "paid")
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.checkout.Session.retrieve')
|
||||
def test_retrieve_checkout_session_not_found(self, mock_retrieve):
|
||||
"""Test retrieving a non-existent checkout session."""
|
||||
import stripe
|
||||
mock_retrieve.side_effect = stripe.error.StripeError("No such session")
|
||||
|
||||
session, error = retrieve_checkout_session("cs_invalid")
|
||||
|
||||
self.assertIsNone(session)
|
||||
self.assertIn("No such session", error)
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.Webhook.construct_event')
|
||||
def test_verify_webhook_signature_success(self, mock_construct):
|
||||
"""Test successful webhook signature verification."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "checkout.session.completed"
|
||||
mock_construct.return_value = mock_event
|
||||
|
||||
event, error = verify_webhook_signature(
|
||||
payload=b'{"test": "data"}',
|
||||
sig_header="sig_header",
|
||||
webhook_secret="whsec_test",
|
||||
)
|
||||
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(event.type, "checkout.session.completed")
|
||||
|
||||
@patch('remarkbox.stripe.checkout.stripe.Webhook.construct_event')
|
||||
def test_verify_webhook_signature_invalid(self, mock_construct):
|
||||
"""Test invalid webhook signature."""
|
||||
import stripe
|
||||
mock_construct.side_effect = stripe.error.SignatureVerificationError(
|
||||
"Invalid signature", "sig_header"
|
||||
)
|
||||
|
||||
event, error = verify_webhook_signature(
|
||||
payload=b'{"test": "data"}',
|
||||
sig_header="bad_sig",
|
||||
webhook_secret="whsec_test",
|
||||
)
|
||||
|
||||
self.assertIsNone(event)
|
||||
self.assertEqual(error, "Invalid signature")
|
||||
|
||||
|
||||
class TestPaymentModel(unittest.TestCase):
|
||||
"""Unit tests for Payment model."""
|
||||
|
||||
@patch("remarkbox.models.user.is_user_name_available", MagicMock(return_value=True))
|
||||
def setUp(self):
|
||||
self.user = User("test@example.com")
|
||||
self.user.id = "test-user-id-123"
|
||||
|
||||
def test_payment_creation(self):
|
||||
"""Test creating a Payment object."""
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
stripe_session_id="cs_test_123",
|
||||
payment_type="pay_what_you_want",
|
||||
amount_cents=1000,
|
||||
duration_months=0,
|
||||
)
|
||||
|
||||
self.assertEqual(payment.stripe_session_id, "cs_test_123")
|
||||
self.assertEqual(payment.payment_type, "pay_what_you_want")
|
||||
self.assertEqual(payment.amount_cents, 1000)
|
||||
self.assertEqual(payment.status, "pending")
|
||||
self.assertIsNotNone(payment.created_timestamp)
|
||||
self.assertIsNone(payment.completed_timestamp)
|
||||
|
||||
def test_payment_amount_dollars(self):
|
||||
"""Test amount_dollars property."""
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
stripe_session_id="cs_test_123",
|
||||
payment_type="pay_what_you_want",
|
||||
amount_cents=1250,
|
||||
duration_months=0,
|
||||
)
|
||||
|
||||
self.assertEqual(payment.amount_dollars, 12.50)
|
||||
|
||||
def test_payment_mark_completed(self):
|
||||
"""Test marking payment as completed."""
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
stripe_session_id="cs_test_123",
|
||||
payment_type="annual",
|
||||
amount_cents=12000,
|
||||
duration_months=12,
|
||||
)
|
||||
|
||||
self.assertEqual(payment.status, "pending")
|
||||
self.assertIsNone(payment.completed_timestamp)
|
||||
|
||||
payment.mark_completed()
|
||||
|
||||
self.assertEqual(payment.status, "completed")
|
||||
self.assertIsNotNone(payment.completed_timestamp)
|
||||
|
||||
def test_payment_mark_failed(self):
|
||||
"""Test marking payment as failed."""
|
||||
payment = Payment(
|
||||
user=self.user,
|
||||
stripe_session_id="cs_test_123",
|
||||
payment_type="top_up",
|
||||
amount_cents=2500,
|
||||
duration_months=0,
|
||||
)
|
||||
|
||||
payment.mark_failed()
|
||||
|
||||
self.assertEqual(payment.status, "failed")
|
||||
|
||||
|
||||
class TestPayWhatYouCanModel(unittest.TestCase):
|
||||
"""Unit tests for PayWhatYouCan model."""
|
||||
|
||||
@patch("remarkbox.models.user.is_user_name_available", MagicMock(return_value=True))
|
||||
def setUp(self):
|
||||
self.user = User("test@example.com")
|
||||
|
||||
def test_pay_what_you_can_creation(self):
|
||||
"""Test creating a PayWhatYouCan preference."""
|
||||
from remarkbox.models import PayWhatYouCan
|
||||
|
||||
pwc = PayWhatYouCan(self.user, "yearly", 100)
|
||||
|
||||
self.assertEqual(pwc.frequency, "yearly")
|
||||
self.assertEqual(pwc.amount, 100)
|
||||
# contributions defaults to 0 in DB but may be None before flush
|
||||
self.assertIn(pwc.contributions, [0, None])
|
||||
self.assertIsNotNone(pwc.created_timestamp)
|
||||
|
||||
def test_pay_what_you_can_update(self):
|
||||
"""Test updating PayWhatYouCan preferences."""
|
||||
from remarkbox.models import PayWhatYouCan
|
||||
|
||||
pwc = PayWhatYouCan(self.user, "once", 50)
|
||||
original_timestamp = pwc.updated_timestamp
|
||||
|
||||
pwc.update("yearly", 100)
|
||||
|
||||
self.assertEqual(pwc.frequency, "yearly")
|
||||
self.assertEqual(pwc.amount, 100)
|
||||
self.assertGreaterEqual(pwc.updated_timestamp, original_timestamp)
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import transaction
|
||||
import unittest
|
||||
import webtest
|
||||
import stripe
|
||||
|
||||
from remarkbox.models import (
|
||||
Node,
|
||||
|
|
@ -17,8 +16,8 @@ from remarkbox.lib.notify import deliver_scheduled_notifications
|
|||
|
||||
from pyramid.paster import get_appsettings
|
||||
|
||||
import mock
|
||||
from mock import patch, call
|
||||
from unittest import mock
|
||||
from unittest.mock import patch, call
|
||||
import re
|
||||
|
||||
try:
|
||||
|
|
@ -80,7 +79,7 @@ class UnauthenticatedFunctionalTests(FunctionalTests):
|
|||
def test_billing_redirects(self):
|
||||
redirect_res = self.testapp.get("/billing", status=302)
|
||||
res = redirect_res.follow()
|
||||
self.assertTrue(b"Thank you for helping us out, Please verify your email in the form below!" in res.body)
|
||||
self.assertTrue(b"Please verify your email to access billing." in res.body)
|
||||
|
||||
def test_user_settings_redirects(self):
|
||||
redirect_res = self.testapp.get("/u/settings", status=302)
|
||||
|
|
@ -158,8 +157,6 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
# Python 3.
|
||||
FunctionalTests.setUpClass.__func__(cls)
|
||||
|
||||
stripe.api_key = cls.settings["app.stripe.secret"]
|
||||
|
||||
def setUp(self):
|
||||
# create test_user1
|
||||
self.test_user1 = get_or_create_user_by_email(
|
||||
|
|
@ -195,9 +192,6 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
self.test_creds2 = ("test2@remarkbox.com", self.raw_otp2)
|
||||
|
||||
def _clean_up_test_user(self, user):
|
||||
if user.stripe_id:
|
||||
# delete remote test Customer object on Stripe's test API.
|
||||
stripe.Customer.retrieve(user.stripe_id).delete()
|
||||
self.dbsession.delete(user)
|
||||
|
||||
def tearDown(self):
|
||||
|
|
@ -288,72 +282,6 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
self.dbsession.refresh(namespace_request)
|
||||
self.assertTrue(namespace_request.verified)
|
||||
|
||||
## the only reason we need to patch is because of temporary operator email.
|
||||
#@patch("smtplib.SMTP")
|
||||
#def test_billing(self, mock_smtp):
|
||||
|
||||
# self._log_in_test_user(self.test_creds1)
|
||||
|
||||
# billing_response = self.testapp.post(
|
||||
# "/billing/add-card",
|
||||
# {
|
||||
# "email": "test@remarkbox.com",
|
||||
# "csrf_token": self.csrf,
|
||||
# "stripeToken": "tok_visa",
|
||||
# },
|
||||
# )
|
||||
|
||||
# self.dbsession.refresh(self.test_user1)
|
||||
# customer = stripe.Customer.retrieve(self.test_user1.stripe_id)
|
||||
# self.assertEqual(
|
||||
# customer.sources.retrieve(customer.default_source).brand, "Visa"
|
||||
# )
|
||||
|
||||
# self.testapp.post(
|
||||
# "/billing/add-card",
|
||||
# {
|
||||
# "email": "test@remarkbox.com",
|
||||
# "csrf_token": self.csrf,
|
||||
# "stripeToken": "tok_amex",
|
||||
# },
|
||||
# )
|
||||
|
||||
# for source in customer.sources.list():
|
||||
# if source.brand == "Visa":
|
||||
# visa = source
|
||||
# if source.brand == "American Express":
|
||||
# amex = source
|
||||
|
||||
# self.testapp.post(
|
||||
# "/billing/update-card",
|
||||
# {
|
||||
# "email": "test@remarkbox.com",
|
||||
# "csrf_token": self.csrf,
|
||||
# "action": "make-card-active",
|
||||
# "card_id": amex.id,
|
||||
# },
|
||||
# )
|
||||
|
||||
# customer = stripe.Customer.retrieve(self.test_user1.stripe_id)
|
||||
# self.assertEqual(
|
||||
# customer.sources.retrieve(customer.default_source).brand, "American Express"
|
||||
# )
|
||||
|
||||
# self.testapp.post(
|
||||
# "/billing/update-card",
|
||||
# {
|
||||
# "email": "test@remarkbox.com",
|
||||
# "csrf_token": self.csrf,
|
||||
# "action": "delete-card",
|
||||
# "card_id": amex.id,
|
||||
# },
|
||||
# )
|
||||
|
||||
# customer = stripe.Customer.retrieve(self.test_user1.stripe_id)
|
||||
# self.assertEqual(
|
||||
# customer.sources.retrieve(customer.default_source).brand, "Visa"
|
||||
# )
|
||||
|
||||
@patch("smtplib.SMTP")
|
||||
def test_notifications(self, mock_smtp):
|
||||
"""
|
||||
|
|
@ -476,3 +404,141 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
# make sure our daily notification was sent.
|
||||
self.assertEqual(notifications[0].frequency, "daily")
|
||||
self.assertTrue(notifications[0].sent)
|
||||
|
||||
def test_billing_page_loads(self):
|
||||
"""Test that the billing page loads for authenticated users."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
res = self.testapp.get("/billing", status=200)
|
||||
self.assertIn(b"Pay What You Can", res.body)
|
||||
self.assertIn(b"Annual Subscription", res.body)
|
||||
self.assertIn(b"Top Up", res.body)
|
||||
|
||||
def test_pay_what_you_can_preference(self):
|
||||
"""Test saving pay-what-you-can preferences."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
# Save preferences
|
||||
redirect_res = self.testapp.post(
|
||||
"/pay-what-you-can",
|
||||
{
|
||||
"frequency": "yearly",
|
||||
"amount": "50",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = redirect_res.follow()
|
||||
self.assertIn(b"Your contribution preferences have been saved", res.body)
|
||||
|
||||
# Verify billing page loads successfully
|
||||
billing_res = self.testapp.get("/billing", status=200)
|
||||
self.assertIn(b"Pay What You Can", billing_res.body)
|
||||
|
||||
def test_pay_what_you_can_update_preference(self):
|
||||
"""Test updating pay-what-you-can preferences."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
# Set initial preferences
|
||||
self.testapp.post(
|
||||
"/pay-what-you-can",
|
||||
{
|
||||
"frequency": "once",
|
||||
"amount": "25",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
)
|
||||
|
||||
# Update preferences
|
||||
redirect_res = self.testapp.post(
|
||||
"/pay-what-you-can",
|
||||
{
|
||||
"frequency": "yearly",
|
||||
"amount": "100",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = redirect_res.follow()
|
||||
self.assertIn(b"Your contribution preferences have been saved", res.body)
|
||||
|
||||
def test_pay_what_you_can_missing_fields(self):
|
||||
"""Test pay-what-you-can with missing fields."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
# Missing amount
|
||||
redirect_res = self.testapp.post(
|
||||
"/pay-what-you-can",
|
||||
{
|
||||
"frequency": "yearly",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = redirect_res.follow()
|
||||
self.assertIn(b"You must set both frequency and amount", res.body)
|
||||
|
||||
@patch("remarkbox.stripe.checkout.stripe.checkout.Session.create")
|
||||
def test_create_checkout_redirects_to_stripe(self, mock_create):
|
||||
"""Test that create-checkout redirects to Stripe."""
|
||||
mock_session = mock.MagicMock()
|
||||
mock_session.id = "cs_test_123"
|
||||
mock_session.url = "https://checkout.stripe.com/pay/cs_test_123"
|
||||
mock_create.return_value = mock_session
|
||||
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
redirect_res = self.testapp.post(
|
||||
"/billing/checkout",
|
||||
{
|
||||
"payment_type": "pay_what_you_want",
|
||||
"amount": "25",
|
||||
"duration_months": "0",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
|
||||
# Should redirect to Stripe Checkout
|
||||
self.assertIn("checkout.stripe.com", redirect_res.location)
|
||||
|
||||
def test_create_checkout_minimum_amount(self):
|
||||
"""Test that checkout enforces minimum amount."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
redirect_res = self.testapp.post(
|
||||
"/billing/checkout",
|
||||
{
|
||||
"payment_type": "pay_what_you_want",
|
||||
"amount": "0.50",
|
||||
"duration_months": "0",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = redirect_res.follow()
|
||||
self.assertIn(b"Minimum payment is $1.00", res.body)
|
||||
|
||||
def test_create_checkout_invalid_amount(self):
|
||||
"""Test checkout with invalid amount."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
redirect_res = self.testapp.post(
|
||||
"/billing/checkout",
|
||||
{
|
||||
"payment_type": "pay_what_you_want",
|
||||
"amount": "not-a-number",
|
||||
"duration_months": "0",
|
||||
"csrf_token": self.csrf,
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = redirect_res.follow()
|
||||
self.assertIn(b"Invalid amount specified", res.body)
|
||||
|
||||
def test_billing_success_missing_session(self):
|
||||
"""Test billing success without session_id."""
|
||||
self._log_in_test_user(self.test_creds1)
|
||||
|
||||
redirect_res = self.testapp.get("/billing/success", status=302)
|
||||
res = redirect_res.follow()
|
||||
self.assertIn(b"Missing session information", res.body)
|
||||
|
|
|
|||
|
|
@ -1,94 +1,229 @@
|
|||
from pyramid.view import view_config
|
||||
"""
|
||||
Stripe payment views for Remarkbox.
|
||||
|
||||
Supports:
|
||||
- Pay What You Want (custom amount, one-time payment)
|
||||
- Annual subscription (yearly payment with duration)
|
||||
- Top-up payments (extend subscription)
|
||||
"""
|
||||
|
||||
from pyramid.view import view_config
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPBadRequest
|
||||
from pyramid.response import Response
|
||||
|
||||
from remarkbox.views import user_required
|
||||
|
||||
from remarkbox.lib.mail import send_operator_email
|
||||
from remarkbox.stripe.checkout import (
|
||||
configure_stripe,
|
||||
create_checkout_session,
|
||||
retrieve_checkout_session,
|
||||
verify_webhook_signature,
|
||||
)
|
||||
from remarkbox.models import Payment, PayWhatYouCan, get_payment_by_session_id, create_payment
|
||||
|
||||
from remarkbox.models import PayWhatYouCan
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@view_config(route_name="billing", renderer="billing.j2")
|
||||
@user_required(
|
||||
flash_msg="Thank you for helping us out, Please verify your email in the form below!",
|
||||
flash_msg="Please verify your email to access billing.",
|
||||
flash_level="info",
|
||||
return_to_route_name="billing"
|
||||
return_to_route_name="billing",
|
||||
)
|
||||
def billing(request):
|
||||
return {"the_title": "Payment Preferences"}
|
||||
"""Display the billing/payment page."""
|
||||
# Get user's payment history
|
||||
payments = request.user.payments.filter(Payment.status == "completed").limit(10).all()
|
||||
|
||||
|
||||
@view_config(route_name="add-card")
|
||||
@user_required()
|
||||
def add_card(request):
|
||||
# try to get the return_to uri from posted parameters.
|
||||
return_to = request.params.get("return-to", "/billing")
|
||||
try:
|
||||
source = request.stripe_customer.sources.create(
|
||||
source=request.params.get("stripeToken")
|
||||
)
|
||||
request.stripe_customer.default_source = source
|
||||
request.session.flash(("You saved a new card.", "success"))
|
||||
# TODO: this is very temporary just as a stop gap for me to stay on top of new customers.
|
||||
send_operator_email(
|
||||
request,
|
||||
"A user (hopefully a new one!) added a new card to their Stripe customer account. Log into Stripe and follow up to close the sale!",
|
||||
)
|
||||
except request.stripe.error.CardError as e:
|
||||
body = e.json_body
|
||||
err = body.get("error", {})
|
||||
request.session.flash((err.get("message"), "error"))
|
||||
request.stripe_customer.save()
|
||||
return HTTPFound(return_to)
|
||||
|
||||
|
||||
@view_config(route_name="confirm-update-card", renderer="update-card.j2")
|
||||
@user_required()
|
||||
def confirm_update_card(request):
|
||||
card_id = request.matchdict.get("card_id")
|
||||
card = request.stripe_customer.sources.retrieve(card_id)
|
||||
action = request.matchdict.get("action")
|
||||
action_human = action.replace("-", " ")
|
||||
button_class = "red-button" if action == "delete-card" else "blue-button"
|
||||
return {
|
||||
"card": card,
|
||||
"card_id": card_id,
|
||||
"action": action,
|
||||
"action_human": action_human,
|
||||
"button_class": button_class,
|
||||
"the_title": action_human.title(),
|
||||
"the_title": "Billing",
|
||||
"payments": payments,
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="update-card")
|
||||
@user_required()
|
||||
def update_card(request):
|
||||
action = request.params.get("action", None)
|
||||
card_id = request.params.get("card_id")
|
||||
if action not in ["make-card-active", "delete-card"]:
|
||||
return HTTPBadRequest
|
||||
if "delete-card" == action:
|
||||
request.stripe_customer.sources.retrieve(card_id).delete()
|
||||
request.session.flash(("You deleted that card.", "success"))
|
||||
if "make-card-active" == action:
|
||||
request.stripe_customer.default_source = card_id
|
||||
request.session.flash(("You set the active card.", "success"))
|
||||
request.stripe_customer.save()
|
||||
return HTTPFound("/billing")
|
||||
|
||||
|
||||
@view_config(route_name="pay-what-you-can")
|
||||
@view_config(route_name="pay-what-you-can", request_method="POST")
|
||||
@user_required()
|
||||
def pay_what_you_can(request):
|
||||
"""Save user's pay-what-you-can preferences (frequency and amount)."""
|
||||
frequency = request.params.get("frequency", None)
|
||||
amount = request.params.get("amount", None)
|
||||
|
||||
if frequency is None or amount is None:
|
||||
request.session.flash(("You must put a value for both frequency and amount.", "error"))
|
||||
request.session.flash(("You must set both frequency and amount.", "error"))
|
||||
elif request.user.pay_what_you_can:
|
||||
request.user.pay_what_you_can.update(frequency, amount)
|
||||
request.session.flash(("You updated your contribution preferences.", "success"))
|
||||
request.session.flash(("Your contribution preferences have been saved.", "success"))
|
||||
else:
|
||||
request.user.pay_what_you_can = PayWhatYouCan(request.user, frequency, amount)
|
||||
request.session.flash(("You updated your contribution preferences.", "success"))
|
||||
request.session.flash(("Your contribution preferences have been saved.", "success"))
|
||||
|
||||
return HTTPFound("/billing")
|
||||
|
||||
|
||||
@view_config(route_name="create-checkout", request_method="POST")
|
||||
@user_required()
|
||||
def create_checkout(request):
|
||||
"""Create a Stripe Checkout session and redirect to it."""
|
||||
configure_stripe(request.app.get("stripe.secret"))
|
||||
|
||||
# Get form parameters
|
||||
payment_type = request.params.get("payment_type", "pay_what_you_want")
|
||||
amount_str = request.params.get("amount", "0")
|
||||
duration_str = request.params.get("duration_months", "12")
|
||||
|
||||
# Parse amount (convert dollars to cents)
|
||||
try:
|
||||
amount_dollars = float(amount_str.replace("$", "").replace(",", "").strip())
|
||||
amount_cents = int(amount_dollars * 100)
|
||||
except (ValueError, AttributeError):
|
||||
request.session.flash(("Invalid amount specified.", "error"))
|
||||
return HTTPFound("/billing")
|
||||
|
||||
# Parse duration
|
||||
try:
|
||||
duration_months = int(duration_str)
|
||||
except (ValueError, AttributeError):
|
||||
duration_months = 12
|
||||
|
||||
if amount_cents < 100:
|
||||
request.session.flash(("Minimum payment is $1.00.", "error"))
|
||||
return HTTPFound("/billing")
|
||||
|
||||
# Build URLs
|
||||
base_url = request.app.get("app_url", request.host_url)
|
||||
success_url = f"{base_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}"
|
||||
cancel_url = f"{base_url}/billing"
|
||||
|
||||
# Create checkout session
|
||||
session, error = create_checkout_session(
|
||||
amount_cents=amount_cents,
|
||||
payment_type=payment_type,
|
||||
duration_months=duration_months,
|
||||
email=request.user.email,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
metadata={"user_id": str(request.user.id)},
|
||||
)
|
||||
|
||||
if error:
|
||||
log.error(f"Checkout creation failed for user {request.user.id}: {error}")
|
||||
request.session.flash((f"Payment error: {error}", "error"))
|
||||
return HTTPFound("/billing")
|
||||
|
||||
# Create pending payment record
|
||||
create_payment(
|
||||
dbsession=request.dbsession,
|
||||
user=request.user,
|
||||
stripe_session_id=session.id,
|
||||
payment_type=payment_type,
|
||||
amount_cents=amount_cents,
|
||||
duration_months=duration_months,
|
||||
)
|
||||
|
||||
# Redirect to Stripe Checkout
|
||||
return HTTPFound(session.url)
|
||||
|
||||
|
||||
@view_config(route_name="billing-success", renderer="billing-success.j2")
|
||||
@user_required()
|
||||
def billing_success(request):
|
||||
"""Handle successful payment return from Stripe."""
|
||||
configure_stripe(request.app.get("stripe.secret"))
|
||||
|
||||
session_id = request.params.get("session_id")
|
||||
if not session_id:
|
||||
request.session.flash(("Missing session information.", "error"))
|
||||
return HTTPFound("/billing")
|
||||
|
||||
# Retrieve the session from Stripe
|
||||
session, error = retrieve_checkout_session(session_id)
|
||||
if error:
|
||||
log.error(f"Failed to retrieve session {session_id}: {error}")
|
||||
request.session.flash(("Could not verify payment.", "error"))
|
||||
return HTTPFound("/billing")
|
||||
|
||||
# Check payment status
|
||||
if session.payment_status != "paid":
|
||||
log.warning(f"Session {session_id} not paid: {session.payment_status}")
|
||||
request.session.flash(("Payment not completed.", "error"))
|
||||
return HTTPFound("/billing")
|
||||
|
||||
# Update payment record
|
||||
payment = get_payment_by_session_id(request.dbsession, session_id)
|
||||
if payment and payment.status == "pending":
|
||||
payment.mark_completed()
|
||||
request.dbsession.add(payment)
|
||||
|
||||
# Send notification to operator
|
||||
send_operator_email(
|
||||
request,
|
||||
f"New payment received! User: {request.user.email}, "
|
||||
f"Amount: ${payment.amount_cents/100:.2f}, Type: {payment.payment_type}",
|
||||
)
|
||||
|
||||
return {
|
||||
"the_title": "Payment Successful",
|
||||
"session": session,
|
||||
"payment": payment,
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="stripe-webhook", request_method="POST")
|
||||
def stripe_webhook(request):
|
||||
"""Handle Stripe webhook events."""
|
||||
webhook_secret = request.app.get("stripe.webhook_secret")
|
||||
if not webhook_secret:
|
||||
log.error("Stripe webhook secret not configured")
|
||||
return Response(status=500, json_body={"error": "Webhook not configured"})
|
||||
|
||||
configure_stripe(request.app.get("stripe.secret"))
|
||||
|
||||
# Get the raw body and signature
|
||||
payload = request.body
|
||||
sig_header = request.headers.get("Stripe-Signature")
|
||||
|
||||
if not sig_header:
|
||||
return Response(status=400, json_body={"error": "Missing signature"})
|
||||
|
||||
# Verify webhook signature
|
||||
event, error = verify_webhook_signature(payload, sig_header, webhook_secret)
|
||||
if error:
|
||||
log.error(f"Webhook signature verification failed: {error}")
|
||||
return Response(status=400, json_body={"error": error})
|
||||
|
||||
log.info(f"Received Stripe webhook: {event.type}")
|
||||
|
||||
# Handle the event
|
||||
if event.type == "checkout.session.completed":
|
||||
session = event.data.object
|
||||
_handle_checkout_completed(request.dbsession, session)
|
||||
elif event.type == "payment_intent.payment_failed":
|
||||
payment_intent = event.data.object
|
||||
log.warning(f"Payment failed: {payment_intent.id}")
|
||||
else:
|
||||
log.debug(f"Unhandled webhook event type: {event.type}")
|
||||
|
||||
return Response(status=200, json_body={"received": True})
|
||||
|
||||
|
||||
def _handle_checkout_completed(dbsession, session):
|
||||
"""Process a completed checkout session from webhook."""
|
||||
session_id = session.id
|
||||
|
||||
if session.payment_status != "paid":
|
||||
log.info(f"Session {session_id} not paid yet: {session.payment_status}")
|
||||
return
|
||||
|
||||
# Find and update the payment record
|
||||
payment = get_payment_by_session_id(dbsession, session_id)
|
||||
if payment:
|
||||
if payment.status == "pending":
|
||||
payment.mark_completed()
|
||||
dbsession.add(payment)
|
||||
log.info(f"Payment {payment.id} marked as completed via webhook")
|
||||
else:
|
||||
log.info(f"Payment {payment.id} already processed: {payment.status}")
|
||||
else:
|
||||
log.warning(f"No payment record found for session {session_id}")
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ requests[security]
|
|||
# request library pins this dependency too low.
|
||||
# idna==2.6
|
||||
|
||||
# credit card storage and processing.
|
||||
stripe==3.5.0
|
||||
# Stripe Checkout payment processing.
|
||||
stripe>=5.0.0
|
||||
|
||||
# slack: bot notifications.
|
||||
slacker
|
||||
|
|
|
|||
3
test.ini
3
test.ini
|
|
@ -76,10 +76,11 @@ app.theme = meta
|
|||
# related to billing and registering Namespaces. Defaults to False
|
||||
#app.stand_alone_mode = enabled
|
||||
|
||||
# stripe: credit card storage and processing.
|
||||
# stripe: Stripe Checkout payment processing.
|
||||
# This syntax will automatically expand an ENV var of the same name.
|
||||
app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET}
|
||||
app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC}
|
||||
app.stripe.webhook_secret = ${REMARKBOX_APP_STRIPE_WEBHOOK_SECRET:-}
|
||||
|
||||
# slack: bot notifications.
|
||||
app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue