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

Subscriptions using Stripe #375

Merged
merged 2 commits into from
Aug 10, 2023
Merged
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
27 changes: 19 additions & 8 deletions metabrainz/model/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,30 @@ def _extract_paypal_ipn_options(form: dict) -> dict:
return options

@classmethod
def log_stripe_charge(cls, session):
def log_subscription_charge(cls, invoice):
""" Log successful Stripe charges for a recurring payment/donation """
charge = stripe.Charge.retrieve(invoice["charge"], expand=["balance_transaction"])
metadata = invoice["lines"]["data"][0]["metadata"]
return cls._log_stripe_charge(charge, metadata)

@classmethod
def log_one_time_charge(cls, session):
""" Log successful Stripe charge for one time payment/donation """
payment_intent = stripe.PaymentIntent.retrieve(session["payment_intent"],
expand=["charges.data.balance_transaction"])
charge = payment_intent["charges"]["data"][0]
metadata = payment_intent["metadata"]
return cls._log_stripe_charge(charge, metadata)

@classmethod
def _log_stripe_charge(cls, charge, metadata):
"""Log successful Stripe charge.

Args:
session: The charge object from Stripe. More information about it is
available at https://stripe.com/docs/api/python#charge_object.
"""
current_app.logger.debug("Processing Stripe charge...")
metadata = session["metadata"]

payment_intent = stripe.PaymentIntent.retrieve(session["payment_intent"],
expand=["charges.data.balance_transaction"])
charge = payment_intent["charges"]["data"][0]

# Transaction already exists in the database, do not insert again
if db.session.query(exists().where(Payment.transaction_id == charge["id"])).scalar():
Expand All @@ -324,15 +335,15 @@ def log_stripe_charge(cls, session):
transaction = charge["balance_transaction"]
currency = transaction["currency"].lower()
if currency not in SUPPORTED_CURRENCIES:
current_app.logger.warning("Unsupported currency: ", session["currency"])
current_app.logger.warning("Unsupported currency: ", transaction["currency"])
return

new_donation = cls(
first_name=details["name"],
last_name="",
amount=transaction["net"] / 100, # cents should be converted
fee=transaction["fee"] / 100, # cents should be converted
currency=currency,
currency="usd",
transaction_id=charge["id"],
payment_method=PAYMENT_METHOD_STRIPE,
is_donation=metadata["is_donation"],
Expand Down
70 changes: 47 additions & 23 deletions metabrainz/payments/stripe/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,62 @@ def pay():
if not form.validate():
return redirect(url_for("payments.error", is_donation=is_donation))

is_recurring = form.recurring.data

charge_metadata = {
"is_donation": is_donation,
}
if is_donation:
# Using DonationForm
if is_donation: # Using DonationForm
charge_metadata["editor"] = form.editor.data
charge_metadata["anonymous"] = form.anonymous.data
charge_metadata["can_contact"] = form.can_contact.data
description = "Donation to the MetaBrainz Foundation"
else:
# Using PaymentForm
else: # Using PaymentForm
charge_metadata["invoice_number"] = form.invoice_number.data
description = f"Payment to the MetaBrainz Foundation for Invoice {form.invoice_number.data}"
# Add invoice number to description only for non-recurring payments
if is_recurring:
description = "Payment to the MetaBrainz Foundation"
else:
description = f"Payment to the MetaBrainz Foundation for Invoice {form.invoice_number.data}"

try:
session = stripe.checkout.Session.create(
billing_address_collection="required",
line_items=[{
session_config = {
"billing_address_collection": "required",
"line_items": [
{
"price_data": {
"unit_amount": int(form.amount.data * 100), # amount in cents
"unit_amount": int(form.amount.data * 100), # amount in cents
"currency": form.currency.data,
"product_data": {
"name": "Support the MetaBrainz Foundation",
"description": description
}
},
"quantity": 1
}],
payment_intent_data={
"description": description
},
payment_method_types=["card"],
mode="payment",
submit_type="donate" if is_donation else "pay",
# stripe wants absolute urls so url_for doesn't suffice
success_url=f'{current_app.config["SERVER_BASE_URL"]}/payment/complete?is_donation={is_donation}',
cancel_url=f'{current_app.config["SERVER_BASE_URL"]}/payment/cancelled?is_donation={is_donation}',
metadata=charge_metadata
)
}
],
"payment_method_types": ["card"],
"mode": "subscription",
# stripe wants absolute urls so url_for doesn't suffice
"success_url": f'{current_app.config["SERVER_BASE_URL"]}/payment/complete?is_donation={is_donation}',
"cancel_url": f'{current_app.config["SERVER_BASE_URL"]}/payment/cancelled?is_donation={is_donation}',
}

if is_recurring:
session_config["mode"] = "subscription"
# configure monthly subscription
session_config["line_items"][0]["price_data"]["recurring"] = {"interval": "month"}
session_config["subscription_data"] = {"metadata": charge_metadata}
else:
session_config["mode"] = "payment"
# submit_type and payment_intent_data are only allowed in payment mode
session_config["submit_type"] = "donate" if is_donation else "pay"
session_config["payment_intent_data"] = {
"description": description,
"metadata": charge_metadata
}

try:
session = stripe.checkout.Session.create(**session_config)
return redirect(session.url, code=303)
except Exception as e:
current_app.logger.error(e, exc_info=True)
Expand All @@ -82,7 +99,14 @@ def webhook():
current_app.logger.error("Stripe signature error, possibly fake event", exc_info=True)
return jsonify({"error": "invalid signature"}), 400

# for one time payments, mode is payment, and we use the checkout.session.completed event to log charges
# other option is mode = subscription i.e. recurring payments, for which payment_intent data is unavailable
# in this webhook. hence, we use invoice.paid event instead which contains it.
if event["type"] == "checkout.session.completed":
Payment.log_stripe_charge(event["data"]["object"])
session = event["data"]["object"]
if session["mode"] == "payment":
Payment.log_one_time_charge(session)
elif event["type"] == "invoice.paid":
Payment.log_subscription_charge(event["data"]["object"])

return jsonify({"status": "ok"})
12 changes: 2 additions & 10 deletions metabrainz/templates/payments/donate.html
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,8 @@ <h3>GitHub</h3>
// This function disables or enables payment buttons depending on what
// features they support.

// We don't support recurring payments via Stripe. Payments in EUR are
// also not supported currently.
if ($('#recurring-flag').is(":checked") || selectedCurrency === CURRENCY.Euro) {
// We don't support payments in EUR via Stripe currently.
if (selectedCurrency === CURRENCY.Euro) {
buttons.stripe.attr('disabled', 'disabled');
} else {
buttons.stripe.removeAttr('disabled');
Expand Down Expand Up @@ -234,13 +233,6 @@ <h3>GitHub</h3>
updateButtonState();
});

/////////////
// RECURRENCE
/////////////
$('#recurring-flag').change(function() {
updateButtonState();
});

///////////////
// EDITOR INPUT
///////////////
Expand Down
Loading