From d11531e9aef8974ca05ac7676dc3ec865ac10ea3 Mon Sep 17 00:00:00 2001 From: Kartik Ohri Date: Thu, 10 Aug 2023 16:04:17 +0530 Subject: [PATCH] Subscriptions using Stripe (#375) * Interim checkin for subscriptions using Stripe * test stripe subscriptions --- metabrainz/model/payment.py | 27 ++++++--- metabrainz/payments/stripe/views.py | 70 +++++++++++++++-------- metabrainz/templates/payments/donate.html | 12 +--- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/metabrainz/model/payment.py b/metabrainz/model/payment.py index 8d157006..56b46479 100644 --- a/metabrainz/model/payment.py +++ b/metabrainz/model/payment.py @@ -300,7 +300,23 @@ 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: @@ -308,11 +324,6 @@ def log_stripe_charge(cls, session): 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(): @@ -324,7 +335,7 @@ 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( @@ -332,7 +343,7 @@ def log_stripe_charge(cls, session): 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"], diff --git a/metabrainz/payments/stripe/views.py b/metabrainz/payments/stripe/views.py index a0b0ac34..6b70206c 100644 --- a/metabrainz/payments/stripe/views.py +++ b/metabrainz/payments/stripe/views.py @@ -22,26 +22,30 @@ 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", @@ -49,18 +53,31 @@ def pay(): } }, "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) @@ -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"}) diff --git a/metabrainz/templates/payments/donate.html b/metabrainz/templates/payments/donate.html index 80783e9b..bcd9da2f 100644 --- a/metabrainz/templates/payments/donate.html +++ b/metabrainz/templates/payments/donate.html @@ -192,9 +192,8 @@

GitHub

// 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'); @@ -234,13 +233,6 @@

GitHub

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