mirror of
https://gitlab.com/tildes/tildes.git
synced 2026-04-16 06:18:34 +02:00
Re-add donations via Stripe
Multiple people have been asking me how they can make a one-time donation without going through GitHub or Patreon, so I'll re-add this for now but will need to keep an eye out for fraud and potentially disable it again soon.
This commit is contained in:
@@ -23,6 +23,8 @@ redis.sessions.timeout = 3600
|
||||
|
||||
sqlalchemy.url = postgresql+psycopg2://tildes:@:6432/tildes
|
||||
|
||||
stripe.recurring_donation_product_id = prod_ProductID
|
||||
|
||||
tildes.default_user_comment_label_weight = 1.0
|
||||
|
||||
# Path to the file to use to check for passwords that have been in data breaches, which
|
||||
@@ -42,4 +44,6 @@ webassets.manifest = json
|
||||
|
||||
# API keys for external APIs
|
||||
api_keys.embedly = embedlykeygoeshere
|
||||
api_keys.stripe.publishable = pk_live_ActualKeyShouldGoHere
|
||||
api_keys.stripe.secret = sk_live_ActualKeyShouldGoHere
|
||||
api_keys.youtube = youtubekeygoeshere
|
||||
|
||||
@@ -34,6 +34,7 @@ requests
|
||||
sentry-sdk
|
||||
SQLAlchemy<1.4
|
||||
SQLAlchemy-Utils
|
||||
stripe
|
||||
titlecase
|
||||
webargs
|
||||
wrapt
|
||||
|
||||
10
tildes/static/js/behaviors/stripe-checkout.js
Normal file
10
tildes/static/js/behaviors/stripe-checkout.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2019 Tildes contributors <code@tildes.net>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
$.onmount("[data-js-stripe-checkout]", function() {
|
||||
/* eslint-disable-next-line no-undef */
|
||||
var stripe = Stripe($(this).attr("data-js-stripe-checkout"));
|
||||
stripe.redirectToCheckout({
|
||||
sessionId: $(this).attr("data-js-stripe-checkout-session")
|
||||
});
|
||||
});
|
||||
28
tildes/static/js/behaviors/stripe-donate-form.js
Normal file
28
tildes/static/js/behaviors/stripe-donate-form.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2019 Tildes contributors <code@tildes.net>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
$.onmount("[data-js-stripe-donate-form]", function() {
|
||||
$(this).on("submit", function(event) {
|
||||
var $amountInput = $(this).find("#amount");
|
||||
var amount = $amountInput.val();
|
||||
|
||||
var $errorDiv = $(this).find(".text-status-message");
|
||||
|
||||
// remove dollar sign and/or comma, then parse into float
|
||||
amount = amount.replace(/[$,]/g, "");
|
||||
amount = parseFloat(amount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
$errorDiv.text("Please enter a valid dollar amount.");
|
||||
event.preventDefault();
|
||||
return;
|
||||
} else if (amount < 1.0) {
|
||||
$errorDiv.text("Donation amount must be at least $1.");
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// set the value in case any of the replacements happened
|
||||
$amountInput.val(amount);
|
||||
});
|
||||
});
|
||||
@@ -308,6 +308,10 @@ _RATE_LIMITED_ACTIONS = (
|
||||
RateLimitedAction("register", timedelta(hours=1), 50),
|
||||
RateLimitedAction("topic_post", timedelta(hours=4), 10, max_burst=4),
|
||||
RateLimitedAction("comment_post", timedelta(hours=1), 10, max_burst=5),
|
||||
RateLimitedAction("donate_stripe", timedelta(hours=1), 5, by_user=False),
|
||||
RateLimitedAction(
|
||||
"global_donate_stripe", timedelta(hours=1), 50, by_user=False, by_ip=False
|
||||
),
|
||||
)
|
||||
|
||||
# (public) dict to be able to look up the actions by name
|
||||
|
||||
@@ -115,6 +115,10 @@ def includeme(config: Configurator) -> None:
|
||||
# Route to expose metrics to Prometheus
|
||||
config.add_route("metrics", "/metrics")
|
||||
|
||||
# Routes for Stripe donations (linked from Docs site)
|
||||
config.add_route("donate_stripe", "/donate_stripe")
|
||||
config.add_route("donate_success", "/donate_success")
|
||||
|
||||
# Add all intercooler routes under the /api/web path
|
||||
with config.route_prefix_context("/api/web"):
|
||||
add_intercooler_routes(config)
|
||||
|
||||
54
tildes/tildes/templates/donate_stripe.jinja2
Normal file
54
tildes/tildes/templates/donate_stripe.jinja2
Normal file
@@ -0,0 +1,54 @@
|
||||
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
|
||||
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
|
||||
|
||||
{% extends 'base_no_sidebar.jinja2' %}
|
||||
|
||||
{% block title %}Donate to Tildes{% endblock %}
|
||||
|
||||
{% block main_heading %}Credit card donation (via Stripe){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>After submitting this form, you will be redirected to the Stripe site to enter your payment info.</p>
|
||||
|
||||
<p>Note that the donation is in Canadian Dollars (CAD) by default, but you can switch to USD if you prefer. At the moment, $10 CAD is about $7.50 USD.</p>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<form class="form-narrow" method="post" data-js-stripe-donate-form>
|
||||
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Donation type</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="interval" value="onetime" checked>
|
||||
<i class="form-icon"></i> One time
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="interval" value="month">
|
||||
<i class="form-icon"></i> Monthly
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="interval" value="year">
|
||||
<i class="form-icon"></i> Yearly
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="amount">Donation amount (must be at least $1)</label>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">$</span>
|
||||
<input class="form-input" id="amount" name="amount" type="text" data-js-auto-focus>
|
||||
<select class="form-select" id="currency" name="currency">
|
||||
<option value="CAD" selected>CAD</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<button class="btn btn-primary" type="submit">Donate</button>
|
||||
<div class="text-status-message text-error"></div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
18
tildes/tildes/templates/donate_stripe_redirect.jinja2
Normal file
18
tildes/tildes/templates/donate_stripe_redirect.jinja2
Normal file
@@ -0,0 +1,18 @@
|
||||
{# Copyright (c) 2018 Tildes contributors <code@tildes.net> #}
|
||||
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
|
||||
|
||||
{% extends 'base_no_sidebar.jinja2' %}
|
||||
|
||||
{% block title %}Stripe donation{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
|
||||
<p>Redirecting to Stripe...</p>
|
||||
|
||||
{# This div will cause the page to redirect to the Stripe Checkout page #}
|
||||
<div
|
||||
data-js-stripe-checkout="{{ publishable_key }}"
|
||||
data-js-stripe-checkout-session="{{ session_id }}"
|
||||
></div>
|
||||
{% endblock %}
|
||||
15
tildes/tildes/templates/donate_success.jinja2
Normal file
15
tildes/tildes/templates/donate_success.jinja2
Normal file
@@ -0,0 +1,15 @@
|
||||
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
|
||||
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
|
||||
|
||||
{% extends 'base_no_sidebar.jinja2' %}
|
||||
|
||||
{% block title %}Thanks for donating!{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="empty">
|
||||
<h2 class="empty-title">Thanks for donating to Tildes!</h2>
|
||||
<p class="empty-subtitle">You should receive an email receipt. If you have any questions, please feel free to contact <a href="mailto:donate@tildes.net">donate@tildes.net</a></p>
|
||||
|
||||
<div class="empty-action"><a href="/" class="btn btn-primary">Back to the home page</a></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
115
tildes/tildes/views/donate.py
Normal file
115
tildes/tildes/views/donate.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""The view for donating via Stripe."""
|
||||
|
||||
import stripe
|
||||
from marshmallow.fields import Float, String
|
||||
from marshmallow.validate import OneOf, Range
|
||||
from pyramid.httpexceptions import HTTPInternalServerError
|
||||
from pyramid.request import Request
|
||||
from pyramid.security import NO_PERMISSION_REQUIRED
|
||||
from pyramid.view import view_config
|
||||
|
||||
from tildes.metrics import incr_counter
|
||||
from tildes.views.decorators import rate_limit_view, use_kwargs
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="donate_stripe",
|
||||
request_method="GET",
|
||||
renderer="donate_stripe.jinja2",
|
||||
permission=NO_PERMISSION_REQUIRED,
|
||||
)
|
||||
def get_donate_stripe(request: Request) -> dict:
|
||||
"""Display the form for donating with Stripe."""
|
||||
# pylint: disable=unused-argument
|
||||
return {}
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="donate_stripe",
|
||||
request_method="POST",
|
||||
renderer="donate_stripe_redirect.jinja2",
|
||||
permission=NO_PERMISSION_REQUIRED,
|
||||
)
|
||||
@use_kwargs(
|
||||
{
|
||||
"amount": Float(required=True, validate=Range(min=1.0)),
|
||||
"currency": String(required=True, validate=OneOf(("CAD", "USD"))),
|
||||
"interval": String(required=True, validate=OneOf(("onetime", "month", "year"))),
|
||||
},
|
||||
location="form",
|
||||
)
|
||||
@rate_limit_view("global_donate_stripe")
|
||||
@rate_limit_view("donate_stripe")
|
||||
def post_donate_stripe(
|
||||
request: Request, amount: int, currency: str, interval: str
|
||||
) -> dict:
|
||||
"""Process a Stripe donation."""
|
||||
try:
|
||||
stripe.api_key = request.registry.settings["api_keys.stripe.secret"]
|
||||
publishable_key = request.registry.settings["api_keys.stripe.publishable"]
|
||||
product_id = request.registry.settings["stripe.recurring_donation_product_id"]
|
||||
except KeyError as exc:
|
||||
raise HTTPInternalServerError from exc
|
||||
|
||||
incr_counter("donation_initiations", type="stripe")
|
||||
|
||||
if interval == "onetime":
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[
|
||||
{
|
||||
"name": "One-time donation - tildes.net",
|
||||
"amount": int(amount * 100),
|
||||
"currency": currency,
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
submit_type="donate",
|
||||
success_url="https://tildes.net/donate_success",
|
||||
cancel_url="https://docs.tildes.net/donate",
|
||||
)
|
||||
else:
|
||||
product = stripe.Product.retrieve(product_id)
|
||||
existing_plans = stripe.Plan.list(product=product, active=True, limit=100)
|
||||
|
||||
# look through existing plans to see if there's already a matching one, or
|
||||
# create a new plan if not
|
||||
for existing_plan in existing_plans:
|
||||
if (
|
||||
existing_plan.amount == int(amount * 100)
|
||||
and existing_plan.currency == currency.lower()
|
||||
and existing_plan.interval == interval
|
||||
):
|
||||
plan = existing_plan
|
||||
break
|
||||
else:
|
||||
plan = stripe.Plan.create(
|
||||
amount=int(amount * 100),
|
||||
currency=currency,
|
||||
interval=interval,
|
||||
product=product,
|
||||
)
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
subscription_data={"items": [{"plan": plan.id}]},
|
||||
success_url="https://tildes.net/donate_success",
|
||||
cancel_url="https://docs.tildes.net/donate",
|
||||
)
|
||||
|
||||
return {"publishable_key": publishable_key, "session_id": session.id}
|
||||
|
||||
|
||||
@view_config(route_name="donate_success", renderer="donate_success.jinja2")
|
||||
def get_donate_success(request: Request) -> dict:
|
||||
"""Display a message after a successful donation."""
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
# incrementing this metric on page-load and hard-coding Stripe isn't ideal, but it
|
||||
# should do the job for now
|
||||
incr_counter("donations", type="stripe")
|
||||
|
||||
return {}
|
||||
Reference in New Issue
Block a user