diff --git a/README.rst b/README.rst
index 7442d24..8a4ce67 100644
--- a/README.rst
+++ b/README.rst
@@ -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
==============================================
diff --git a/development.ini b/development.ini
index 6fa67c3..3da0494 100644
--- a/development.ini
+++ b/development.ini
@@ -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}
diff --git a/journal.rst b/journal.rst
index 03f7a53..7ac8dc6 100644
--- a/journal.rst
+++ b/journal.rst
@@ -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';
diff --git a/remarkbox/__init__.py b/remarkbox/__init__.py
index 863097f..0927803 100644
--- a/remarkbox/__init__.py
+++ b/remarkbox/__init__.py
@@ -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)
diff --git a/remarkbox/lib/__init__.py b/remarkbox/lib/__init__.py
index dc3e25e..f20d549 100644
--- a/remarkbox/lib/__init__.py
+++ b/remarkbox/lib/__init__.py
@@ -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)
diff --git a/remarkbox/models/__init__.py b/remarkbox/models/__init__.py
index dfef524..1e9e229 100644
--- a/remarkbox/models/__init__.py
+++ b/remarkbox/models/__init__.py
@@ -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
diff --git a/remarkbox/models/meta.py b/remarkbox/models/meta.py
index 5a067a6..80fc32c 100644
--- a/remarkbox/models/meta.py
+++ b/remarkbox/models/meta.py
@@ -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)
diff --git a/remarkbox/models/payment.py b/remarkbox/models/payment.py
new file mode 100644
index 0000000..674fde8
--- /dev/null
+++ b/remarkbox/models/payment.py
@@ -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
diff --git a/remarkbox/models/user.py b/remarkbox/models/user.py
index 1e194d3..a1da0f2 100644
--- a/remarkbox/models/user.py
+++ b/remarkbox/models/user.py
@@ -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")
diff --git a/remarkbox/routes.py b/remarkbox/routes.py
index 7fd724b..725032b 100644
--- a/remarkbox/routes.py
+++ b/remarkbox/routes.py
@@ -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")
diff --git a/remarkbox/stripe/__init__.py b/remarkbox/stripe/__init__.py
new file mode 100644
index 0000000..36d7e0f
--- /dev/null
+++ b/remarkbox/stripe/__init__.py
@@ -0,0 +1 @@
+# Stripe integration module
diff --git a/remarkbox/stripe/checkout.py b/remarkbox/stripe/checkout.py
new file mode 100644
index 0000000..93e709e
--- /dev/null
+++ b/remarkbox/stripe/checkout.py
@@ -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"
diff --git a/remarkbox/templates/billing-success.j2 b/remarkbox/templates/billing-success.j2
new file mode 100644
index 0000000..105ec15
--- /dev/null
+++ b/remarkbox/templates/billing-success.j2
@@ -0,0 +1,34 @@
+{% extends request.base_funnel_template -%}
+
+{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%}
+{% block content -%}
+
+
+{{ the_title }}
+
+
+
+
+
+
✓
+
Thank You!
+
+ {% if payment %}
+
Your payment of ${{ "%.2f" | format(payment.amount_dollars) }} has been received.
+
+ {% if payment.payment_type == "annual" %}
+
Your annual subscription is now active.
+ {% elif payment.payment_type == "top_up" %}
+
Your subscription has been extended by {{ payment.duration_months }} month(s).
+ {% else %}
+
Your contribution helps keep Remarkbox running. We truly appreciate your support!
+ {% endif %}
+ {% else %}
+
Your payment has been processed successfully.
+ {% endif %}
+
+
+
Back to Billing
+
+
+{%- endblock -%}
diff --git a/remarkbox/templates/billing.j2 b/remarkbox/templates/billing.j2
index 9d6fa61..d681167 100644
--- a/remarkbox/templates/billing.j2
+++ b/remarkbox/templates/billing.j2
@@ -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 @@
{{ the_title }}
+Support Remarkbox with a contribution. Set your preferences below, then pay when you're ready.
+
+{# Pay What You Can Preferences #}
{{ forms.pay_what_you_can() }}
-
-
-{{ stripe.active_card() }}
+{# Pay Now Button - uses saved preferences or custom amount #}
+
-{{ stripe.saved_cards() }}
+{# Annual Subscription #}
+
-
-Or PayPal @russellbal
+{# Top Up #}
+
-
-Thank you so much!
+{# Payment History #}
+{% if payments %}
+
+
+{% endif %}
+
+
+
+
+ Payments are securely processed by Stripe.
+
+ Or PayPal @russellbal
+
+
+
-
-
{%- endblock -%}
diff --git a/remarkbox/templates/setup-namespace.j2 b/remarkbox/templates/setup-namespace.j2
index 871d502..ab63268 100644
--- a/remarkbox/templates/setup-namespace.j2
+++ b/remarkbox/templates/setup-namespace.j2
@@ -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. : )
-
-{% if request.stripe_active_card %}
- {{ stripe.active_card(change_card=True) }}
-{% else %}
- {{ stripe.new_card() }}
-{% endif %}
-
+Go to Billing
+
Or PayPal @russellbal
diff --git a/remarkbox/templates/snippets/phone-menu.j2 b/remarkbox/templates/snippets/phone-menu.j2
index daa302b..096d4e4 100644
--- a/remarkbox/templates/snippets/phone-menu.j2
+++ b/remarkbox/templates/snippets/phone-menu.j2
@@ -14,9 +14,7 @@
{%- if request.user.authenticated %}
{{ snippets.user_link(request.user) }} [log out]
account settings
- {% if request.user.stripe_id %}
- payment preferences
- {% endif %}
+ billing
{% else %}
join-or-log-in
{% endif %}
diff --git a/remarkbox/templates/snippets/stripe.j2 b/remarkbox/templates/snippets/stripe.j2
deleted file mode 100644
index a7b5bc5..0000000
--- a/remarkbox/templates/snippets/stripe.j2
+++ /dev/null
@@ -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",
- }
- %}
-
-
-
- {{ card.brand }}
- ending in
- {{ card.last4 }}
-
-
- expiring {{ card.exp_month }}/{{ card.exp_year }}
-
-
-
-
-
-{% 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 %}
-
- {% for card in request.stripe_saved_cards %}
- {% if card != request.stripe_active_card %}
- {{ display_card(card) }}
-
- {% endif %}
- {% endfor %}
-
- {% endif %}
-{% endmacro %}
-
-{% macro new_card() %}
-
-
-
-
-{% endmacro %}
diff --git a/remarkbox/templates/update-card.j2 b/remarkbox/templates/update-card.j2
deleted file mode 100644
index 48d6ac1..0000000
--- a/remarkbox/templates/update-card.j2
+++ /dev/null
@@ -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 -%}
-{{ the_title }}
-
-
-
-{{ stripe.display_card(card, actions=False) }}
-
-
-
-
-
-{%- endblock -%}
diff --git a/remarkbox/templates/user-settings.j2 b/remarkbox/templates/user-settings.j2
index 5e89141..ad63203 100644
--- a/remarkbox/templates/user-settings.j2
+++ b/remarkbox/templates/user-settings.j2
@@ -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 -%}
diff --git a/remarkbox/tests/test_models.py b/remarkbox/tests/test_models.py
index b5ffda9..3be883e 100644
--- a/remarkbox/tests/test_models.py
+++ b/remarkbox/tests/test_models.py
@@ -1,6 +1,6 @@
import unittest
-import mock
+from unittest import mock
from remarkbox.models.user import User, is_user_name_valid
diff --git a/remarkbox/tests/test_stripe.py b/remarkbox/tests/test_stripe.py
new file mode 100644
index 0000000..025ba49
--- /dev/null
+++ b/remarkbox/tests/test_stripe.py
@@ -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)
diff --git a/remarkbox/tests/test_views.py b/remarkbox/tests/test_views.py
index 65e3a72..49c2cfb 100644
--- a/remarkbox/tests/test_views.py
+++ b/remarkbox/tests/test_views.py
@@ -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)
diff --git a/remarkbox/views/authenticated/stripe.py b/remarkbox/views/authenticated/stripe.py
index 66fa01d..fe7c799 100644
--- a/remarkbox/views/authenticated/stripe.py
+++ b/remarkbox/views/authenticated/stripe.py
@@ -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}")
diff --git a/requirements.txt b/requirements.txt
index 4cbd5f1..4ab428b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/test.ini b/test.ini
index a2a3e7c..bc805bf 100644
--- a/test.ini
+++ b/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}