From acfbe52a65ab8a6e55c0225f27fdf7d151b2bbb8 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 19 Dec 2025 20:52:38 -0500 Subject: [PATCH] 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 --- development.ini | 3 +- remarkbox/__init__.py | 34 --- remarkbox/lib/__init__.py | 5 + remarkbox/models/__init__.py | 1 + remarkbox/models/meta.py | 1 + remarkbox/models/payment.py | 109 +++++++ remarkbox/models/user.py | 8 + remarkbox/routes.py | 10 +- .../a1b2c3d4e5f6_add_payment_table.py | 47 +++ remarkbox/stripe/__init__.py | 1 + remarkbox/stripe/checkout.py | 138 +++++++++ remarkbox/templates/billing-success.j2 | 34 +++ remarkbox/templates/billing.j2 | 126 +++++++-- remarkbox/templates/snippets/stripe.j2 | 133 --------- remarkbox/templates/update-card.j2 | 23 -- remarkbox/views/authenticated/stripe.py | 267 +++++++++++++----- requirements.txt | 4 +- test.ini | 3 +- 18 files changed, 665 insertions(+), 282 deletions(-) create mode 100644 remarkbox/models/payment.py create mode 100644 remarkbox/scripts/alembic/versions/a1b2c3d4e5f6_add_payment_table.py create mode 100644 remarkbox/stripe/__init__.py create mode 100644 remarkbox/stripe/checkout.py create mode 100644 remarkbox/templates/billing-success.j2 delete mode 100644 remarkbox/templates/snippets/stripe.j2 delete mode 100644 remarkbox/templates/update-card.j2 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/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..a1fa808 100644 --- a/remarkbox/models/user.py +++ b/remarkbox/models/user.py @@ -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") 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/scripts/alembic/versions/a1b2c3d4e5f6_add_payment_table.py b/remarkbox/scripts/alembic/versions/a1b2c3d4e5f6_add_payment_table.py new file mode 100644 index 0000000..699c5b9 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/a1b2c3d4e5f6_add_payment_table.py @@ -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") 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 #} +
+
+ Pay Now + {% if request.user.pay_what_you_can and request.user.pay_what_you_can.amount %} +

Pay your configured amount of ${{ request.user.pay_what_you_can.amount }} now.

+ + + + {% include 'snippets/csrf.j2' %} + + {% else %} +

Set your contribution amount above first, or enter a custom amount:

+ + + + +

+ {% include 'snippets/csrf.j2' %} + + {% endif %} +
+

-{{ stripe.saved_cards() }} +{# Annual Subscription #} +
+
+ Annual Subscription +

Support Remarkbox with a yearly contribution.

- -
-
-{{ stripe.new_card() }} -
+ + + + + + +

+ {% include 'snippets/csrf.j2' %} + +
+
-

-Or PayPal @russellbal +{# Top Up #} +
+
+ Top Up +

Make an additional contribution anytime.

+ + + + + + + +

+ {% include 'snippets/csrf.j2' %} + +
+
-

-Thank you so much! +{# Payment History #} +{% if payments %} +
+ Payment History + + + + + + + + + + {% for payment in payments %} + + + + + + {% endfor %} + +
DateTypeAmount
{{ payment.human_created_date }}{{ payment.payment_type | replace("_", " ") | title }}${{ "%.2f" | format(payment.amount_dollars) }}
+
+
+{% endif %} + +
+

+ + Payments are securely processed by Stripe. +
+ Or PayPal @russellbal +
+

+
-
-
{%- endblock -%} 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 }} - -
-
-
- {% if actions %} - - - {% endif %} - {% if change_card %} - - {% endif %} -
-
-{% 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() %} - -
- {% include 'snippets/csrf.j2' %} - -
- -
-
- -
-
- - -
- -
-
- - - - -
- -
-
- - -{% 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) }} - -
- -
-
- {% include 'snippets/csrf.j2' %} - - - - -
-
- -{%- endblock -%} 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}