diff --git a/.mypy.ini b/.mypy.ini index 51060591d..ea5da189f 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -16,10 +16,6 @@ ignore_missing_imports = True # https://github.com/shapely/shapely/issues/721 ignore_missing_imports = True -[mypy-stripe.*] -# https://github.com/stripe/stripe-python/issues/650 -ignore_missing_imports = True - [mypy-sqlalchemy.engine.*] ignore_missing_imports = True diff --git a/apps/admin/payments.py b/apps/admin/payments.py index 283361ea2..24f75a24d 100644 --- a/apps/admin/payments.py +++ b/apps/admin/payments.py @@ -19,7 +19,7 @@ from sqlalchemy.sql.functions import func -from main import db, stripe +from main import db, get_stripe_client from models.payment import ( Payment, RefundRequest, @@ -190,8 +190,9 @@ def update_payment(payment_id): payment.lock() if payment.provider == "stripe": + stripe_client = get_stripe_client(app.config) try: - stripe_update_payment(payment) + stripe_update_payment(stripe_client, payment) except StripeUpdateConflict as e: app.logger.warn(f"StripeUpdateConflict updating payment: {e}") flash("Unable to update due to a status conflict") @@ -446,9 +447,11 @@ def refund(payment_id): payment.currency, ) + stripe_client = get_stripe_client(app.config) + if form.stripe_refund.data: app.logger.info("Refunding using Stripe") - charge = stripe.Charge.retrieve(payment.charge_id) + charge = stripe_client.charges.retrieve(payment.charge_id) if charge.refunded: # This happened unexpectedly - send the email as usual @@ -506,8 +509,11 @@ def refund(payment_id): if form.stripe_refund.data: try: - stripe_refund = stripe.Refund.create( - charge=payment.charge_id, amount=refund.amount_int + stripe_refund = stripe_client.refunds.create( + params={ + "charge": payment.charge_id, + "amount": refund.amount_int, + } ) except Exception as e: diff --git a/apps/payments/refund.py b/apps/payments/refund.py index 9a5190e7c..7e4a77c55 100644 --- a/apps/payments/refund.py +++ b/apps/payments/refund.py @@ -1,11 +1,11 @@ from decimal import Decimal -from stripe.error import StripeError +from stripe import StripeError from flask import current_app as app, render_template from flask_mailman import EmailMessage from typing import Optional from models.payment import RefundRequest, StripePayment, StripeRefund, BankRefund -from main import stripe, db +from main import get_stripe_client, db from ..common.email import from_email @@ -22,16 +22,21 @@ def create_stripe_refund( ) -> Optional[StripeRefund]: """Initiate a stripe refund, and return the StripeRefund object.""" # TODO: This should probably live in the stripe module. + stripe_client = get_stripe_client(app.config) assert amount > 0 - charge = stripe.Charge.retrieve(payment.charge_id) + charge = stripe_client.charges.retrieve(payment.charge_id) if charge.refunded: return None refund = StripeRefund(payment, amount) try: - stripe_refund = stripe.Refund.create( - charge=payment.charge_id, amount=refund.amount_int, metadata=metadata + stripe_refund = stripe_client.refunds.create( + params={ + "charge": payment.charge_id, + "amount": refund.amount_int, + "metadata": metadata, + } ) except StripeError as e: raise RefundException("Error creating Stripe refund") from e diff --git a/apps/payments/stripe.py b/apps/payments/stripe.py index b721a66ec..21346b6ab 100644 --- a/apps/payments/stripe.py +++ b/apps/payments/stripe.py @@ -9,6 +9,7 @@ complicate this code. """ import logging +from typing import Optional from flask import ( render_template, @@ -23,9 +24,9 @@ from flask_mailman import EmailMessage from wtforms import SubmitField from sqlalchemy.orm.exc import NoResultFound -from stripe.error import AuthenticationError +import stripe -from main import db, stripe +from main import db, get_stripe_client from models.payment import StripePayment from ..common import feature_enabled from ..common.email import from_email @@ -78,20 +79,23 @@ def stripe_capture(payment_id): logger.warn("Unable to capture payment as Stripe is disabled") flash("Card payments are currently unavailable. Please try again later") return redirect(url_for("users.purchases")) + stripe_client = get_stripe_client(app.config) if payment.intent_id is None: # Create the payment intent with Stripe. This intent will persist across retries. - intent = stripe.PaymentIntent.create( - amount=payment.amount_int, - currency=payment.currency.upper(), - statement_descriptor_suffix=payment.description, - metadata={"user_id": current_user.id, "payment_id": payment.id}, + intent = stripe_client.payment_intents.create( + params={ + "amount": payment.amount_int, + "currency": payment.currency.upper(), + "statement_descriptor_suffix": payment.description, + "metadata": {"user_id": current_user.id, "payment_id": payment.id}, + }, ) payment.intent_id = intent.id db.session.commit() else: # Reuse a previously-created payment intent - intent = stripe.PaymentIntent.retrieve(payment.intent_id) + intent = stripe_client.payment_intents.retrieve(payment.intent_id) if intent.status == "succeeded": logger.warn(f"Intent already succeeded, not capturing again") payment.state = "charging" @@ -170,8 +174,9 @@ def stripe_waiting(payment_id): @payments.route("/stripe-webhook", methods=["POST"]) def stripe_webhook(): + stripe_client = get_stripe_client(app.config) try: - event = stripe.Webhook.construct_event( + event = stripe_client.construct_event( request.data, request.headers["STRIPE_SIGNATURE"], app.config.get("STRIPE_WEBHOOK_KEY"), @@ -179,7 +184,7 @@ def stripe_webhook(): except ValueError: logger.exception("Error decoding Stripe webhook") abort(400) - except stripe.error.SignatureVerificationError: + except stripe.SignatureVerificationError: logger.exception("Error verifying Stripe webhook signature") abort(400) @@ -212,29 +217,34 @@ def stripe_ping(_type, _obj): return ("", 200) -def stripe_update_payment(payment: StripePayment, intent: stripe.PaymentIntent = None): +def stripe_update_payment( + stripe_client: stripe.StripeClient, + payment: StripePayment, + intent: Optional[stripe.PaymentIntent] = None, +): """Update a Stripe payment. - If a PaymentIntent object is not passed in, this will fetch the payment details from the Stripe API. + If a PaymentIntent object is not passed in, this will fetch the payment details from + the Stripe API. """ if intent is None: - intent = stripe.PaymentIntent.retrieve(payment.intent_id) - - if len(intent.charges) == 0: + intent = stripe_client.payment_intents.retrieve( + payment.intent_id, params=dict(expand=["latest_charge"]) + ) + if intent.latest_charge is None: # Intent does not have a charge (yet?), do nothing return - elif len(intent.charges) > 1: - raise StripeUpdateUnexpected( - f"Payment intent #{intent['id']} has more than one charge" - ) - - charge = intent.charges.data[0] + if isinstance(intent.latest_charge, stripe.Charge): + # The payment intent object has been expanded already + charge = intent.latest_charge + else: + charge = stripe_client.charges.retrieve(intent.latest_charge) - if payment.charge_id is not None and payment.charge_id != charge["id"]: + if payment.charge_id is not None and payment.charge_id != charge.id: logger.warn( - f"Charge ID for intent {intent['id']} has changed from {payment.charge_id} to {charge['id']}" + f"Charge ID for intent {intent.id} has changed from {payment.charge_id} to {charge.id}" ) - payment.charge_id = charge["id"] + payment.charge_id = charge.id if charge.refunded: return stripe_payment_refunded(payment) @@ -367,8 +377,9 @@ def stripe_payment_intent_updated(hook_type, intent): payment.id, ) + stripe_client = get_stripe_client(app.config) try: - stripe_update_payment(payment, intent) + stripe_update_payment(stripe_client, payment, intent) except StripeUpdateConflict: abort(409) except StripeUpdateUnexpected: @@ -423,10 +434,11 @@ def stripe_validate(): else: result.append((False, "Webhook key not configured")) + stripe_client = get_stripe_client(app.config) try: - webhooks = stripe.WebhookEndpoint.list() + webhooks = stripe_client.webhook_endpoints.list() result.append((True, "Connection to Stripe API succeeded")) - except AuthenticationError as e: + except stripe.AuthenticationError as e: result.append((False, f"Connecting to Stripe failed: {e}")) return result diff --git a/main.py b/main.py index 4a1d8112a..40dcc81b5 100644 --- a/main.py +++ b/main.py @@ -64,6 +64,13 @@ def include_object(object, name, type_, reflected, compare_to): wise = None +def get_stripe_client(config) -> stripe.StripeClient: + return stripe.StripeClient( + api_key=config["STRIPE_SECRET_KEY"], + stripe_version="2023-10-16", + ) + + def check_cache_configuration(): """Check the cache configuration is appropriate for production""" if cache.cache.__class__.__name__ == "SimpleCache": @@ -153,7 +160,6 @@ def load_user(userid): login_manager.anonymous_user = load_anonymous_user - stripe.api_key = app.config["STRIPE_SECRET_KEY"] global wise wise = pywisetransfer.Client( api_key=app.config["TRANSFERWISE_API_TOKEN"], diff --git a/poetry.lock b/poetry.lock index fdda1c40d..789ec843c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3699,17 +3699,18 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] name = "stripe" -version = "2.38.0" +version = "8.0.0" description = "Python bindings for the Stripe API" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" files = [ - {file = "stripe-2.38.0-py2.py3-none-any.whl", hash = "sha256:ea26abb9c4f6eea94dda630157a1f713e5925b9ccec0fbac5e53a9c6e4541bbd"}, - {file = "stripe-2.38.0.tar.gz", hash = "sha256:b37bc34045b2becad6ce40a5ca0abd3c83ee2528e3dce0149026b29f30ac90a6"}, + {file = "stripe-8.0.0-py2.py3-none-any.whl", hash = "sha256:0c6c7ded4ae98340107a9d61f2d029ea0802ee88580c9bebb3693230557e766c"}, + {file = "stripe-8.0.0.tar.gz", hash = "sha256:16d9086c95801fb6931565ff8a3dd7ca3e85beb4cb6fdb2020e555f4254ff643"}, ] [package.dependencies] requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} [[package]] name = "tinycss2" @@ -4316,4 +4317,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "f78a2d4b8556b815794afc47a6a4736a8c715b1c4621ea33c6f1d1bc15d940f0" +content-hash = "01d1fc86dd207c6c21c5bdcc5aec32915fe0a15162bac6ea12dde4f5a1acf854" diff --git a/pyproject.toml b/pyproject.toml index 24b81d91f..e334cfe8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ pybarcode = {git = "https://github.com/emfcamp/python-barcode"} pillow = "~=10.0" icalendar = "==3.11.7" pytz = "*" -stripe = "~=2.38.0" +stripe = "~=8.0.0" ofxparse = "==0.16" python-dateutil = "*" slotmachine = {git = "https://github.com/emfcamp/slotmachine.git"}