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 #} +
+
+ 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/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 }} - -
    -
    -
    - {% 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/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}