Changeset - ce4ae22fa59e
[Not reviewed]
0 5 1
Ben Sturmfels (bsturmfels) - 1 month ago 2024-09-18 05:34:59
ben@sturm.com.au
Add prototype monthly recurring payment via Stripe
6 files changed with 110 insertions and 9 deletions:
0 comments (0 inline, 0 general)
conservancy/supporters/forms.py
Show inline comments
 
from django import forms
 

	
 
from .models import SustainerOrder
 

	
 
class SustainerForm(forms.ModelForm):
 
    amount_monthly = forms.IntegerField(initial=12, required=False)
 

	
 
    class Meta:
 
        model = SustainerOrder
 
        fields = [
 
            'name',
 
            'email',
 
            'amount',
 
            'acknowledge_publicly',
 
            'add_to_mailing_list',
 
            'tshirt_size',
 
            'street',
 
            'city',
 
            'state',
 
            'zip_code',
 
            'country',
 
        ]
 

	
 
    def __init__(self, *args, **kwargs):
 
        super().__init__(*args, **kwargs)
 
        self.fields['amount'].widget.attrs['style'] = 'width: 5rem'
 
        self.fields['amount'].initial = 128
 
        self.fields['amount_monthly'].widget.attrs['style'] = 'width: 5rem'
 
        self.fields['tshirt_size'].widget.attrs['x-model'] = 'tshirt_size'
conservancy/supporters/migrations/0002_sustainerorder_monthly_recurring_and_more.py
Show inline comments
 
new file 100644
 
# Generated by Django 4.2.16 on 2024-09-18 01:27
 

	
 
import django.core.validators
 
from django.db import migrations, models
 

	
 

	
 
class Migration(migrations.Migration):
 

	
 
    dependencies = [
 
        ('supporters', '0001_initial'),
 
    ]
 

	
 
    operations = [
 
        migrations.AddField(
 
            model_name='sustainerorder',
 
            name='monthly_recurring',
 
            field=models.BooleanField(default=False),
 
        ),
 
        migrations.AlterField(
 
            model_name='sustainerorder',
 
            name='amount',
 
            field=models.IntegerField(
 
                validators=[django.core.validators.MinValueValidator(100)]
 
            ),
 
        ),
 
        migrations.AlterField(
 
            model_name='sustainerorder',
 
            name='paid_time',
 
            field=models.DateTimeField(blank=True, null=True),
 
        ),
 
        migrations.AlterField(
 
            model_name='sustainerorder',
 
            name='tshirt_size',
 
            field=models.CharField(
 
                choices=[
 
                    ('', (('None', 'None'),)),
 
                    (
 
                        "Men's",
 
                        (
 
                            ("Men's S", "Men's S"),
 
                            ("Men's M", "Men's M"),
 
                            ("Men's L", "Men's L"),
 
                            ("Men's XL", "Men's XL"),
 
                            ("Men's 2XL", "Men's 2XL"),
 
                        ),
 
                    ),
 
                    (
 
                        "Standard women's",
 
                        (
 
                            ("Standard women's S", "Standard women's S"),
 
                            ("Standard women's M", "Standard women's M"),
 
                            ("Standard women's L", "Standard women's L"),
 
                            ("Standard women's XL", "Standard women's XL"),
 
                            ("Standard women's 2XL", "Standard women's 2XL"),
 
                        ),
 
                    ),
 
                    (
 
                        "Fitted women's",
 
                        (
 
                            ("Fitted women's S", "Fitted women's S"),
 
                            ("Fitted women's M", "Fitted women's M"),
 
                            ("Fitted women's L", "Fitted women's L"),
 
                            ("Fitted women's XL", "Fitted women's XL"),
 
                            ("Fitted women's 2XL", "Fitted women's 2XL"),
 
                        ),
 
                    ),
 
                ],
 
                max_length=50,
 
            ),
 
        ),
 
    ]
conservancy/supporters/models.py
Show inline comments
...
 
@@ -39,43 +39,43 @@ class SustainerOrder(models.Model):
 
            (
 
                ("Standard women's S", "Standard women's S"),
 
                ("Standard women's M", "Standard women's M"),
 
                ("Standard women's L", "Standard women's L"),
 
                ("Standard women's XL", "Standard women's XL"),
 
                ("Standard women's 2XL", "Standard women's 2XL"),
 
            ),
 
        ),
 
        (
 
            "Fitted women's",
 
            (
 
                ("Fitted women's S", "Fitted women's S"),
 
                ("Fitted women's M", "Fitted women's M"),
 
                ("Fitted women's L", "Fitted women's L"),
 
                ("Fitted women's XL", "Fitted women's XL"),
 
                ("Fitted women's 2XL", "Fitted women's 2XL"),
 
            ),
 
        ),
 
    ]
 

	
 
    created_time = models.DateTimeField(auto_now_add=True)
 
    name = models.CharField(max_length=255)
 
    email = models.EmailField()
 
    amount = models.IntegerField(
 
        default=128,
 
        validators=[
 
            validators.MinValueValidator(100),
 
        ])
 
    monthly_recurring = models.BooleanField(default=False)
 
    paid_time = models.DateTimeField(null=True, blank=True)
 
    acknowledge_publicly = models.BooleanField(default=False)
 
    add_to_mailing_list = models.BooleanField(default=False)
 
    tshirt_size = models.CharField(max_length=50, choices=TSHIRT_CHOICES)
 
    street = models.CharField(max_length=255, blank=True)
 
    city = models.CharField(max_length=255, blank=True)
 
    state = models.CharField(max_length=255, blank=True)
 
    zip_code = models.CharField(max_length=255, blank=True)
 
    country = models.CharField(max_length=255, blank=True)
 

	
 
    def __str__(self):
 
        return f'Sustainer order {self.id}: {self.email}'
 

	
 
    def paid(self):
 
        return self.paid_time is not None
conservancy/supporters/templates/supporters/sustainers_stripe.html
Show inline comments
...
 
@@ -6,48 +6,51 @@
 
{% block head %}
 
  {{ block.super }}
 
  <style>
 
   @media screen and (min-width: 40em) {
 
     #sustainer-grid {
 
       display: grid;
 
       grid-template-columns: 2fr 1fr;
 
       grid-template-rows: min-content 1fr;
 
       gap: 1.5rem;
 
     }
 
   }
 
   progress {
 
     background: #ddd;
 
     border: none;
 
   }
 
   progress::-moz-progress-bar {
 
     background: #224c57;
 
   }
 
   progress::-webkit-progress-bar {
 
     background: #ddd;
 
   }
 
   progress::-webkit-progress-value {
 
     background: #224c57;
 
   }
 
   .btn:active {
 
     transform: scale(1.05);
 
   }
 
  </style>
 
{% endblock %}
 

	
 
{% block content %}
 
  <h1 class="lh-title tc mt4 mb0">Become a Sustainer Now</h1>
 
  <p class="measure-wide center tc">Sustainers help us do our work in a strategic, long-term way.</p>
 

	
 
  <div id="sustainer-grid" class="mv4">
 
    <div style="grid-row: 1 / span 2">
 
      <video controls poster="https://sfconservancy.org/videos/sfc-introduction-video_poster.jpg" class="mb3">
 
        <source src="https://sfconservancy.org/videos/sfc-introduction_1080p.mp4">
 
        <track src="/docs/sfc-introduction-vtt-captions.txt" kind="subtitles" srclang="en" label="English">
 
        <a href="https://sfconservancy.org/videos/sfc-introduction_1080p.mp4"><img src="https://sfconservancy.org/videos/sfc-introduction-video_poster.jpg" alt="Software Freedom Conservancy introduction video"></a><br>
 
        <a href="https://youtu.be/yCCxMfW0LTM">(watch on Youtube)</a>
 
      </video>
 

	
 
      <h3>The wide range of work we engage in is supported by people like you.</h3>
 

	
 
      <p>We are so proud that we're funded by individuals and stay unbeholden to corporate interests and pressures. We stand up for developers, consumers and those who have been historically excluded. We work to make technology truly fair for all. </p>
 

	
 
      <p>Thank you for helping making this work possible:</p>
 

	
 
      <ul>
 
        <li>Standing up for consumer rights in <a href="/copyleft-compliance/">copyleft compliance</a></li>
...
 
@@ -254,37 +257,37 @@ code forges and what the new contributor experience is like. A great mix of
 
code related and process related talks. The <a href="https://reproducible-builds.org/">Reproducible Builds</a>
 
annual summit was hosted in Hamburg featuring incredible
 
technical talks, project planning and continues to build the momentum and
 
reach for reproducibility. </p>
 
</details>
 

	
 

	
 
      <h3>Our sustainers</h3>
 

	
 
      <p>Anonymous (100 people), Aurimas Fišeras, Kat Walsh, Richard Wheeler, Karl Ove Hufthammer, Mark Wielaard, Karl Fogel, Richard Fontana, Richard L. Schmeidler, Karen Sandler, Russ Allbery, Christine Webber, Jeremy Allison, J.B. Nicholson, Michael Dexter, Tom Marble, Johannes Krampf, Michael Linksvayer, Jack Hill, Stefano Zacchiroli, Daniel Callahan, Ben Cotton, in memory of Marina Zhurakhinskaya, Jim Radford, Tyng-Ruey Chuang, Francois Marier, Henri Sivonen, Keith Packard, Monica Neumann, Michal Nazarewicz, Bdale Garbee, David Neary, Alexander Bokovoy, Andrew Isaacson, Brian Hart, James Pannacciulli, Sasa Poznanovic, David Batley, David Crossland, Steve Sprang, Bob Murphy, Mark Galassi, James R. Garrison, Bluebird Interactive, David Quist, Patrick Masson, Neil McGovern, Lenore Ramm Hill, Paul Logston, David Arnold, Benjamin Pfaff, Timothy Howes, Britta Gustafson, Wookey, Michael Gulick, Tanu Kaskinen, Jeffrey Layton, Raphaël Hertzog, Will Thompson, Matteo Settenvini, Kevin Krammer, Elana Hashman, Richard Schultz, Charkov Alexey, Donald Craig, Michael Catanzaro, Olav Reinert, Stephen Kitt, Barry Fishman, Luigi Toscano, Steve McIntyre, Cornelia Huck, Jonathan McDowell, Emmanuel Seyman, Mike Crowe, Alexandre Julliard, Ross Vandegrift, Ian Jackson, Alexander Reichle-Schmehl, Sang Engineering, Preston Maness, John Hagemeister, Julien Cristau, Rebecca Sobol, John Hughes, Peter Link, Solomon Peachy, Riccardo Ghetta, Stefano Rivera, Julian Gilbey, Srivats P, JRS System Solutions, Eric Dorland, Matija Nalis, Brett Smith, Dmitry Borodaenko, Johannes Berg, Howard Shih, Nigel Whillier, Peter Maydell, Lars Wirzenius, Stephanie Feingold, Kevin Adler, Matthew Vernon, Stefan Seefeld, scrye.com, Robert Horn, Andreas Bombe, Michael Kuhn, Stephen Waite, Philip Cohn-Cort, Stuart Smith, Michel Machado, Joseph Thompson, Joan Sandler, Sage Ross, Peter Levy, Daniel Gillmor, James Carter, Wilson E. Alvarez, Michael Andreen, Aaron Puchert, Andrew Eikum, Vladimir Michl, Gregory Grossmeier, Josh Triplett, James Blair, Felix Gruber, Claire Connelly, Antoine Amarilli, Kenneth J. Pronovici, Igalia S. L., Karl-Johan Karlsson, David Gibson, Tom Callaway, Steven A. Ovadia, Gerard Ryan, James Garrett, William Norris, Luke Faraone, Christian Gelinek, Chris Neugebauer, David Potter, Paul Fenwick, George Bojanov, Jondale Stratton, Kiatikun Luangkesorn, hmk, Yu Tomita, Jure Varlec, Antonin Houska, Chad Henderson, Adam Batkin, Marc Jeanmougin, Mike Dowling, Nicholas George, Leif Lindholm, Diane Trout, Daniel Walls, Donald Anderson, Darrick Wong, Greg Price, Martin Krafft, Tony Sebro, Matthew Treinish, Jason Baker, Kathy Giori, Brennen Bearnes, Olly Betts, Steven Adger, John Maloney, Gargi Sharma, Andrew Janke, Andy Kittner, Holger Levsen, Jacopo Corbetta, Andy Balaam, Justin W. Flory, Albert Chae, Elias Rudberg, Gene Hightower, Asumu Takikawa, John-Isaac Greant, Ulrich Czekalla, Bob Proulx, Nick Alcock, Geoffrey Knauth, Luke Shumaker, Stephen Hinton, Philip McGrath, Anjandev Momi, Meisam Tabriz, Alex Dryden, Thomas Schwinge, Julia Kreger, nicholas Bishop, Rachel Wonnacott, Benjamin Kraus, David Witten, Pontus Ullgren, Brendan Horan, Alex Karle, Michael Pennisi, Dave Jansen, Kit Aultman, Jason Prince, Frank Eigler, Keyhan Vakil, Daniel Whiting, tam phan, Jon Stumpf, Anna Philips, Anthony Symkowick, Drew Fustini, Anthony Mirabella, Eric Perko, Simon Michael, Rod Nayfield, Joerg Jaspert, Lieven Govaerts, David Harris, BRUST, Alexander Couzens, Amisha Singla, Athul Iddya, kyle Davis, Trace Pearson, Paul Williams, Peter Murray, anne fonteyn</p>
 
    </div>
 

	
 
    <div>
 
      <progress min="0" max="100" value="84.5" class="w-100">84.5%</progress>
 
      <div class="mv3">
 
        <div class="f2 b">$15,558</div>
 
        <div class="f5 b black-50">Remaining of the $100,000 goal</div>
 
      </div>
 
      <div class="mv3">
 
        <div class="f2 b">15 days</div>
 
        <div class="f5 b black-50">Remaining</div>
 
      </div>
 
      <div class="mt4">
 
        <a href="{% url "stripe2" %}">
 
          <button type="submit" class="pointer" style="height: 40px; width: 100%; font-size: 18px; font-weight: bold; color: white; background-color: var(--orange); border-radius: 0.5rem; border: none; border-bottom: 2px solid rgba(0,0,0,0.1);">Become a Sustainer!</button>
 
          <button type="submit" class="pointer btn" style="height: 40px; width: 100%; font-size: 18px; font-weight: bold; color: white; background-color: var(--orange); border-radius: 0.5rem; border: none; border-bottom: 2px solid rgba(0,0,0,0.1);">Become a Sustainer!</button>
 
        </a>
 
      </div>
 

	
 
      <div class="mt3">
 
        <figure>
 
          <img src="/static/img/tshirt-2023.png" alt="Software Freedom Conservancy T-shirt">
 
          <figcaption class="tc black-70" style="margin-top: -20px">Our new Sustainer T-shirt</figcaption>
 
        </figure>
 
      </div>
 
    </div>
 
  </div>
 
{% endblock %}
conservancy/supporters/templates/supporters/sustainers_stripe2.html
Show inline comments
 
{% extends "base_conservancy.html" %}
 
{% load static %}
 
{% block subtitle %}Support Conservancy - {% endblock %}
 
{% block category %}sustainer{% endblock %}
 

	
 
{% block head %}
 
  {{ block.super }}
 
  <script defer src="{% static "js/vendor/alpine-3.14.1.js" %}"></script>
 
  <style>
 
   .btn:active {
 
     transform: scale(1.05);
 
   }
 
  </style>
 
{% endblock %}
 

	
 
{% block content %}
 
  <h1 class="lh-title tc mt4 mb0">Become a Sustainer Now</h1>
 
  <p class="measure-wide center tc">Sustainers help us do our work in a strategic, long-term way.</p>
 

	
 
  <div class="bg-black-05 pa3 br3 mb4 center" style="max-width: 24rem; border: 1px solid #ccc">
 
    <form id="sustainer" method="post" action="."
 
          x-data="{
 
                    tshirt_size: 'None',
 
                    tshirt_required: function () { return this.tshirt_size !== 'None' },
 
                    recurring: 'once',
 
                  }">
 
      {% csrf_token %}
 
      {{ form.errors }}
 
      <div class="mb2"><label>Name
 
        <span class="db mt1">{{ form.name }}</span>
 
      </label></div>
 
      <div class="mb2"><label>Email
 
        <span class="db mt1">{{ form.email }}</span>
 
      </label>
 
      <p class="f7 black-60 mt1">To send your receipt</p>
 
      </div>
 
      <div class="mb2"><label>Amount
 
      <div class="mb2"><label>
 
        <label class="mr1"><input type="radio" name="recurring" value="once" x-model="recurring"> Once</label>
 
        <label><input type="radio" name="recurring" value="monthly" x-model="recurring"> Monthly</label>
 
      </label></div>
 
      <div class="mb2" x-show="recurring === 'once'"><label>Amount
 
        <span class="db mt1">$ {{ form.amount }}</span>
 
      </label></div>
 
      <div class="mb2" x-show="recurring === 'monthly'"><label>Amount
 
          <span class="db mt1">$ {{ form.amount_monthly }}</span>
 
        </label></div>
 
      <div class="mv3"><label class="lh-title"><input type="checkbox"> Acknowledge me on the public <a href="">list of sustainers</a></label></div>
 
      <div class="mv3"><label class="lh-title"><input type="checkbox"> Add me to the low-traffic <a href="https://lists.sfconservancy.org/pipermail/announce/">announcements</a> email list</label></div>
 
      <div class="mv3">
 
        <label>T-shirt:
 
          <!-- Form field has an x-model attribute in forms.py. -->
 
          <span class="db mt1">{{ form.tshirt_size }}</span>
 
        </label>
 
        <p class="f7 black-60 mt1">Sizing:
 
          <a href="https://sfconservancy.org/videos/women-2017-to-2020-t-shirt-sizing.jpg" target="_blank" class="black-60">Women's</a>,
 
          <a href="https://sfconservancy.org/videos/men-2017-to-2020-t-shirt-sizing.jpg" target="_blank" class="black-60">Men's</a></p>
 
      </div>
 
      <!-- Using Alpine.js to show/hide the address based on T-shirt choice. -->
 
      <template x-if="tshirt_required">
 
        <fieldset id="address">
 
          <legend>Postal address</legend>
 
        <div class="mb2"><label>Street
 
          <span class="db mt1">{{ form.street }}</span>
 
        </label></div>
 
        <div class="mb2"><label>City
 
          <span class="db mt1">{{ form.city }}</span>
 
        </label></div>
 
        <div class="mb2"><label>State/Region
 
          <span class="db mt1">{{ form.state }}</span>
 
        </label></div>
 
        <div class="mb2"><label>Zip/Postal
 
          <span class="db mt1">{{ form.zip_code }}</span>
 
        </label></div>
 
        <div class="mb2"><label>Country
 
          <span class="db mt1">{{ form.country }}</span>
 
        </label></div>
 
        </fieldset>
 
      </template>
 

	
 
      <div class="mt3"><button type="submit" style="height: 40px; width: 100%; font-size: 18px; font-weight: bold; color: white; background-color: var(--orange); border-radius: 0.5rem; border: none; border-bottom: 2px solid rgba(0,0,0,0.1);">Pay via Stripe</button></div>
 
      <div class="mt3"><button type="submit" class="btn" style="height: 40px; width: 100%; font-size: 18px; font-weight: bold; color: white; background-color: var(--orange); border-radius: 0.5rem; border: none; border-bottom: 2px solid rgba(0,0,0,0.1);">Pay via Stripe</button></div>
 

	
 
      <p class="f7 mt3">If you have concerns or issues paying with Stripe, we also accept payment by <a href="#">paper check</a> and <a href="#">wire transfer</a>.</p>
 
    </form>
 
  </div>
 
{% endblock %}
conservancy/supporters/views.py
Show inline comments
...
 
@@ -24,84 +24,91 @@ def sustainers(request):
 
    context = {
 
        'partial_amount': partial_amount,
 
        'minimum_amount': 120 - partial_amount,
 
    }
 
    return render(request, "supporters/sustainers.html", context)
 

	
 

	
 
def sponsors(request):
 
    """Conservancy Sponsors Page view
 

	
 
    Performs object queries necessary to render the sponsors page.
 
    """
 
    supporters = Supporter.objects.all().filter(display_until_date__gte=datetime.now())
 
    supporters_count = len(supporters)
 
    anonymous_count  = len(supporters.filter(display_name='Anonymous'))
 
    supporters = supporters.exclude(display_name='Anonymous').order_by('ledger_entity_id')
 
    c = {
 
        'supporters' : supporters,
 
        'supporters_count' : supporters_count,
 
        'anonymous_count' : anonymous_count
 
    }
 
    return render(request, "supporters/sponsors.html", c)
 

	
 

	
 
def create_checkout_session(reference_id, email, amount, base_url):
 
def create_checkout_session(reference_id, email: str, amount: int, recurring: bool, base_url: str):
 
    # https://docs.stripe.com/payments/accept-a-payment
 
    YOUR_DOMAIN = base_url
 
    try:
 
        checkout_session = stripe.checkout.Session.create(
 
            client_reference_id=str(reference_id),
 
            line_items=[
 
                {
 
                    'price_data': {
 
                        'currency': 'usd',
 
                        'product_data': {'name': 'Contribution'},
 
                        'unit_amount': amount * 100,
 
                        'unit_amount': amount * 100,  # in cents
 
                        # https://docs.stripe.com/products-prices/pricing-models#variable-pricing
 
                        'recurring': {'interval': 'month'} if recurring else None,
 
                    },
 
                    'quantity': 1,
 
                },
 
            ],
 
            customer_email=email,
 
            mode='payment',
 
            mode='subscription' if recurring else 'payment',
 
            success_url=YOUR_DOMAIN + '/sustainer/success/?session_id={CHECKOUT_SESSION_ID}',
 
            cancel_url=YOUR_DOMAIN + '/sustainer/stripe/',
 
        )
 
    except Exception as e:
 
        return str(e)
 
    return checkout_session.url
 

	
 

	
 
def sustainers_stripe(request):
 
    return render(request, 'supporters/sustainers_stripe.html', {})
 

	
 

	
 
def sustainers_stripe2(request):
 
    if request.method == 'POST':
 
        form = forms.SustainerForm(request.POST)
 
        if form.is_valid():
 
            order = form.save()
 
            order = form.save(commit=False)
 
            if form.data['recurring'] == 'monthly':
 
                order.amount = form.cleaned_data['amount_monthly']
 
                order.monthly_recurring = True
 
            order.save()
 
            base_url = f'{request.scheme}://{request.get_host()}'
 
            stripe_checkout_url = create_checkout_session(order.id, order.email, order.amount, base_url)
 
            stripe_checkout_url = create_checkout_session(order.id, order.email, order.amount, order.monthly_recurring, base_url)
 
            return redirect(stripe_checkout_url)
 
    else:
 
        form = forms.SustainerForm()
 
    return render(request, 'supporters/sustainers_stripe2.html', {'form': form})
 

	
 

	
 
stripe.api_key = 'sk_test_zaAqrpHmpkXnHQfAs4UWkE3d'
 

	
 
def fulfill_checkout(session_id):
 
    print("Fulfilling Checkout Session", session_id)
 

	
 
    # TODO: Make this function safe to run multiple times,
 
    # even concurrently, with the same session ID
 

	
 
    # TODO: Make sure fulfillment hasn't already been
 
    # peformed for this Checkout Session
 

	
 
    # Retrieve the Checkout Session from the API with line_items expanded
 
    checkout_session = stripe.checkout.Session.retrieve(
 
        session_id,
 
        expand=['line_items'],
 
    )
 

	
 
    # Check the Checkout Session's payment_status property
0 comments (0 inline, 0 general)