How it begins

I’ve been volunteering time to help the PTA at my youngest daughter’s Elementary School implement a new website and bring some web technologies to their operations. This post focuses on one aspect of this effort, fundraising.

The annual Color Run is coming soon and the PTA uses this event as one of the many small fundraisers for the organization. The question to me was, “Can we accept online donations for the students, where each student has their own unique donation page/url?” My reply… “I think I can do it, but I’ll need to experiment a little with how to make it work best. When do the online forms need to be ready? Do you have a list of all students that we need to generate a form for?”

Static Site and Source Data

I’ve using Hugo for their website, which is hosted on Netlify. While this configuration is awesome for speed, easy editing in Forestry and a secure site, not having a traditional webserver that can run scripts and processes in the backend requires some thinking ‘outside the box’. :)

I asked them if they could give me a basic set of data that they would like shown on the website. This started as an XLSX file that turned into a CSV file with the following fields.

fname,lname,teacher,grade

fname - The full first name of the student. lname - The first letter of the student’s last name. teacher - The student’s teacher’s last name. grade - The student’s grade.

Currently Hugo does not have a way of generating pages from a data file like this. Although I have hope that this may change in the future.

Python as a Page Generator

There are probably a million ways to do this, but I know that Netlify supports Python in the build system, and I know a enough Python to be dangerous, so I combined these two bits of information with a great starting point from Evan Lovely with the csv_to_jekyll script.

I modified the script a little bit to make it work for me. Here’s the source on GitHub and shown below.

NOTE: This script was removed and replaced with a new script that uses Google Sheets data instead of a CSV file. I wrote a post where you can read more about “Generating Pages for Hugo Static Site Generator using Python and Google Spreadsheets”.

# Takes in a file CSV file and outputs each row as a Markdown file with YAML front matter named after first column.
# Data in the first row of the CSV is assumed to be the column heading.
# Original work borrowed from: https://github.com/EvanLovely/csv_to_jekyll

# Import the python library for parsing CSV files.
import csv
from pathlib import Path
import os

# Delete old page files. Leave the _index.md file there.
[ os.remove("site/content/colorrun/" + f) for f in os.listdir("site/content/colorrun/") if not f.startswith("_") and f.endswith(".md") ]

# Path to the csv file to read from.
filename = "src/scripts/colorrun/colorrundatabase.csv"

# Open our data file in read-mode.
csvfile = open(filename, 'r')

# Set location to write new files to.
outputpath = Path("site/content/colorrun/")

# Create a CSV Reader object.
datareader = csv.reader(csvfile, delimiter=',', quotechar='"')

# Empty array for data headings, which we will fill with the first row from our CSV.
data_headings = []

# Loop through each row...
for row_index, row in enumerate(datareader):

  # If this is the first row, populate our data_headings variable.
  if row_index == 0:
    data_headings = row

  # Othrwise, create a YAML file from the data in this row...
  else:
    # Open a new file with filename based on the first column
    filename = row[0].lower().replace(" ", "-") + row[1].lower().replace(" ", "-") + row[3].lower() + '.md'
    outputfile = outputpath / filename
    new_yaml = open(outputfile, 'w')

    # Empty string that we will fill with YAML formatted text based on data extracted from our CSV.
    yaml_text = ""
    yaml_text += "---\n"
    yaml_text += "type: colorrun \n"
    
    # Set the Page title value.
    yaml_text += "title: Donation page for " + row[0] + " " + row[1] + ".\n"

    # Loop through each cell in this row...
    for cell_index, cell in enumerate(row):

      # Compile a line of YAML text from our headings list and the text of the current cell, followed by a linebreak.
      # Heading text is converted to lowercase. Spaces are converted to underscores and hyphens are removed.
      # In the cell text, line endings are replaced with commas.
      cell_heading = data_headings[cell_index].lower().replace(" ", "-")
      cell_text = cell_heading + ': "' + cell.replace("\n", ", ") + '"\n'

      # Add this line of text to the current YAML string.
      yaml_text += cell_text

    # Write our YAML string to the new text file and close it.
    new_yaml.write(yaml_text + "---\n")
    new_yaml.close()

# We're done! Close the CSV file.
csvfile.close()

This script nicely generates a set of files in the colorrun subdirectory of the content directory that with the frontmatter providing the base of the individual donation pages.

---
type: colorrun 
title: Donation page for Halle S.
fname: "Halle"
lname: "S"
teacher: "NG"
grade: "2"
---

Currently, I am running this script manually when I update the source data file. Eventually, I could add running this script into my build script.

NOTE: I eventually did add the new GSheets based script into the build script. I just added the Python script command before the npm build command in my Netlify toml file. You can separate commands in the build script with &&.

Stripe Checkout for the Payment Form

Now I was ready to build the layout and a partial that would provide the page design and build a unique form on each page that would associate the donation with the student.

The page layout was fairly basic, but the colorrun-form.html partial is where all the Stripe Checkout magic happens.

<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
<script src="https://checkout.stripe.com/checkout.js"></script>

<div id="content" class="mw7 center cf">
  <h3>Name: {{ .Params.fname }} {{ .Params.lname }}. - Grade: {{ .Params.grade }} </h3>
  <p>Thank you for helping me raise money for Island View PTA!  The funds will make so much possible for the students and staff at Island View Elementary, including classroom enhancements, FIRST LEGO League Jr., Reading is Fundamental's free books, ClassACT School Plays, free popcorn every month, Family Fun Nights, Watch DOGS, Lego Club, I Love to Read Month, Scholastic Magazines, Educational Software, Mountain School, and MORE!</p>
  <p>Please enter your Full Name, Email Address and Donation amount in the form below to make a donation in {{ .Params.fname }}'s name for the Color Run event. Please note that we do not store any of your credit card information.</p>

  <form name="colorrun-donation" id="colorrun-donation" class="pa2 w-30" action="https://wt-178473a4ca36b28c0d024bfdae0bd5b3-0.run.webtask.io/ivepta-stripe-payment/payment" method="POST">
    <div class="w-100 pv2">
      <label for="name">Name</label>
      <input id="name" type="text" placeholder="Enter Your Full Name" required="" autocomplete="name">
    </div>
    <div class="w-100 pv2">
      <label for="email">Email</label>
      <input id="email" type="email" placeholder="Primary Email Address" required="" autocomplete="email">
    </div>
    <div class="w-100 pv2">
        <label for="dollarAmount">Amount</label>
        <input id="dollarAmount" type="text" placeholder="$ to Donate" required="">
    </div>
    
    <input type="hidden" name="stripeToken" id="stripeToken" value="">
    <input type="hidden" name="amount" id="amount" value="">
    <input type="hidden" name="description" id="description" value="Color Run Donation for {{ .Params.fname }} {{ .Params.lname }} - {{ .Params.teacher }}.">

    <button id="donateButton" class="w-100 ba bw0 br2 mt2 ph3 pv2 dib gold bg-primary no-underline">Donate</button>
    <div id="error_explanation"></div>
  </form>
</div>

<script>
var handler = StripeCheckout.configure({
  key: 'pk_live_Ugg347ZSje2PMeJJLowOllwv',
  name: 'Color Run Donation',
  zipCode: true,
  allowRememberMe: false,
  panelLabel: 'Donate',
  description: 'Donation for {{ .Params.fname }} {{ .Params.lname }}.',
  token: function(token) {
    $('input#stripeToken').val(token.id);
    $('#colorrun-donation').submit();
  }
});

$('#donateButton').on('click', function(e) {
  e.preventDefault();

  $('#error_explanation').html('');

  var amount = $('input#dollarAmount').val();
  var email = $('input#email').val();
  amount = amount.replace(/\$/g, '').replace(/\,/g, '')

  amount = parseFloat(amount);

  if (isNaN(amount)) {
    $('#error_explanation').html("<p class='w-100 ba br2 center pa3 ma2 red bg-washed-red'>Please enter a whole dollar amount in USD ($).</p>");
  }
  else if (amount < 5.00) {
    $('#error_explanation').html("<p class='w-100 ba br2 center pa3 ma2 red bg-washed-red'>Donation amount must be at least $5.</p>");
  }
  else {
    amount = amount * 100; // Needs to be an integer!
    amount = Math.round(amount);
    $('input#amount').val(amount);
    handler.open({
      amount: amount,
      email: email
    });
  }
});

// Close Checkout on page navigation
$(window).on('popstate', function() {
  handler.close();
});

</script>

The form and the javascript for it all lives in the Hugo partial, and there I can populate the data fields with information from the front matter as each student page is generated. Neat!

This gives every student a unique URL for a donation page to give out to their friends and family.

Webtask.io for Stripe Payment Processing

Now we need a backend to process the Stripe Checkout form data. For this I found an example of Stripe payment processing with Webtask.io and so I went with a slight modification to the example. I wanted to pull the amount and description from form body data and not from URL parameters, so I used the following code for that.

Here is the node.js source script on GitHub for that.

'use latest';

import express from 'express';
import { fromExpress } from 'webtask-tools';
import bodyParser from 'body-parser';
import stripe from 'stripe';

var app = express();
app.use(bodyParser.urlencoded({extended:true}));
app.use(bodyParser.json());

app.post('/payment', (req,res) => {
  var ctx = req.webtaskContext;
  var STRIPE_SECRET_KEY = ctx.secrets.STRIPE_SECRET_KEY;

  stripe(STRIPE_SECRET_KEY).charges.create({
    amount: req.body.amount,
    currency: 'USD',
    source: req.body.stripeToken,
    receipt_email: req.body.email,
    description: req.body.description
  }, (err, charge) => {
    const status = err ? 400: 200;
    const message = err ? err.message: 'Payment done!';
    // res.writeHead(status, { 'Content-Type': 'text/html' });
    // return res.end('<h1>' + message + '</h1>');
    res.redirect('https://www.islandviewpta.org/thankyou/');
  });
});

module.exports = fromExpress(app);  

At the end of this script, I redirect to a thank you page, back on the PTA website.

Adding Donations to Google Sheet with IFTTT

Finally, I wanted to add Stripe Payment information to a Google Spreadsheet for tracking donations. I decided to use IFTTT to check Stripe transactions, filter them and write only those for the Color Run to a Google Sheet in a shared folder within the PTA Oganization’s Google Drive.

It’s super simple to setup a basic Stripe to Google Sheets applet in IFTTT, but I wanted to add a filter into the process to only pass data through to the Google Sheet if it was a Color Run donation. For this I needed to use the IFTTT Platform ‘Maker’ tools which provides the ability to make private applets and to add filter functions based on javascript code. The filter code I added is below.

var description = Stripe.newPayment.Description || ""
var test = "Color Run Donation"

if (description.indexOf(test) < 0) {
  GoogleSheets.appendToGoogleSpreadsheet.skip()
}

As it is quite easy to read, you can see I take the Description data from the Stripe payment and set it to the value passed in, or to a blank string. Then I set the comparison text that I want to test the Description text for. As this text is generated by my Stripe Payment it will be consistent and different from other transactions. I then simply test the description against my test string and if the index is less than 0 (meaning that there is not a match), then it skips the ‘then’ part of IFTTT, which would be appending data to the end of the Google Sheet.

Thoughts for the Future…

I wanted to get something working quickly and took the shortest path to implementation, using tools and tech that I knew would get me to where I wanted to be. The next pass would be optimization, here are some things I was thinking about for the future.

DONE - I could probably use an API to take student data from a Google Sheet, and then use this to generate the student page Markdown files. I would then add running the page build Python script as part of the site build script.

I might move the IFTTT bit to append to the Google Sheet into the Webtask.io function before the redirection to the Thank You page. The filtering would already happen as part of a successful transaction and I would not need to bring another service into the mix.

I might move the Webtask.io and IFTTT processes into a Netlify Lambda function. This would keep everything within Netlify and remove two external services from the mix. It’s a fairly recent feature for Netlify and I haven’t really spent enough time looking at how it all works.

Any comments, questions, suggestions or complaints are welcome.