Add Stripe payment integration for donations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2025-12-29 14:20:03 -08:00
parent f459023c8c
commit a5472e33dc
4 changed files with 227 additions and 13 deletions

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25.4
require (
github.com/lib/pq v1.10.9
github.com/stripe/stripe-go/v76 v76.25.0
google.golang.org/api v0.258.0
)

13
go.sum
View File

@@ -4,6 +4,7 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -29,8 +30,12 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v76 v76.25.0 h1:kmDoOTvdQSTQssQzWZQQkgbAR2Q8eXdMWbN/ylNalWA=
github.com/stripe/stripe-go/v76 v76.25.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
@@ -49,18 +54,24 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
@@ -71,5 +82,7 @@ google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

130
main.go
View File

@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"html/template"
"io"
"log"
"net/http"
"os"
@@ -15,6 +16,9 @@ import (
"strings"
_ "github.com/lib/pq"
"github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/checkout/session"
"github.com/stripe/stripe-go/v76/webhook"
"google.golang.org/api/idtoken"
)
@@ -52,9 +56,13 @@ func init() {
}
func main() {
stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
http.HandleFunc("/", handleStatic)
http.HandleFunc("/auth/google/callback", handleGoogleCallback)
http.HandleFunc("/api/rsvp/", handleRSVP)
http.HandleFunc("/api/donate/", handleDonate)
http.HandleFunc("/api/stripe/webhook", handleStripeWebhook)
log.Println("server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
@@ -174,23 +182,18 @@ func handleRSVP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
var numPeople int
var totalPeople int
err := db.QueryRow("SELECT num_people FROM rsvps WHERE event_id = $1 AND google_username = $2", eventID, email).Scan(&numPeople)
var donation float64
err := db.QueryRow("SELECT num_people, donation FROM rsvps WHERE event_id = $1 AND google_username = $2", eventID, email).Scan(&numPeople, &donation)
if err == sql.ErrNoRows {
numPeople = 0
donation = 0
} else if err != nil {
log.Println("[ERROR] failed to query rsvp:", err)
http.Error(w, "database error", http.StatusInternalServerError)
return
}
err = db.QueryRow("SELECT COALESCE(SUM(num_people), 0) FROM rsvps WHERE event_id = $1", eventID).Scan(&totalPeople)
if err != nil {
log.Println("[ERROR] failed to query total:", err)
http.Error(w, "database error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"numPeople": numPeople, "totalPeople": totalPeople})
json.NewEncoder(w).Encode(map[string]any{"numPeople": numPeople, "donation": donation})
case http.MethodPost:
var req struct {
@@ -209,12 +212,115 @@ func handleRSVP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
var totalPeople int
db.QueryRow("SELECT COALESCE(SUM(num_people), 0) FROM rsvps WHERE event_id = $1", eventID).Scan(&totalPeople)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"numPeople": req.NumPeople, "totalPeople": totalPeople})
json.NewEncoder(w).Encode(map[string]int{"numPeople": req.NumPeople})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleDonate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
eventID := strings.TrimPrefix(r.URL.Path, "/api/donate/")
if eventID == "" {
http.Error(w, "missing event id", http.StatusBadRequest)
return
}
token := r.Header.Get("Authorization")
email, ok := verifyToken(strings.TrimPrefix(token, "Bearer "))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Amount int64 `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Amount < 100 {
http.Error(w, "minimum donation is $1", http.StatusBadRequest)
return
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
Currency: stripe.String("usd"),
ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
Name: stripe.String("Donation - Applause for a Cause"),
},
UnitAmount: stripe.Int64(req.Amount),
},
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(os.Getenv("BASE_URL") + "/" + eventID + ".html?donated=1"),
CancelURL: stripe.String(os.Getenv("BASE_URL") + "/" + eventID + ".html"),
Metadata: map[string]string{
"event_id": eventID,
"email": email,
},
}
s, err := session.New(params)
if err != nil {
log.Println("[ERROR] failed to create checkout session:", err)
http.Error(w, "failed to create checkout session", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": s.URL})
}
func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), os.Getenv("STRIPE_WEBHOOK_SECRET"))
if err != nil {
log.Println("[ERROR] failed to verify webhook:", err)
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
if event.Type == "checkout.session.completed" {
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
log.Println("[ERROR] failed to parse session:", err)
http.Error(w, "failed to parse session", http.StatusBadRequest)
return
}
eventID := sess.Metadata["event_id"]
email := sess.Metadata["email"]
amount := float64(sess.AmountTotal) / 100
_, err := db.Exec(`
UPDATE rsvps SET donation = donation + $3
WHERE event_id = $1 AND google_username = $2
`, eventID, email, amount)
if err != nil {
log.Println("[ERROR] failed to update donation:", err)
} else {
log.Printf("recorded donation of $%.2f from %s for %s", amount, email, eventID)
}
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -56,6 +56,37 @@
gap: 1rem;
margin-top: 1rem;
}
.donation-options {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--wa-color-neutral-80);
}
.donation-options label {
display: block;
margin: 0.5rem 0;
cursor: pointer;
}
.custom-amount {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: 1.5rem;
}
.thank-you {
background: var(--wa-color-success-95);
color: var(--wa-color-success-30);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.wa-dark .thank-you {
background: var(--wa-color-success-20);
color: var(--wa-color-success-90);
}
.donation-info {
margin-top: 0.5rem;
opacity: 0.7;
}
</style>
</head>
<body style="opacity: 0">
@@ -67,6 +98,9 @@
<span class="spacer"></span>
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
</div>
<div id="thank-you" class="thank-you" style="display: none;">
Thank you for your generous donation!
</div>
<wa-card>
<div class="event-header">
<img src="/afac26-logo.png" alt="Applause for a Cause">
@@ -79,16 +113,33 @@
<div class="rsvp-controls">
<span>Number of people:</span>
<wa-input type="number" id="num-people" min="1" value="1" style="width: 80px;"></wa-input>
<wa-button variant="brand" id="rsvp-btn">RSVP</wa-button>
</div>
<div class="donation-options">
<strong>Would you like to make a donation?</strong>
<label><input type="radio" name="donation" value="25" checked> $25 suggested donation per family</label>
<label><input type="radio" name="donation" value="custom"> Other amount</label>
<div class="custom-amount" id="custom-amount-row" style="display: none;">
$<wa-input type="number" id="custom-amount" min="1" value="25" style="width: 100px;"></wa-input>
</div>
<label><input type="radio" name="donation" value="0"> Maybe later</label>
</div>
<wa-button variant="brand" id="rsvp-btn" style="margin-top: 1rem;">RSVP</wa-button>
</div>
<div id="rsvp-status" style="display: none;">
<strong id="rsvp-message"></strong>
<div id="donation-status" class="donation-info"></div>
<div class="rsvp-controls">
<span>Change to:</span>
<wa-input type="number" id="num-people-update" min="0" value="1" style="width: 80px;"></wa-input>
<wa-button variant="neutral" id="update-btn">Update</wa-button>
</div>
<div class="donation-options">
<strong>Make an additional donation?</strong>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;">
$<wa-input type="number" id="additional-amount" min="1" value="25" style="width: 100px;"></wa-input>
<wa-button variant="neutral" id="donate-btn">Donate</wa-button>
</div>
</div>
</div>
</div>
</wa-card>
@@ -107,6 +158,18 @@
const eventId = 'afac26';
if (new URLSearchParams(location.search).get('donated') === '1') {
document.getElementById('thank-you').style.display = 'block';
history.replaceState({}, '', location.pathname);
}
document.querySelectorAll('input[name="donation"]').forEach(radio => {
radio.addEventListener('change', () => {
document.getElementById('custom-amount-row').style.display =
radio.value === 'custom' && radio.checked ? 'flex' : 'none';
});
});
async function loadRSVP() {
const data = await api('GET', `/api/rsvp/${eventId}`);
updateUI(data);
@@ -119,16 +182,40 @@
const word = data.numPeople === 1 ? 'person' : 'people';
document.getElementById('rsvp-message').textContent = `You're RSVPed for ${data.numPeople} ${word}.`;
document.getElementById('num-people-update').value = data.numPeople;
if (data.donation > 0) {
document.getElementById('donation-status').textContent = `You've donated $${data.donation.toFixed(2)}. Thank you!`;
} else {
document.getElementById('donation-status').textContent = '';
}
} else {
document.getElementById('rsvp-prompt').style.display = 'block';
document.getElementById('rsvp-status').style.display = 'none';
}
}
function getSelectedDonation() {
const selected = document.querySelector('input[name="donation"]:checked');
if (!selected || selected.value === '0') return 0;
if (selected.value === 'custom') {
return parseInt(document.getElementById('custom-amount').value) || 0;
}
return parseInt(selected.value);
}
async function startDonation(amount) {
const data = await api('POST', `/api/donate/${eventId}`, { amount: amount * 100 });
location.href = data.url;
}
document.getElementById('rsvp-btn').addEventListener('click', async () => {
const numPeople = parseInt(document.getElementById('num-people').value) || 1;
const data = await api('POST', `/api/rsvp/${eventId}`, { numPeople });
updateUI(data);
const donationAmount = getSelectedDonation();
if (donationAmount > 0) {
await startDonation(donationAmount);
}
});
document.getElementById('update-btn').addEventListener('click', async () => {
@@ -137,6 +224,13 @@
updateUI(data);
});
document.getElementById('donate-btn').addEventListener('click', async () => {
const amount = parseInt(document.getElementById('additional-amount').value) || 0;
if (amount > 0) {
await startDonation(amount);
}
});
await loadRSVP();
await customElements.whenDefined('wa-card');