diff --git a/go.mod b/go.mod index 13b56d1..718d3d3 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 6233b35..b0bb8e4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 9e6e7d3..6a42097 100644 --- a/main.go +++ b/main.go @@ -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) +} diff --git a/static/afac26.html b/static/afac26.html index 784d7a0..669c4cd 100644 --- a/static/afac26.html +++ b/static/afac26.html @@ -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; + }
@@ -67,6 +98,9 @@
@@ -79,16 +113,33 @@