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 -%} + +
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 %} + +Support Remarkbox with a contribution. Set your preferences below, then pay when you're ready.
+
+
+ Payments are securely processed by Stripe.
+
+ Or PayPal @russellbal
+
+