Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update stripe to latest package/api version #1345

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ Or, you can create an account and simultaneously make it an admin by using `./fl

E-mail sending is disabled in development (but is printed out on the console). You can also log in directly by setting `BYPASS_LOGIN=True` in `config/development.cfg` and then using a URL of the form e.g. `/login/admin@test.invalid`.

### Testing payments

The easiest way to test the payments flow (marginally less easy if you don't have a Stripe account) is to use Stripe test keys and pay by card with Stripe's test cards. To test incoming webhooks you can use a service like ngrok or tailscale funnel to expose your local dev instance to the web (temporarily!).

### Database Migrations

- `./flask db migrate -m 'Migration name'` to generate migration scripts when models have been updated.
Expand Down
16 changes: 11 additions & 5 deletions apps/admin/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 10 additions & 5 deletions apps/payments/refund.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
Expand Down
86 changes: 59 additions & 27 deletions apps/payments/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
complicate this code.
"""
import logging
from typing import Optional

from flask import (
render_template,
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -170,16 +174,17 @@ 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"),
)
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)

Expand Down Expand Up @@ -212,29 +217,54 @@ 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.
"""
intent_is_fresh = False
if intent is None:
intent = stripe.PaymentIntent.retrieve(payment.intent_id)
intent = stripe_client.payment_intents.retrieve(
payment.intent_id, params=dict(expand=["latest_charge"])
)
intent_is_fresh = True

if len(intent.charges) == 0:
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:
# The payment's failed and been retried, and this might be a
# delayed webhook notification for the old charge ID. So we
# need to check whether it's the latest.
if intent_is_fresh:
fresh_intent = intent
else:
fresh_intent = stripe_client.payment_intents.retrieve(
payment.intent_id, params=dict(expand=["latest_charge"])
)

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']}"
)
if fresh_intent.latest_charge == charge.id:
logger.warn(
f"Charge ID for intent {intent.id} has changed from {payment.charge_id} to {charge.id}"
)
else:
logger.warn(
f"Charge ID {charge.id} for intent {intent.id} is out of date, ignoring"
)
return

payment.charge_id = charge["id"]
payment.charge_id = charge.id

if charge.refunded:
return stripe_payment_refunded(payment)
Expand Down Expand Up @@ -367,8 +397,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:
Expand Down Expand Up @@ -423,10 +454,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

Expand Down
8 changes: 7 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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"],
Expand Down
11 changes: 6 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pybarcode = {git = "https://github.com/emfcamp/python-barcode"}
pillow = "~=10.2"
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"}
Expand Down
Loading
Loading