From a621b1deb9804bf2e39fbfef5c9dfc0a9e3c9ef1 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sat, 28 Mar 2026 16:10:00 -0500 Subject: [PATCH] fix some issues wiht payments processed --- internal/payments/payments.go | 40 +++++++++++++++++++++++---- internal/web/handler.go | 8 +++--- internal/web/templates/dashboard.html | 4 +-- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/internal/payments/payments.go b/internal/payments/payments.go index 51fde4e..e66aef0 100644 --- a/internal/payments/payments.go +++ b/internal/payments/payments.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log/slog" "os" "time" @@ -45,6 +46,17 @@ func (c Config) Ready() bool { return c.SecretKey != "" && c.WebhookSecret != "" } +// PlanName returns the human-readable plan key for a Stripe price ID, +// or an empty string if the price ID isn't in the configured map. +func (c Config) PlanName(priceID string) string { + for name, id := range c.PriceIDs { + if id == priceID { + return name + } + } + return "" +} + // CreateCustomer creates a Stripe customer and returns the Stripe customer ID. func CreateCustomer(email, firstName, lastName string) (string, error) { params := &stripelib.CustomerParams{ @@ -90,10 +102,13 @@ func CreateCheckoutSession( return s.URL, nil } -// CancelSubscription cancels a Stripe subscription by ID. +// CancelSubscription schedules a Stripe subscription to cancel at the end of +// the current billing period rather than immediately. func CancelSubscription(stripeSubID string) error { - params := &stripelib.SubscriptionCancelParams{} - _, err := subscription.Cancel(stripeSubID, params) + params := &stripelib.SubscriptionParams{ + CancelAtPeriodEnd: stripelib.Bool(true), + } + _, err := subscription.Update(stripeSubID, params) if err != nil { return fmt.Errorf("stripe cancel subscription: %w", err) } @@ -101,7 +116,7 @@ func CancelSubscription(stripeSubID string) error { } // HandleCheckoutCompleted processes a checkout.session.completed webhook event. -func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error { +func HandleCheckoutCompleted(db *sql.DB, cfg Config, raw json.RawMessage) error { var cs stripelib.CheckoutSession if err := json.Unmarshal(raw, &cs); err != nil { return fmt.Errorf("unmarshal checkout session: %w", err) @@ -119,15 +134,28 @@ func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error { var customerID int64 fmt.Sscanf(customerIDStr, "%d", &customerID) + // Fetch the full subscription to get the price ID and plan name. + priceID := "" + planName := "" + sub, err := subscription.Get(cs.Subscription.ID, nil) + if err != nil { + slog.Warn("checkout completed: fetch subscription from stripe", "err", err) + } else if len(sub.Items.Data) > 0 { + priceID = sub.Items.Data[0].Price.ID + planName = cfg.PlanName(priceID) + } + now := time.Now().UTC().Format(time.RFC3339) - _, err := db.Exec( + _, err = db.Exec( `INSERT INTO subscriptions (customer_id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', '', ?, ?) ON CONFLICT(stripe_subscription_id) DO UPDATE SET status = 'active', + stripe_price_id = excluded.stripe_price_id, + plan_name = excluded.plan_name, updated_at = excluded.updated_at`, - customerID, cs.Subscription.ID, "", "", now, now, + customerID, cs.Subscription.ID, priceID, planName, now, now, ) return err } diff --git a/internal/web/handler.go b/internal/web/handler.go index b83a48e..477a8f6 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -417,7 +417,7 @@ func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) { flash = "Checkout was cancelled. No charge was made." } if r.URL.Query().Get("cancelled") == "1" { - flash = "Your subscription has been cancelled." + flash = "Your subscription has been cancelled and will not renew. You retain access until the end of the current billing period." } if r.URL.Query().Get("error") == "cancel_failed" { flash = "Could not cancel subscription. Please contact support." @@ -597,7 +597,7 @@ func (h *Handler) WebhookPOST(w http.ResponseWriter, r *http.Request) { switch event.Type { case "checkout.session.completed": - if err := payments.HandleCheckoutCompleted(h.DB, event.Data.Raw); err != nil { + if err := payments.HandleCheckoutCompleted(h.DB, h.Stripe, event.Data.Raw); err != nil { slog.Error("webhook: checkout.session.completed", "err", err) } case "invoice.paid": @@ -643,10 +643,10 @@ func (h *Handler) CancelPOST(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC().Format(time.RFC3339) _, _ = h.DB.Exec( - `UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`, + `UPDATE subscriptions SET status = 'cancelling', updated_at = ? WHERE stripe_subscription_id = ?`, now, stripeSubID, ) - slog.Info("subscription cancelled", "customer_id", customerID, "stripe_sub_id", stripeSubID) + slog.Info("subscription scheduled for cancellation", "customer_id", customerID, "stripe_sub_id", stripeSubID) http.Redirect(w, r, "/dashboard?cancelled=1", http.StatusSeeOther) } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index c000168..facd561 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -48,9 +48,9 @@ {{if .Subscription.CurrentPeriodEnd}}
- next billing date + {{if eq .Subscription.Status "cancelling"}}access until{{else}}next billing date{{end}} - {{.Subscription.CurrentPeriodEnd}} + {{.Subscription.CurrentPeriodEnd | fmtDate}}
{{end}}