Replace deprecated Stripe Sources API with Stripe Checkout

- Add stripe/checkout.py module for Stripe Checkout Sessions
- Add Payment model to track completed payments
- Update billing page with pay-what-you-want, annual, and top-up options
- Keep pay_what_you_can preferences, add Pay Now button for actual payment
- Add webhook handler for Stripe events
- Remove old card management code (deprecated Sources API)
- Update stripe requirement to >=5.0.0
This commit is contained in:
Russell Ballestrini 2025-12-19 20:52:38 -05:00
parent f641d39b11
commit acfbe52a65
18 changed files with 665 additions and 282 deletions

View file

@ -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}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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
View 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

View file

@ -157,6 +157,14 @@ 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)",
)
@property
def node_watchers(self):
return self.watchers.filter(Watcher.type == "node")

View file

@ -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")

View file

@ -0,0 +1,47 @@
"""Add payment table for Stripe Checkout
Revision ID: a1b2c3d4e5f6
Revises: fa8402aa1a00
Create Date: 2024-12-19
"""
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType
def upgrade():
op.create_table(
"rb_payment",
sa.Column("id", UUIDType(binary=False), primary_key=True, index=True),
sa.Column("user_id", UUIDType(binary=False), sa.ForeignKey("rb_user.id"), index=True, nullable=False),
sa.Column("stripe_session_id", sa.Unicode(128), unique=True, nullable=False, index=True),
sa.Column(
"payment_type",
sa.Enum("pay_what_you_want", "annual", "top_up", name="payment_type_enum"),
nullable=False,
),
sa.Column("amount_cents", sa.BigInteger(), nullable=False),
sa.Column("duration_months", sa.BigInteger(), default=0, nullable=False),
sa.Column(
"status",
sa.Enum("pending", "completed", "failed", "refunded", name="payment_status_enum"),
default="pending",
nullable=False,
),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("completed_timestamp", sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_table("rb_payment")
op.execute("DROP TYPE IF EXISTS payment_type_enum")
op.execute("DROP TYPE IF EXISTS payment_status_enum")

View file

@ -0,0 +1 @@
# Stripe integration module

View 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"

View 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;">&#10003;</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 -%}

View file

@ -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 -%}

View file

@ -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;">&#128274; 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 %}

View file

@ -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 -%}

View file

@ -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}")

View file

@ -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

View file

@ -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}