diff --git a/src/templates/src/manage_subscription.html b/src/templates/src/manage_subscription.html
deleted file mode 100644
index 3b302a7a..00000000
--- a/src/templates/src/manage_subscription.html
+++ /dev/null
@@ -1,186 +0,0 @@
-{{ template "base-2024.html" . }}
-
-{{ define "content" }}
-
-
Manage your membership
-
-
- {{ if .User.IsSubscribed }}
-
Thank you for being a supporter! Your recurring donation helps us maintain the site, host events, and
- advocate for better software.
-
- {{ template "supporter_card" . }}
-
- {{ if .IsInGracePeriod }}
-
-
- We couldn't process your latest payment. Your membership benefits remain active until {{ .GracePeriodEnd }}.
- Please update your payment method to avoid losing access.
-
-
- {{ else }}
-
-
- {{ if .User.CancelAtPeriodEnd }}
- Your membership will end at the end of the current billing period on {{ .CurrentPeriodEnd }}.
- {{ else }}
- {{ if and .LastPaymentAmount .LastPaymentMethod }}
- Your payment method on file ({{ .LastPaymentMethod }}) will be automatically charged for {{
- .LastPaymentAmount }} on {{ .CurrentPeriodEnd }}.
- {{ else if .LastPaymentAmount }}
- Your payment method on file will be automatically charged for {{ .LastPaymentAmount }} on {{
- .CurrentPeriodEnd }}.
- {{ else }}
- Your payment method on file will be automatically charged on {{ .CurrentPeriodEnd }}.
- {{ end }}
- {{ end }}
-
-
- {{ end }}
-
- {{ if .PaymentHistory }}
-
- Payment
- History ▼
-
- {{ range .PaymentHistory }}
- -
-
·
-
-
{{ .Date }}
-
{{ .Amount }}{{ if .CardInfo }} · {{ .CardInfo }}{{ end }}
-
-
- {{ end }}
-
-
- {{ end }}
-
- {{ else }}
-
The Handmade Network is supported by the community. By becoming a supporter, you help us stay independent and
- focused on our mission. You'll also get some nice perks on our Discord!
-
- {{ template "supporter_card" . }}
- {{ end }}
-
-
-{{ end }}
-
-{{ define "supporter_card" }}
-
-
Supporter Benefits
-
-
-
-
-
- {{ if .User.IsSubscribed }}
- {{ .CurrentCurrencySymbol }}
- {{ else }}
-
- {{ .CurrentCurrencySymbol }}▼
-
-
- $ USD
-
- {{ if .EurMembershipPriceID }}
-
- € EUR
-
- {{ end }}
-
-
- {{ end }}
- {{ if .CurrentAmount }}{{ .CurrentAmount }}{{ else }}5.00{{ end }}/mo
-
- {{ if and .User.IsSubscribed .User.CancelAtPeriodEnd }}
-
- {{ else }}
-
- {{ end }}
-
-
-
-
-{{ end }}
\ No newline at end of file
diff --git a/src/templates/types.go b/src/templates/types.go
index 1bf423cf..2c01bf54 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -80,6 +80,10 @@ type Header struct {
BannerEvent *BannerEvent
SuppressBanners bool
+
+ ShowMembershipVerificationBanner bool
+ MembershipVerificationUrl string
+ MembershipGraceDaysRemaining int
}
type BannerEvent struct {
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 6036e775..85eb29e3 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -10,6 +10,7 @@ import (
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
+ "github.com/stripe/stripe-go/v84"
)
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
@@ -131,6 +132,18 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
if c.CurrentUser != nil {
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
+ if userNeedsBankVerificationReminder(c.CurrentUser) {
+ baseData.Header.ShowMembershipVerificationBanner = true
+ bannerURL := hmnurl.BuildHSFMembership()
+ if c.CurrentUser.StripeSubscriptionID != nil && config.Config.Stripe.SecretKey != "" {
+ sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ if hostedURL := hostedBankVerificationURL(c, sc, *c.CurrentUser.StripeSubscriptionID); hostedURL != "" {
+ bannerURL = hostedURL
+ }
+ }
+ baseData.Header.MembershipVerificationUrl = bannerURL
+ baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow())
+ }
}
if !project.IsHMN() {
diff --git a/src/website/hsf.go b/src/website/hsf.go
index 78e61c8e..80422bdf 100644
--- a/src/website/hsf.go
+++ b/src/website/hsf.go
@@ -32,7 +32,7 @@ func HSFMembership(c *RequestContext) ResponseData {
baseData.HideMembershipCTA = true
var res ResponseData
- res.MustWriteTemplate("hsf_membership.html", baseData, c.Perf)
+ res.MustWriteTemplate("hsf_membership.html", buildMembershipPageData(c, baseData), c.Perf)
return res
}
diff --git a/src/website/routes.go b/src/website/routes.go
index 584156a5..c57791eb 100644
--- a/src/website/routes.go
+++ b/src/website/routes.go
@@ -151,7 +151,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
hmnOnly.GET(hmnurl.RegexHSFLanding, HSFLanding)
hmnOnly.GET(hmnurl.RegexHSFDetails, HSFDetails)
hmnOnly.GET(hmnurl.RegexHSFMembership, HSFMembership)
- hmnOnly.GET(hmnurl.RegexSubscriptionManage, needsAuth(SubscriptionManage))
+ hmnOnly.GET(hmnurl.RegexSubscriptionManage, SubscriptionManageRedirect)
hmnOnly.POST(hmnurl.RegexSubscriptionSubscribe, needsAuth(csrfMiddleware(SubscriptionSubscribe)))
hmnOnly.POST(hmnurl.RegexSubscriptionCancel, needsAuth(csrfMiddleware(SubscriptionCancel)))
hmnOnly.POST(hmnurl.RegexSubscriptionResume, needsAuth(csrfMiddleware(SubscriptionResume)))
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index f758af4f..29cabbeb 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -149,6 +149,36 @@ func userInGracePeriod(user *models.User) bool {
return user != nil && user.SubscriptionStatus != nil && *user.SubscriptionStatus == SubscriptionStatusGracePeriod
}
+func userNeedsBankVerificationReminder(user *models.User) bool {
+ if user == nil || user.SubscriptionStatus == nil {
+ return false
+ }
+ switch *user.SubscriptionStatus {
+ case SubscriptionStatusPendingVerification, "incomplete":
+ return true
+ case SubscriptionStatusGracePeriod:
+ return user.IsSubscribed
+ default:
+ return false
+ }
+}
+
+func gracePeriodDaysRemaining(user *models.User, now time.Time) int {
+ if user == nil || user.GracePeriodEndsAt == nil || !user.GracePeriodEndsAt.After(now) {
+ return 0
+ }
+
+ hoursRemaining := user.GracePeriodEndsAt.Sub(now).Hours()
+ days := int(hoursRemaining / 24)
+ if hoursRemaining > float64(days*24) {
+ days++
+ }
+ if days < 1 {
+ return 1
+ }
+ return days
+}
+
func StartSubscriptionGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error {
return startGracePeriod(ctx, conn, userID, SubscriptionNow())
}
diff --git a/src/website/subscription_grace_test.go b/src/website/subscription_grace_test.go
index e4d25342..185a01f3 100644
--- a/src/website/subscription_grace_test.go
+++ b/src/website/subscription_grace_test.go
@@ -80,3 +80,43 @@ func TestUserInGracePeriod(t *testing.T) {
assert.True(t, userInGracePeriod(&models.User{SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod)}))
assert.False(t, userInGracePeriod(&models.User{SubscriptionStatus: statusPtr("active")}))
}
+
+func TestGracePeriodDaysRemaining(t *testing.T) {
+ now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC)
+
+ assert.Equal(t, 0, gracePeriodDaysRemaining(nil, now))
+ assert.Equal(t, 0, gracePeriodDaysRemaining(&models.User{}, now))
+
+ user := &models.User{GracePeriodEndsAt: timePtr(now.Add(6 * time.Hour))}
+ assert.Equal(t, 1, gracePeriodDaysRemaining(user, now))
+
+ user.GracePeriodEndsAt = timePtr(now.Add(7 * 24 * time.Hour))
+ assert.Equal(t, 7, gracePeriodDaysRemaining(user, now))
+
+ user.GracePeriodEndsAt = timePtr(now.Add(7*24*time.Hour + time.Hour))
+ assert.Equal(t, 8, gracePeriodDaysRemaining(user, now))
+
+ user.GracePeriodEndsAt = timePtr(now.Add(-time.Hour))
+ assert.Equal(t, 0, gracePeriodDaysRemaining(user, now))
+}
+
+func TestUserNeedsBankVerificationReminder(t *testing.T) {
+ assert.True(t, userNeedsBankVerificationReminder(&models.User{
+ SubscriptionStatus: statusPtr(SubscriptionStatusPendingVerification),
+ }))
+ assert.True(t, userNeedsBankVerificationReminder(&models.User{
+ SubscriptionStatus: statusPtr("incomplete"),
+ }))
+ assert.True(t, userNeedsBankVerificationReminder(&models.User{
+ IsSubscribed: true,
+ SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod),
+ }))
+ assert.False(t, userNeedsBankVerificationReminder(&models.User{
+ IsSubscribed: false,
+ SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod),
+ }))
+ assert.False(t, userNeedsBankVerificationReminder(&models.User{
+ IsSubscribed: true,
+ SubscriptionStatus: statusPtr("active"),
+ }))
+}
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index bd8b53f5..f4fddb55 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -93,8 +93,15 @@ type ManageSubscriptionTemplateData struct {
EurMembershipPriceID string
}
-func SubscriptionManage(c *RequestContext) ResponseData {
+func SubscriptionManageRedirect(c *RequestContext) ResponseData {
+ target := hmnurl.BuildHSFMembership()
+ if query := c.Req.URL.RawQuery; query != "" {
+ target += "?" + query
+ }
+ return c.Redirect(target, http.StatusSeeOther)
+}
+func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) ManageSubscriptionTemplateData {
// If the user just completed checkout, Stripe redirects with a session_id.
// Verify it so we can show the correct "subscribed" view even if webhooks
// haven't updated the DB yet.
@@ -170,9 +177,8 @@ func SubscriptionManage(c *RequestContext) ResponseData {
}
}
- var res ResponseData
- res.MustWriteTemplate("manage_subscription.html", ManageSubscriptionTemplateData{
- BaseData: getBaseData(c, "Manage Membership", nil),
+ return ManageSubscriptionTemplateData{
+ BaseData: baseData,
SubscribeUrl: hmnurl.BuildSubscriptionSubscribe(),
CancelSubscriptionUrl: hmnurl.BuildSubscriptionCancel(),
ResumeSubscriptionUrl: hmnurl.BuildSubscriptionResume(),
@@ -186,16 +192,15 @@ func SubscriptionManage(c *RequestContext) ResponseData {
IsInGracePeriod: isInGracePeriod,
DefaultMembershipPriceID: config.Config.Stripe.PriceID,
EurMembershipPriceID: eurPriceID,
- }, c.Perf)
- return res
+ }
}
func SubscriptionSubscribe(c *RequestContext) ResponseData {
if c.CurrentUser.IsSubscribed {
- return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther)
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
if userInGracePeriod(c.CurrentUser) {
- return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther)
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
if err := c.Req.ParseForm(); err != nil {
@@ -214,8 +219,8 @@ func SubscriptionSubscribe(c *RequestContext) ResponseData {
params := &stripe.CheckoutSessionCreateParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
- SuccessURL: stripe.String(hmnurl.BuildSubscriptionManage() + "?session_id={CHECKOUT_SESSION_ID}"),
- CancelURL: stripe.String(hmnurl.BuildSubscriptionManage()),
+ SuccessURL: stripe.String(hmnurl.BuildHSFMembership() + "?session_id={CHECKOUT_SESSION_ID}"),
+ CancelURL: stripe.String(hmnurl.BuildHSFMembership()),
ClientReferenceID: stripe.String(strconv.Itoa(c.CurrentUser.ID)),
LineItems: []*stripe.CheckoutSessionCreateLineItemParams{
{
@@ -282,7 +287,7 @@ func SubscriptionSubscribe(c *RequestContext) ResponseData {
func SubscriptionCancel(c *RequestContext) ResponseData {
if c.CurrentUser.StripeSubscriptionID == nil {
- return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther)
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
@@ -300,12 +305,12 @@ func SubscriptionCancel(c *RequestContext) ResponseData {
logging.Error().Err(err).Msg("failed to update user cancel_at_period_end optimistically")
}
- return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther)
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
func SubscriptionResume(c *RequestContext) ResponseData {
if c.CurrentUser.StripeSubscriptionID == nil {
- return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther)
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
if c.CurrentUser.CurrentPeriodEnd == nil || c.CurrentUser.CurrentPeriodEnd.Before(SubscriptionNow()) {
@@ -327,7 +332,7 @@ func SubscriptionResume(c *RequestContext) ResponseData {
logging.Error().Err(err).Msg("failed to update user cancel_at_period_end optimistically")
}
- return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther)
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
@@ -364,6 +369,52 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
return
}
+ if session.Customer == nil || session.Subscription == nil {
+ logging.Error().Int("userID", userID).Msg("checkout.session.completed missing customer or subscription")
+ return
+ }
+
+ if session.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
+ user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to fetch user for pending checkout session")
+ return
+ }
+
+ _, err = c.Conn.Exec(c, `
+ UPDATE hmn_user
+ SET
+ is_subscribed = true,
+ stripe_customer_id = $1,
+ stripe_subscription_id = $2,
+ cancel_at_period_end = false
+ WHERE id = $3
+ `, session.Customer.ID, session.Subscription.ID, userID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to link pending checkout subscription")
+ return
+ }
+
+ now := SubscriptionNow()
+ if canStartGrace(user, now) {
+ if err := startGracePeriod(c, c.Conn, userID, now); err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for pending checkout payment")
+ }
+ } else if !isGraceActive(user, now) {
+ _, err = c.Conn.Exec(c, `
+ UPDATE hmn_user
+ SET subscription_status = $1
+ WHERE id = $2
+ `, "incomplete", userID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to mark subscription incomplete after pending checkout")
+ }
+ }
+
+ logging.Info().Int("userID", userID).Str("paymentStatus", string(session.PaymentStatus)).Msg("checkout completed with pending payment")
+ return
+ }
+
user, err := db.QueryOne[models.User](c, c.Conn, `
UPDATE hmn_user
SET
@@ -786,3 +837,50 @@ func sendThankYouEmail(c *RequestContext, user *models.User, renewalDate *time.T
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send thank you email")
}
}
+
+func hostedBankVerificationURL(ctx context.Context, sc *stripe.Client, subscriptionID string) string {
+ if sc == nil || subscriptionID == "" {
+ return ""
+ }
+
+ params := &stripe.SubscriptionRetrieveParams{}
+ params.AddExpand("latest_invoice")
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, params)
+ if err != nil {
+ logging.Warn().Err(err).Str("subscriptionID", subscriptionID).Msg("failed to retrieve subscription for bank verification banner")
+ return ""
+ }
+ if sub.LatestInvoice == nil || sub.LatestInvoice.ID == "" {
+ return ""
+ }
+
+ listParams := &stripe.InvoicePaymentListParams{
+ Invoice: stripe.String(sub.LatestInvoice.ID),
+ }
+ listParams.AddExpand("data.payment.payment_intent")
+
+ var hostedURL string
+ sc.V1InvoicePayments.List(ctx, listParams)(func(ip *stripe.InvoicePayment, err error) bool {
+ if err != nil {
+ logging.Warn().Err(err).Str("invoiceID", sub.LatestInvoice.ID).Msg("failed to list invoice payments for bank verification banner")
+ return false
+ }
+ if ip == nil || ip.Payment == nil || ip.Payment.PaymentIntent == nil {
+ return true
+ }
+ if url := paymentIntentHostedVerificationURL(ip.Payment.PaymentIntent); url != "" {
+ hostedURL = url
+ return false
+ }
+ return true
+ })
+
+ return hostedURL
+}
+
+func paymentIntentHostedVerificationURL(pi *stripe.PaymentIntent) string {
+ if pi == nil || pi.NextAction == nil || pi.NextAction.VerifyWithMicrodeposits == nil {
+ return ""
+ }
+ return pi.NextAction.VerifyWithMicrodeposits.HostedVerificationURL
+}
From f4d011c285035c45bae93ae64020e3610cee2dc6 Mon Sep 17 00:00:00 2001
From: reece365
Date: Fri, 29 May 2026 04:44:40 -0500
Subject: [PATCH 05/15] Modifyed grace period to end on failed payment
---
src/hmnurl/hmnurl_test.go | 1 +
src/hmnurl/urls.go | 10 ++
src/templates/src/hsf_membership.html | 44 +++--
src/website/hsf.go | 14 ++
src/website/routes.go | 1 +
src/website/stripe.go | 8 +
src/website/subscription_grace.go | 102 +++++++++++
src/website/subscription_grace_eligibility.go | 170 ++++++++++++++++++
.../subscription_grace_eligibility_test.go | 44 +++++
src/website/subscription_grace_revoke.go | 29 +++
src/website/subscription_grace_test.go | 23 +++
.../subscription_payment_intent_webhook.go | 76 ++++++++
src/website/subscription_payment_retry.go | 67 +++++++
src/website/subscriptions.go | 126 ++++++++++---
14 files changed, 673 insertions(+), 42 deletions(-)
create mode 100644 src/website/subscription_grace_eligibility.go
create mode 100644 src/website/subscription_grace_eligibility_test.go
create mode 100644 src/website/subscription_grace_revoke.go
create mode 100644 src/website/subscription_payment_intent_webhook.go
create mode 100644 src/website/subscription_payment_retry.go
diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go
index 8b4ce43f..2232c1ed 100644
--- a/src/hmnurl/hmnurl_test.go
+++ b/src/hmnurl/hmnurl_test.go
@@ -550,6 +550,7 @@ func TestFoundationSubscriptionBuildUrls(t *testing.T) {
AssertRegexMatch(t, BuildSubscriptionSubscribe(), RegexSubscriptionSubscribe, nil)
AssertRegexMatch(t, BuildSubscriptionCancel(), RegexSubscriptionCancel, nil)
AssertRegexMatch(t, BuildSubscriptionResume(), RegexSubscriptionResume, nil)
+ AssertRegexMatch(t, BuildSubscriptionUpdatePaymentMethod(), RegexSubscriptionUpdatePaymentMethod, nil)
}
func TestThingsThatDontNeedCoverage(t *testing.T) {
diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go
index 0c5c1981..11ee1ed2 100644
--- a/src/hmnurl/urls.go
+++ b/src/hmnurl/urls.go
@@ -1083,6 +1083,10 @@ func BuildHSFMembership() string {
return Url("/foundation/membership", nil)
}
+func BuildHSFMembershipPaymentMethodReturn() string {
+ return Url("/foundation/membership", []Q{{Name: "payment_method_updated", Value: "1"}})
+}
+
var RegexSubscriptionManage = regexp.MustCompile(`^/foundation/membership/manage$`)
func BuildSubscriptionManage() string {
@@ -1107,6 +1111,12 @@ func BuildSubscriptionResume() string {
return Url("/foundation/membership/resume", nil)
}
+var RegexSubscriptionUpdatePaymentMethod = regexp.MustCompile(`^/foundation/membership/update-payment-method$`)
+
+func BuildSubscriptionUpdatePaymentMethod() string {
+ return Url("/foundation/membership/update-payment-method", nil)
+}
+
/*
* Perf
*/
diff --git a/src/templates/src/hsf_membership.html b/src/templates/src/hsf_membership.html
index c0598969..20d4787f 100644
--- a/src/templates/src/hsf_membership.html
+++ b/src/templates/src/hsf_membership.html
@@ -130,24 +130,32 @@ Supporter Benefits
{{ end }}
{{ if .CurrentAmount }}{{ .CurrentAmount }}{{ else }}5.00{{ end }}/mo
- {{ if and .User.IsSubscribed .User.CancelAtPeriodEnd }}
-
- {{ else }}
-
- {{ end }}
+
+ {{ if .User.IsSubscribed }}
+
+ {{ end }}
+ {{ if and .User.IsSubscribed .User.CancelAtPeriodEnd }}
+
+ {{ else }}
+
+ {{ end }}
+
{{ else }}
diff --git a/src/website/hsf.go b/src/website/hsf.go
index 80422bdf..52715c91 100644
--- a/src/website/hsf.go
+++ b/src/website/hsf.go
@@ -1,8 +1,12 @@
package website
import (
+ "git.handmade.network/hmn/hmn/src/config"
+ "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
+ "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/templates"
+ "github.com/stripe/stripe-go/v84"
)
func HSFLanding(c *RequestContext) ResponseData {
@@ -23,6 +27,16 @@ func HSFDetails(c *RequestContext) ResponseData {
}
func HSFMembership(c *RequestContext) ResponseData {
+ if c.Req.URL.Query().Get("payment_method_updated") == "1" && c.CurrentUser != nil {
+ sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, c.CurrentUser); err != nil {
+ c.Logger.Warn().Err(err).Msg("failed to retry subscription payment after billing portal return")
+ }
+ if user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", c.CurrentUser.ID); err == nil {
+ c.CurrentUser = user
+ }
+ }
+
breadcrumbs := []templates.Breadcrumb{
hsfBaseBreadcrumb,
{Name: "Membership", Url: hmnurl.BuildHSFMembership()},
diff --git a/src/website/routes.go b/src/website/routes.go
index c57791eb..7359e787 100644
--- a/src/website/routes.go
+++ b/src/website/routes.go
@@ -155,6 +155,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
hmnOnly.POST(hmnurl.RegexSubscriptionSubscribe, needsAuth(csrfMiddleware(SubscriptionSubscribe)))
hmnOnly.POST(hmnurl.RegexSubscriptionCancel, needsAuth(csrfMiddleware(SubscriptionCancel)))
hmnOnly.POST(hmnurl.RegexSubscriptionResume, needsAuth(csrfMiddleware(SubscriptionResume)))
+ hmnOnly.POST(hmnurl.RegexSubscriptionUpdatePaymentMethod, needsAuth(csrfMiddleware(SubscriptionUpdatePaymentMethod)))
hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine)
hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions)
diff --git a/src/website/stripe.go b/src/website/stripe.go
index 7e395305..944492dc 100644
--- a/src/website/stripe.go
+++ b/src/website/stripe.go
@@ -40,6 +40,14 @@ func StripeWebhook(c *RequestContext) ResponseData {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ if isMembershipGracePaymentRetryEvent(&event) {
+ handleMembershipGracePaymentRetryWebhook(c, sc, &event)
+ }
+
+ if tryHandleMembershipPaymentIntentWebhook(c, sc, &event) {
+ return ResponseData{StatusCode: http.StatusOK}
+ }
+
priceIDs, err := stripePriceIDsForEvent(c, sc, &event)
if err != nil {
c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to resolve price IDs for stripe event")
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index 29cabbeb..925771d5 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -8,6 +8,7 @@ import (
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
+ "github.com/stripe/stripe-go/v84"
)
const (
@@ -186,3 +187,104 @@ func StartSubscriptionGracePeriod(ctx context.Context, conn db.ConnOrTx, userID
func ExpireSubscriptionGracePeriods(ctx context.Context, conn db.ConnOrTx) (int64, error) {
return expireDueGracePeriods(ctx, conn, SubscriptionNow())
}
+
+func shouldRetrySubscriptionPayment(user *models.User) bool {
+ if user == nil || user.StripeCustomerID == nil || user.StripeSubscriptionID == nil {
+ return false
+ }
+ if userInGracePeriod(user) {
+ return true
+ }
+ if !user.IsSubscribed || user.SubscriptionStatus == nil {
+ return false
+ }
+ switch *user.SubscriptionStatus {
+ case SubscriptionStatusPendingVerification, "incomplete", "past_due", "unpaid":
+ return true
+ default:
+ return false
+ }
+}
+
+func retryPastDueSubscriptionPayment(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, user *models.User) error {
+ if !shouldRetrySubscriptionPayment(user) {
+ return nil
+ }
+
+ invoiceID, err := findOpenSubscriptionInvoice(ctx, sc, *user.StripeCustomerID, *user.StripeSubscriptionID)
+ if err != nil {
+ return err
+ }
+ if invoiceID == "" {
+ logging.Info().Int("userID", user.ID).Msg("no open subscription invoice to retry")
+ return nil
+ }
+
+ inv, err := sc.V1Invoices.Pay(ctx, invoiceID, &stripe.InvoicePayParams{})
+ if err != nil {
+ return err
+ }
+
+ logging.Info().Int("userID", user.ID).Str("invoiceID", invoiceID).Str("status", string(inv.Status)).Msg("retried open subscription invoice payment")
+
+ if inv.Status == stripe.InvoiceStatusPaid {
+ if err := clearGracePeriod(ctx, conn, user.ID); err != nil {
+ return err
+ }
+ _, err = conn.Exec(ctx, `
+ UPDATE hmn_user
+ SET is_subscribed = true, subscription_status = 'active'
+ WHERE id = $1
+ `, user.ID)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func findOpenSubscriptionInvoice(ctx context.Context, sc *stripe.Client, customerID, subscriptionID string) (string, error) {
+ subParams := &stripe.SubscriptionRetrieveParams{}
+ subParams.AddExpand("latest_invoice")
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, subParams)
+ if err != nil {
+ return "", err
+ }
+ if sub.LatestInvoice != nil && sub.LatestInvoice.Status == stripe.InvoiceStatusOpen && sub.LatestInvoice.AmountRemaining > 0 {
+ return sub.LatestInvoice.ID, nil
+ }
+
+ listParams := &stripe.InvoiceListParams{
+ Customer: stripe.String(customerID),
+ Status: stripe.String(string(stripe.InvoiceStatusOpen)),
+ }
+ var invoiceID string
+ var listErr error
+ sc.V1Invoices.List(ctx, listParams)(func(inv *stripe.Invoice, err error) bool {
+ if err != nil {
+ listErr = err
+ return false
+ }
+ if inv == nil || inv.AmountRemaining <= 0 {
+ return true
+ }
+ if !invoiceBelongsToSubscription(inv, subscriptionID) {
+ return true
+ }
+ invoiceID = inv.ID
+ return false
+ })
+ if listErr != nil {
+ return "", listErr
+ }
+ return invoiceID, nil
+}
+
+func invoiceBelongsToSubscription(inv *stripe.Invoice, subscriptionID string) bool {
+ if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil {
+ return false
+ }
+ sub := inv.Parent.SubscriptionDetails.Subscription
+ return sub != nil && sub.ID == subscriptionID
+}
diff --git a/src/website/subscription_grace_eligibility.go b/src/website/subscription_grace_eligibility.go
new file mode 100644
index 00000000..837a9d96
--- /dev/null
+++ b/src/website/subscription_grace_eligibility.go
@@ -0,0 +1,170 @@
+package website
+
+import (
+ "context"
+
+ "github.com/stripe/stripe-go/v84"
+)
+
+func isAsyncPaymentMethodType(pmType string) bool {
+ switch pmType {
+ case "us_bank_account", "acss_debit", "sepa_debit":
+ return true
+ default:
+ return false
+ }
+}
+
+func paymentIntentHasMicrodepositVerification(pi *stripe.PaymentIntent) bool {
+ if pi == nil || pi.NextAction == nil {
+ return false
+ }
+ return pi.NextAction.Type == stripe.PaymentIntentNextActionTypeVerifyWithMicrodeposits
+}
+
+// shouldGrantGraceForPaymentIntent returns true when payment is in-flight for an async
+// method (e.g. ACH processing or microdeposit verification), not a card decline.
+func shouldGrantGraceForPaymentIntent(pi *stripe.PaymentIntent, paymentMethodType string) bool {
+ if pi == nil || !isAsyncPaymentMethodType(paymentMethodType) {
+ return false
+ }
+ switch pi.Status {
+ case stripe.PaymentIntentStatusProcessing:
+ return true
+ case stripe.PaymentIntentStatusRequiresAction:
+ return paymentIntentHasMicrodepositVerification(pi)
+ default:
+ return false
+ }
+}
+
+func paymentIntentIsHardDecline(pi *stripe.PaymentIntent, paymentMethodType string) bool {
+ if pi == nil || isAsyncPaymentMethodType(paymentMethodType) {
+ return false
+ }
+ if pi.LastPaymentError != nil {
+ return isHardDeclineErrorCode(string(pi.LastPaymentError.Code))
+ }
+ return pi.Status == stripe.PaymentIntentStatusRequiresPaymentMethod ||
+ pi.Status == stripe.PaymentIntentStatusCanceled
+}
+
+func isHardDeclineErrorCode(code string) bool {
+ switch stripe.ErrorCode(code) {
+ case stripe.ErrorCodeCardDeclined,
+ stripe.ErrorCodeInsufficientFunds,
+ stripe.ErrorCodeExpiredCard,
+ stripe.ErrorCodeIncorrectCVC,
+ stripe.ErrorCodeIncorrectNumber,
+ stripe.ErrorCodeInvalidCVC,
+ stripe.ErrorCodeInvalidExpiryMonth,
+ stripe.ErrorCodeInvalidExpiryYear,
+ stripe.ErrorCodeInvalidNumber,
+ stripe.ErrorCodeProcessingError,
+ stripe.ErrorCodeAuthenticationRequired:
+ return true
+ default:
+ return false
+ }
+}
+
+func paymentIntentPaymentMethodType(ctx context.Context, sc *stripe.Client, pi *stripe.PaymentIntent) string {
+ if pi == nil {
+ return ""
+ }
+ if pi.PaymentMethod != nil && pi.PaymentMethod.Type != "" {
+ return string(pi.PaymentMethod.Type)
+ }
+ if pi.PaymentMethod == nil || pi.PaymentMethod.ID == "" {
+ return ""
+ }
+ pm, err := sc.V1PaymentMethods.Retrieve(ctx, pi.PaymentMethod.ID, nil)
+ if err != nil || pm == nil {
+ return ""
+ }
+ return string(pm.Type)
+}
+
+func retrievePaymentIntent(ctx context.Context, sc *stripe.Client, paymentIntentID string) (*stripe.PaymentIntent, error) {
+ if paymentIntentID == "" {
+ return nil, nil
+ }
+ params := &stripe.PaymentIntentRetrieveParams{}
+ params.AddExpand("payment_method")
+ return sc.V1PaymentIntents.Retrieve(ctx, paymentIntentID, params)
+}
+
+func checkoutSessionPaymentIntent(ctx context.Context, sc *stripe.Client, session *stripe.CheckoutSession) (*stripe.PaymentIntent, string, error) {
+ if session == nil || session.PaymentIntent == nil {
+ return nil, "", nil
+ }
+ piID := session.PaymentIntent.ID
+ pi, err := retrievePaymentIntent(ctx, sc, piID)
+ if err != nil {
+ return nil, "", err
+ }
+ return pi, paymentIntentPaymentMethodType(ctx, sc, pi), nil
+}
+
+func invoicePaymentIntent(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) (*stripe.PaymentIntent, string, error) {
+ if inv == nil {
+ return nil, "", nil
+ }
+ params := &stripe.InvoicePaymentListParams{
+ Invoice: stripe.String(inv.ID),
+ }
+ params.AddExpand("data.payment.payment_intent.payment_method")
+
+ var pi *stripe.PaymentIntent
+ sc.V1InvoicePayments.List(ctx, params)(func(ip *stripe.InvoicePayment, err error) bool {
+ if err != nil || ip == nil || ip.Payment == nil || ip.Payment.PaymentIntent == nil {
+ return true
+ }
+ pi = ip.Payment.PaymentIntent
+ return false
+ })
+ if pi == nil {
+ return nil, "", nil
+ }
+ if pi.PaymentMethod == nil || pi.PaymentMethod.Type == "" {
+ full, err := retrievePaymentIntent(ctx, sc, pi.ID)
+ if err != nil {
+ return nil, "", err
+ }
+ pi = full
+ }
+ return pi, paymentIntentPaymentMethodType(ctx, sc, pi), nil
+}
+
+func shouldGrantGraceForSubscription(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) bool {
+ if sub == nil || sub.LatestInvoice == nil {
+ return false
+ }
+ invParams := &stripe.InvoiceRetrieveParams{}
+ invParams.AddExpand("payments.data.payment.payment_intent.payment_method")
+ inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams)
+ if err != nil {
+ return false
+ }
+ pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
+ if err != nil {
+ return false
+ }
+ return shouldGrantGraceForPaymentIntent(pi, pmType)
+}
+
+func shouldGrantGraceForInvoice(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool {
+ pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
+ if err != nil {
+ return false
+ }
+ return shouldGrantGraceForPaymentIntent(pi, pmType)
+}
+
+func invoicePaymentIsHardDecline(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool {
+ pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
+ if err != nil || pi == nil {
+ return false
+ }
+ return paymentIntentIsHardDecline(pi, pmType)
+}
diff --git a/src/website/subscription_grace_eligibility_test.go b/src/website/subscription_grace_eligibility_test.go
new file mode 100644
index 00000000..9f0d84b3
--- /dev/null
+++ b/src/website/subscription_grace_eligibility_test.go
@@ -0,0 +1,44 @@
+package website
+
+import (
+ "testing"
+
+ "github.com/stripe/stripe-go/v84"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestShouldGrantGraceForPaymentIntent(t *testing.T) {
+ achPI := &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusProcessing}
+ assert.True(t, shouldGrantGraceForPaymentIntent(achPI, "us_bank_account"))
+ assert.False(t, shouldGrantGraceForPaymentIntent(achPI, "card"))
+
+ cardPI := &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusProcessing}
+ assert.False(t, shouldGrantGraceForPaymentIntent(cardPI, "card"))
+
+ achVerify := &stripe.PaymentIntent{
+ Status: stripe.PaymentIntentStatusRequiresAction,
+ NextAction: &stripe.PaymentIntentNextAction{
+ Type: stripe.PaymentIntentNextActionTypeVerifyWithMicrodeposits,
+ },
+ }
+ assert.True(t, shouldGrantGraceForPaymentIntent(achVerify, "us_bank_account"))
+}
+
+func TestPaymentIntentIsHardDecline(t *testing.T) {
+ declined := &stripe.PaymentIntent{
+ Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
+ LastPaymentError: &stripe.Error{
+ Code: stripe.ErrorCodeInsufficientFunds,
+ },
+ }
+ assert.True(t, paymentIntentIsHardDecline(declined, "card"))
+ assert.False(t, paymentIntentIsHardDecline(declined, "us_bank_account"))
+
+ processingACH := &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusProcessing}
+ assert.False(t, paymentIntentIsHardDecline(processingACH, "us_bank_account"))
+}
+
+func TestIsAsyncPaymentMethodType(t *testing.T) {
+ assert.True(t, isAsyncPaymentMethodType("us_bank_account"))
+ assert.False(t, isAsyncPaymentMethodType("card"))
+}
diff --git a/src/website/subscription_grace_revoke.go b/src/website/subscription_grace_revoke.go
new file mode 100644
index 00000000..17689b7d
--- /dev/null
+++ b/src/website/subscription_grace_revoke.go
@@ -0,0 +1,29 @@
+package website
+
+import (
+ "context"
+
+ "git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/logging"
+)
+
+// revokeSubscriptionAccessAfterDeclinedPayment clears member access when a payment
+// was declined (not processing). Restores grace_available so a future ACH attempt can
+// still use the one-time grace period.
+func revokeSubscriptionAccessAfterDeclinedPayment(ctx context.Context, conn db.ConnOrTx, userID int, subscriptionStatus string) error {
+ _, err := conn.Exec(ctx, `
+ UPDATE hmn_user
+ SET
+ is_subscribed = false,
+ subscription_status = $1,
+ grace_period_started_at = NULL,
+ grace_period_ends_at = NULL,
+ grace_available = true
+ WHERE id = $2
+ `, subscriptionStatus, userID)
+ if err != nil {
+ return err
+ }
+ logging.Info().Int("userID", userID).Str("status", subscriptionStatus).Msg("revoked subscription access after declined payment")
+ return nil
+}
diff --git a/src/website/subscription_grace_test.go b/src/website/subscription_grace_test.go
index 185a01f3..53c082b8 100644
--- a/src/website/subscription_grace_test.go
+++ b/src/website/subscription_grace_test.go
@@ -100,6 +100,29 @@ func TestGracePeriodDaysRemaining(t *testing.T) {
assert.Equal(t, 0, gracePeriodDaysRemaining(user, now))
}
+func TestShouldRetrySubscriptionPayment(t *testing.T) {
+ subID := "sub_123"
+ custID := "cus_123"
+
+ assert.False(t, shouldRetrySubscriptionPayment(nil))
+ assert.False(t, shouldRetrySubscriptionPayment(&models.User{IsSubscribed: true, SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod)}))
+
+ user := &models.User{
+ IsSubscribed: true,
+ StripeCustomerID: &custID,
+ StripeSubscriptionID: &subID,
+ SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod),
+ }
+ assert.True(t, shouldRetrySubscriptionPayment(user))
+
+ user.SubscriptionStatus = statusPtr("incomplete")
+ assert.True(t, shouldRetrySubscriptionPayment(user))
+
+ user.SubscriptionStatus = statusPtr("active")
+ user.IsSubscribed = true
+ assert.False(t, shouldRetrySubscriptionPayment(user))
+}
+
func TestUserNeedsBankVerificationReminder(t *testing.T) {
assert.True(t, userNeedsBankVerificationReminder(&models.User{
SubscriptionStatus: statusPtr(SubscriptionStatusPendingVerification),
diff --git a/src/website/subscription_payment_intent_webhook.go b/src/website/subscription_payment_intent_webhook.go
new file mode 100644
index 00000000..6ccedbe5
--- /dev/null
+++ b/src/website/subscription_payment_intent_webhook.go
@@ -0,0 +1,76 @@
+package website
+
+import (
+ "encoding/json"
+ "strconv"
+
+ "git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/logging"
+ "git.handmade.network/hmn/hmn/src/models"
+ "github.com/stripe/stripe-go/v84"
+)
+
+func tryHandleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client, event *stripe.Event) bool {
+ switch event.Type {
+ case stripe.EventTypePaymentIntentProcessing, stripe.EventTypePaymentIntentPaymentFailed:
+ default:
+ return false
+ }
+
+ var pi stripe.PaymentIntent
+ if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
+ c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to unmarshal payment_intent for membership")
+ return false
+ }
+
+ return handleMembershipPaymentIntentWebhook(c, sc, event.Type, &pi)
+}
+
+func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client, eventType stripe.EventType, pi *stripe.PaymentIntent) bool {
+ if pi == nil || pi.Customer == nil {
+ return false
+ }
+
+ user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", pi.Customer.ID)
+ if err != nil {
+ return false
+ }
+ if user.StripeSubscriptionID == nil {
+ return false
+ }
+
+ pmType := paymentIntentPaymentMethodType(c, sc, pi)
+ now := SubscriptionNow()
+
+ switch eventType {
+ case stripe.EventTypePaymentIntentProcessing:
+ if shouldGrantGraceForPaymentIntent(pi, pmType) && canStartGrace(user, now) {
+ if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment_intent.processing")
+ }
+ }
+ case stripe.EventTypePaymentIntentPaymentFailed:
+ if paymentIntentIsHardDecline(pi, pmType) {
+ if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, "incomplete"); err != nil {
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to revoke access from payment_intent.payment_failed")
+ }
+ }
+ default:
+ return false
+ }
+
+ return true
+}
+
+func handleCheckoutAsyncPaymentFailed(c *RequestContext, sc *stripe.Client, session *stripe.CheckoutSession) {
+ if session.ClientReferenceID == "" {
+ return
+ }
+ userID, err := strconv.Atoi(session.ClientReferenceID)
+ if err != nil {
+ return
+ }
+ if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, userID, "incomplete"); err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to revoke access after async checkout payment failed")
+ }
+}
diff --git a/src/website/subscription_payment_retry.go b/src/website/subscription_payment_retry.go
new file mode 100644
index 00000000..23c3d16c
--- /dev/null
+++ b/src/website/subscription_payment_retry.go
@@ -0,0 +1,67 @@
+package website
+
+import (
+ "encoding/json"
+
+ "git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/logging"
+ "git.handmade.network/hmn/hmn/src/models"
+ "github.com/stripe/stripe-go/v84"
+)
+
+func isMembershipGracePaymentRetryEvent(event *stripe.Event) bool {
+ switch event.Type {
+ case "payment_method.attached", "customer.updated":
+ return true
+ default:
+ return false
+ }
+}
+
+func handleMembershipGracePaymentRetryWebhook(c *RequestContext, sc *stripe.Client, event *stripe.Event) {
+ switch event.Type {
+ case "payment_method.attached":
+ var pm stripe.PaymentMethod
+ if err := json.Unmarshal(event.Data.Raw, &pm); err != nil {
+ c.Logger.Error().Err(err).Msg("failed to unmarshal payment_method.attached for grace retry")
+ return
+ }
+ if pm.Customer == nil {
+ return
+ }
+ maybeRetrySubscriptionPaymentForCustomer(c, sc, pm.Customer.ID)
+ case "customer.updated":
+ if !customerDefaultPaymentMethodChanged(event) {
+ return
+ }
+ var customer stripe.Customer
+ if err := json.Unmarshal(event.Data.Raw, &customer); err != nil {
+ c.Logger.Error().Err(err).Msg("failed to unmarshal customer.updated for grace retry")
+ return
+ }
+ maybeRetrySubscriptionPaymentForCustomer(c, sc, customer.ID)
+ }
+}
+
+func customerDefaultPaymentMethodChanged(event *stripe.Event) bool {
+ if event.Data == nil || len(event.Data.PreviousAttributes) == 0 {
+ return false
+ }
+ if _, ok := event.Data.PreviousAttributes["invoice_settings"]; ok {
+ return true
+ }
+ if _, ok := event.Data.PreviousAttributes["default_source"]; ok {
+ return true
+ }
+ return false
+}
+
+func maybeRetrySubscriptionPaymentForCustomer(c *RequestContext, sc *stripe.Client, customerID string) {
+ user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", customerID)
+ if err != nil {
+ return
+ }
+ if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, user); err != nil {
+ logging.Warn().Err(err).Int("userID", user.ID).Str("customerID", customerID).Msg("failed to retry subscription payment after payment method change")
+ }
+}
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index f4fddb55..a4283962 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -66,6 +66,13 @@ func handleMembershipStripeEvent(c *RequestContext, sc *stripe.Client, event *st
return
}
handleInvoicePaymentFailed(c, sc, &inv)
+ case "checkout.session.async_payment_failed":
+ var session stripe.CheckoutSession
+ if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
+ c.Logger.Error().Err(err).Msg("failed to unmarshal checkout.session.async_payment_failed")
+ return
+ }
+ handleCheckoutAsyncPaymentFailed(c, sc, &session)
}
}
@@ -77,9 +84,10 @@ type PaymentHistoryItem struct {
type ManageSubscriptionTemplateData struct {
templates.BaseData
- SubscribeUrl string
- CancelSubscriptionUrl string
- ResumeSubscriptionUrl string
+ SubscribeUrl string
+ CancelSubscriptionUrl string
+ ResumeSubscriptionUrl string
+ UpdatePaymentMethodUrl string
CurrentCurrencySymbol string
CurrentAmount string
PaymentHistory []PaymentHistoryItem
@@ -182,6 +190,7 @@ func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) Man
SubscribeUrl: hmnurl.BuildSubscriptionSubscribe(),
CancelSubscriptionUrl: hmnurl.BuildSubscriptionCancel(),
ResumeSubscriptionUrl: hmnurl.BuildSubscriptionResume(),
+ UpdatePaymentMethodUrl: hmnurl.BuildSubscriptionUpdatePaymentMethod(),
CurrentCurrencySymbol: currentCurrencySymbol,
CurrentAmount: currentAmount,
PaymentHistory: history,
@@ -335,7 +344,33 @@ func SubscriptionResume(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
}
+func SubscriptionUpdatePaymentMethod(c *RequestContext) ResponseData {
+ if !c.CurrentUser.IsSubscribed || c.CurrentUser.StripeCustomerID == nil {
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
+ }
+
+ sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ returnURL := hmnurl.BuildHSFMembershipPaymentMethodReturn()
+ params := &stripe.BillingPortalSessionCreateParams{
+ Customer: stripe.String(*c.CurrentUser.StripeCustomerID),
+ ReturnURL: stripe.String(returnURL),
+ FlowData: &stripe.BillingPortalSessionCreateFlowDataParams{
+ Type: stripe.String(string(stripe.BillingPortalSessionFlowTypePaymentMethodUpdate)),
+ AfterCompletion: &stripe.BillingPortalSessionCreateFlowDataAfterCompletionParams{
+ Type: stripe.String(string(stripe.BillingPortalSessionFlowAfterCompletionTypeRedirect)),
+ Redirect: &stripe.BillingPortalSessionCreateFlowDataAfterCompletionRedirectParams{
+ ReturnURL: stripe.String(returnURL),
+ },
+ },
+ },
+ }
+ session, err := sc.V1BillingPortalSessions.Create(c, params)
+ if err != nil {
+ return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create billing portal session"))
+ }
+ return c.Redirect(session.URL, http.StatusSeeOther)
+}
func handleSubscriptionCreated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) {
if uidStr, ok := sub.Metadata["user_id"]; ok {
@@ -381,37 +416,53 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
return
}
- _, err = c.Conn.Exec(c, `
- UPDATE hmn_user
- SET
- is_subscribed = true,
- stripe_customer_id = $1,
- stripe_subscription_id = $2,
- cancel_at_period_end = false
- WHERE id = $3
- `, session.Customer.ID, session.Subscription.ID, userID)
+ pi, pmType, err := checkoutSessionPaymentIntent(c, sc, session)
if err != nil {
- logging.Error().Err(err).Int("userID", userID).Msg("failed to link pending checkout subscription")
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to load checkout payment intent")
return
}
+ grantGrace := shouldGrantGraceForPaymentIntent(pi, pmType)
now := SubscriptionNow()
- if canStartGrace(user, now) {
+
+ if grantGrace && canStartGrace(user, now) {
+ _, err = c.Conn.Exec(c, `
+ UPDATE hmn_user
+ SET
+ stripe_customer_id = $1,
+ stripe_subscription_id = $2,
+ cancel_at_period_end = false
+ WHERE id = $3
+ `, session.Customer.ID, session.Subscription.ID, userID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to link pending checkout subscription")
+ return
+ }
if err := startGracePeriod(c, c.Conn, userID, now); err != nil {
- logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for pending checkout payment")
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for processing checkout payment")
}
- } else if !isGraceActive(user, now) {
+ } else {
_, err = c.Conn.Exec(c, `
UPDATE hmn_user
- SET subscription_status = $1
- WHERE id = $2
- `, "incomplete", userID)
+ SET
+ is_subscribed = false,
+ stripe_customer_id = $1,
+ stripe_subscription_id = $2,
+ subscription_status = $3,
+ cancel_at_period_end = false
+ WHERE id = $4
+ `, session.Customer.ID, session.Subscription.ID, "incomplete", userID)
if err != nil {
- logging.Error().Err(err).Int("userID", userID).Msg("failed to mark subscription incomplete after pending checkout")
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to link incomplete checkout subscription")
}
}
- logging.Info().Int("userID", userID).Str("paymentStatus", string(session.PaymentStatus)).Msg("checkout completed with pending payment")
+ logging.Info().
+ Int("userID", userID).
+ Str("paymentStatus", string(session.PaymentStatus)).
+ Bool("grantGrace", grantGrace).
+ Str("paymentMethodType", pmType).
+ Msg("checkout completed with pending payment")
return
}
@@ -467,12 +518,26 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
Msg("updating user subscription from webhook")
if isFailedPaymentStripeStatus(stripeStatus) {
- if canStartGrace(user, now) {
+ if isGraceActive(user, now) {
+ if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, user); err != nil {
+ logging.Warn().Err(err).Int("userID", user.ID).Msg("failed to retry subscription payment on subscription update")
+ } else {
+ refreshed, refreshErr := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", user.ID)
+ if refreshErr == nil {
+ user = refreshed
+ }
+ if refreshedSub, retrieveErr := sc.V1Subscriptions.Retrieve(c, sub.ID, nil); retrieveErr == nil {
+ stripeStatus = string(refreshedSub.Status)
+ }
+ }
+ }
+
+ if isFailedPaymentStripeStatus(stripeStatus) && canStartGrace(user, now) && shouldGrantGraceForSubscription(c, sc, sub) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period")
return
}
- } else if !isGraceActive(user, now) {
+ } else if isFailedPaymentStripeStatus(stripeStatus) && !isGraceActive(user, now) {
_, err = c.Conn.Exec(c, `
UPDATE hmn_user
SET
@@ -490,6 +555,9 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
return
}
+ if !isFailedPaymentStripeStatus(stripeStatus) {
+ // Payment retry cleared the past-due state; fall through to active handling.
+ } else {
_, err = c.Conn.Exec(c, `
UPDATE hmn_user
SET
@@ -505,6 +573,7 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription grace state from webhook")
}
return
+ }
}
isSubscribed := stripeSubscriptionGrantsAccess(stripeStatus)
@@ -647,10 +716,19 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip
}
now := SubscriptionNow()
- if canStartGrace(user, now) {
+ grantGrace := shouldGrantGraceForInvoice(c, sc, inv)
+ if grantGrace && canStartGrace(user, now) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period from invoice.payment_failed")
}
+ } else if invoicePaymentIsHardDecline(c, sc, inv) || userInGracePeriod(user) {
+ status := "incomplete"
+ if inv.Status != "" {
+ status = string(inv.Status)
+ }
+ if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, status); err != nil {
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to revoke access after declined invoice payment")
+ }
} else if !isGraceActive(user, now) && !user.GraceAvailable {
_, err = c.Conn.Exec(c, `
UPDATE hmn_user
From 7e31200c4d55530880440b5341cf15e0553a24d8 Mon Sep 17 00:00:00 2001
From: reece365
Date: Fri, 29 May 2026 05:37:03 -0500
Subject: [PATCH 06/15] Added basic Discord support for memberships, granting
supporter role using bot, including ability to manually sync
---
src/config/config.go.example | 1 +
src/config/types.go | 1 +
src/discord/cmd/cmd.go | 36 ++++++
src/hmnurl/hmnurl_test.go | 4 +
src/hmnurl/urls.go | 6 +
...AddDismissedMembershipDiscordLinkBanner.go | 43 +++++++
src/models/user.go | 2 +
src/templates/src/include/header-2024.html | 18 +++
src/templates/types.go | 4 +
src/website/base_data.go | 10 ++
src/website/discord.go | 10 ++
src/website/discord_membership.go | 116 ++++++++++++++++++
src/website/discord_membership_test.go | 40 ++++++
src/website/routes.go | 1 +
src/website/subscription_grace.go | 25 +++-
src/website/subscription_grace_job.go | 2 +-
src/website/subscription_grace_revoke.go | 1 +
src/website/subscriptions.go | 8 ++
18 files changed, 322 insertions(+), 6 deletions(-)
create mode 100644 src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go
create mode 100644 src/website/discord_membership.go
create mode 100644 src/website/discord_membership_test.go
diff --git a/src/config/config.go.example b/src/config/config.go.example
index 451b5e34..8bb245bf 100644
--- a/src/config/config.go.example
+++ b/src/config/config.go.example
@@ -68,6 +68,7 @@ var Config = HMNConfig{
GuildID: "",
MemberRoleID: "",
+ SupporterRoleID: "",
HMHReplayRoleID: "",
ShowcaseChannelID: "",
JamChannelID: "",
diff --git a/src/config/types.go b/src/config/types.go
index 228c3bb4..5c40e6fd 100644
--- a/src/config/types.go
+++ b/src/config/types.go
@@ -85,6 +85,7 @@ type DiscordConfig struct {
GuildID string
MemberRoleID string
+ SupporterRoleID string
HMHReplayRoleID string
ShowcaseChannelID string
JamChannelID string
diff --git a/src/discord/cmd/cmd.go b/src/discord/cmd/cmd.go
index 5304a5b2..4be19114 100644
--- a/src/discord/cmd/cmd.go
+++ b/src/discord/cmd/cmd.go
@@ -80,4 +80,40 @@ func init() {
},
}
rootCommand.AddCommand(makeSnippetCommand)
+
+ syncSupporterRolesCommand := &cobra.Command{
+ Use: "sync-supporter-roles",
+ Short: "Sync supporter Discord roles for subscribed members",
+ Long: "Grants SupporterRoleID to subscribed users with linked Discord accounts, and removes it from others.",
+ Run: func(cmd *cobra.Command, args []string) {
+ ctx := context.Background()
+ conn := db.NewConnPool()
+ defer conn.Close()
+
+ dryRun, _ := cmd.Flags().GetBool("dry-run")
+
+ userIDPtrs, err := db.Query[int](ctx, conn, `
+ SELECT hmn_user.id
+ FROM hmn_user
+ INNER JOIN discord_user ON discord_user.hmn_user_id = hmn_user.id
+ WHERE hmn_user.is_subscribed = true
+ `)
+ if err != nil {
+ logging.Error().Err(err).Msg("failed to list subscribed users with Discord")
+ os.Exit(1)
+ }
+
+ if dryRun {
+ logging.Info().Int("count", len(userIDPtrs)).Msg("dry run: would sync supporter Discord roles")
+ return
+ }
+
+ for _, userID := range userIDPtrs {
+ website.SyncSupporterDiscordRole(ctx, conn, *userID)
+ }
+ logging.Info().Int("count", len(userIDPtrs)).Msg("synced supporter Discord roles")
+ },
+ }
+ syncSupporterRolesCommand.Flags().Bool("dry-run", false, "log how many users would be synced without calling Discord")
+ rootCommand.AddCommand(syncSupporterRolesCommand)
}
diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go
index 2232c1ed..336520d4 100644
--- a/src/hmnurl/hmnurl_test.go
+++ b/src/hmnurl/hmnurl_test.go
@@ -457,6 +457,10 @@ func TestDiscordUnlink(t *testing.T) {
AssertRegexMatch(t, BuildDiscordUnlink(), RegexDiscordUnlink, nil)
}
+func TestDismissMembershipDiscordLinkBanner(t *testing.T) {
+ AssertRegexMatch(t, BuildDismissMembershipDiscordLinkBanner(), RegexDismissMembershipDiscordLinkBanner, nil)
+}
+
func TestDiscordShowcaseBacklog(t *testing.T) {
AssertRegexMatch(t, BuildDiscordShowcaseBacklog(), RegexDiscordShowcaseBacklog, nil)
}
diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go
index 11ee1ed2..64e8bc5f 100644
--- a/src/hmnurl/urls.go
+++ b/src/hmnurl/urls.go
@@ -999,6 +999,12 @@ func BuildDiscordUnlink() string {
return Url("/_discord_unlink", nil)
}
+var RegexDismissMembershipDiscordLinkBanner = regexp.MustCompile("^/_dismiss_membership_discord_link_banner$")
+
+func BuildDismissMembershipDiscordLinkBanner() string {
+ return Url("/_dismiss_membership_discord_link_banner", nil)
+}
+
var RegexDiscordShowcaseBacklog = regexp.MustCompile("^/discord_showcase_backlog$")
func BuildDiscordShowcaseBacklog() string {
diff --git a/src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go b/src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go
new file mode 100644
index 00000000..46adfa0c
--- /dev/null
+++ b/src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go
@@ -0,0 +1,43 @@
+package migrations
+
+import (
+ "context"
+ "time"
+
+ "git.handmade.network/hmn/hmn/src/migration/types"
+ "github.com/jackc/pgx/v5"
+)
+
+func init() {
+ registerMigration(AddDismissedMembershipDiscordLinkBanner{})
+}
+
+type AddDismissedMembershipDiscordLinkBanner struct{}
+
+func (m AddDismissedMembershipDiscordLinkBanner) Version() types.MigrationVersion {
+ return types.MigrationVersion(time.Date(2026, 5, 24, 18, 0, 0, 0, time.UTC))
+}
+
+func (m AddDismissedMembershipDiscordLinkBanner) Name() string {
+ return "AddDismissedMembershipDiscordLinkBanner"
+}
+
+func (m AddDismissedMembershipDiscordLinkBanner) Description() string {
+ return "Track dismissal of the membership Discord link banner on hmn_user"
+}
+
+func (m AddDismissedMembershipDiscordLinkBanner) Up(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ ALTER TABLE hmn_user
+ ADD COLUMN dismissed_membership_discord_link_banner BOOLEAN NOT NULL DEFAULT false;
+ `)
+ return err
+}
+
+func (m AddDismissedMembershipDiscordLinkBanner) Down(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ ALTER TABLE hmn_user
+ DROP COLUMN dismissed_membership_discord_link_banner;
+ `)
+ return err
+}
diff --git a/src/models/user.go b/src/models/user.go
index c7681a83..d53f5c5f 100644
--- a/src/models/user.go
+++ b/src/models/user.go
@@ -60,6 +60,8 @@ type User struct {
GracePeriodEndsAt *time.Time `db:"grace_period_ends_at"`
GraceAvailable bool `db:"grace_available"`
+ DismissedMembershipDiscordLinkBanner bool `db:"dismissed_membership_discord_link_banner"`
+
MarkedAllReadAt time.Time `db:"marked_all_read_at"`
// Non-db fields, to be filled in by fetch helpers
diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html
index 3a899de4..9afec5ed 100644
--- a/src/templates/src/include/header-2024.html
+++ b/src/templates/src/include/header-2024.html
@@ -86,6 +86,24 @@
{{ end }}
+{{ if and .Header.ShowMembershipDiscordLinkBanner (not .Header.SuppressBanners) }}
+
+{{ end }}
+
{{ if and (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }}
diff --git a/src/templates/types.go b/src/templates/types.go
index 2c01bf54..9e6fd43d 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -84,6 +84,10 @@ type Header struct {
ShowMembershipVerificationBanner bool
MembershipVerificationUrl string
MembershipGraceDaysRemaining int
+
+ ShowMembershipDiscordLinkBanner bool
+ MembershipDiscordLinkUrl string
+ MembershipDiscordLinkDismissUrl string
}
type BannerEvent struct {
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 85eb29e3..98bf3f5a 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -5,6 +5,7 @@ import (
"git.handmade.network/hmn/hmn/src/buildcss"
"git.handmade.network/hmn/hmn/src/config"
+ "git.handmade.network/hmn/hmn/src/discord"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
@@ -144,6 +145,15 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
baseData.Header.MembershipVerificationUrl = bannerURL
baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow())
}
+ if userNeedsDiscordLinkReminder(c.CurrentUser) {
+ baseData.Header.ShowMembershipDiscordLinkBanner = true
+ baseData.Header.MembershipDiscordLinkDismissUrl = hmnurl.BuildDismissMembershipDiscordLinkBanner()
+ if c.CurrentSession != nil {
+ baseData.Header.MembershipDiscordLinkUrl = discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken, false)
+ } else {
+ baseData.Header.MembershipDiscordLinkUrl = hmnurl.BuildUserSettings("discord")
+ }
+ }
}
if !project.IsHMN() {
diff --git a/src/website/discord.go b/src/website/discord.go
index 04393709..afc4046e 100644
--- a/src/website/discord.go
+++ b/src/website/discord.go
@@ -317,6 +317,10 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
}
}
+ if hmnUser.IsSubscribed {
+ SyncSupporterDiscordRole(c, c.Conn, hmnUser.ID)
+ }
+
// We only expect direct URLs to HMN pages, or values that were sanitized on their way into
// pending_login, but defense in depth is not a bad thing.
safeDest := hmnurl.SafeRedirectUrl(destinationUrl)
@@ -372,6 +376,12 @@ func DiscordUnlink(c *RequestContext) ResponseData {
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink")
}
+ if config.Config.Discord.SupporterRoleID != "" {
+ err = discord.RemoveGuildMemberRole(c, discordUser.UserID, config.Config.Discord.SupporterRoleID)
+ if err != nil {
+ c.Logger.Warn().Err(err).Msg("failed to remove supporter role on unlink")
+ }
+ }
return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther)
}
diff --git a/src/website/discord_membership.go b/src/website/discord_membership.go
new file mode 100644
index 00000000..c8d65b48
--- /dev/null
+++ b/src/website/discord_membership.go
@@ -0,0 +1,116 @@
+package website
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "git.handmade.network/hmn/hmn/src/config"
+ "git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/discord"
+ "git.handmade.network/hmn/hmn/src/logging"
+ "git.handmade.network/hmn/hmn/src/models"
+ "git.handmade.network/hmn/hmn/src/oops"
+)
+
+func userEligibleForSupporterDiscordRole(user *models.User) bool {
+ return user != nil && user.IsSubscribed
+}
+
+func userNeedsDiscordLinkReminder(user *models.User) bool {
+ return user != nil &&
+ user.IsSubscribed &&
+ user.DiscordUser == nil &&
+ !user.DismissedMembershipDiscordLinkBanner
+}
+
+func SyncSupporterDiscordRole(ctx context.Context, conn db.ConnOrTx, userID int) {
+ roleID := config.Config.Discord.SupporterRoleID
+ if roleID == "" {
+ return
+ }
+
+ user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ if err != db.NotFound {
+ logging.Warn().Err(err).Int("userID", userID).Msg("failed to load user for supporter Discord role sync")
+ }
+ return
+ }
+
+ discordUser, err := db.QueryOne[models.DiscordUser](ctx, conn,
+ "SELECT $columns FROM discord_user WHERE hmn_user_id = $1",
+ userID,
+ )
+ if err == db.NotFound {
+ return
+ }
+ if err != nil {
+ logging.Warn().Err(err).Int("userID", userID).Msg("failed to load Discord user for supporter role sync")
+ return
+ }
+
+ syncSupporterDiscordRoleForUser(ctx, user, discordUser.UserID, roleID)
+}
+
+func SyncSupporterDiscordRoleForCustomer(ctx context.Context, conn db.ConnOrTx, stripeCustomerID string) {
+ if config.Config.Discord.SupporterRoleID == "" || stripeCustomerID == "" {
+ return
+ }
+
+ user, err := db.QueryOne[models.User](ctx, conn,
+ "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1",
+ stripeCustomerID,
+ )
+ if err != nil {
+ if err != db.NotFound {
+ logging.Warn().Err(err).Str("customerID", stripeCustomerID).Msg("failed to load user for supporter Discord role sync")
+ }
+ return
+ }
+
+ SyncSupporterDiscordRole(ctx, conn, user.ID)
+}
+
+func syncSupporterDiscordRoleForUser(ctx context.Context, user *models.User, discordUserID, roleID string) {
+ var err error
+ if userEligibleForSupporterDiscordRole(user) {
+ err = discord.AddGuildMemberRole(ctx, discordUserID, roleID)
+ } else {
+ err = discord.RemoveGuildMemberRole(ctx, discordUserID, roleID)
+ }
+
+ if err == nil {
+ return
+ }
+ if errors.Is(err, discord.NotFound) {
+ logging.Warn().
+ Int("userID", user.ID).
+ Str("discordUserID", discordUserID).
+ Bool("grant", userEligibleForSupporterDiscordRole(user)).
+ Msg("Discord user not in guild; skipped supporter role sync")
+ return
+ }
+ logging.Warn().
+ Err(err).
+ Int("userID", user.ID).
+ Str("discordUserID", discordUserID).
+ Bool("grant", userEligibleForSupporterDiscordRole(user)).
+ Msg("failed to sync supporter Discord role")
+}
+
+func DismissMembershipDiscordLinkBanner(c *RequestContext) ResponseData {
+ _, err := c.Conn.Exec(c,
+ `UPDATE hmn_user SET dismissed_membership_discord_link_banner = true WHERE id = $1`,
+ c.CurrentUser.ID,
+ )
+ if err != nil {
+ return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to dismiss membership Discord link banner"))
+ }
+
+ dest := c.FullUrl()
+ if referer := c.Req.Referer(); referer != "" {
+ dest = referer
+ }
+ return c.Redirect(dest, http.StatusSeeOther)
+}
diff --git a/src/website/discord_membership_test.go b/src/website/discord_membership_test.go
new file mode 100644
index 00000000..2f4c20ed
--- /dev/null
+++ b/src/website/discord_membership_test.go
@@ -0,0 +1,40 @@
+package website
+
+import (
+ "testing"
+
+ "git.handmade.network/hmn/hmn/src/config"
+ "git.handmade.network/hmn/hmn/src/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUserEligibleForSupporterDiscordRole(t *testing.T) {
+ assert.False(t, userEligibleForSupporterDiscordRole(nil))
+ assert.False(t, userEligibleForSupporterDiscordRole(&models.User{IsSubscribed: false}))
+ assert.True(t, userEligibleForSupporterDiscordRole(&models.User{IsSubscribed: true}))
+}
+
+func TestUserNeedsDiscordLinkReminder(t *testing.T) {
+ assert.False(t, userNeedsDiscordLinkReminder(nil))
+ assert.False(t, userNeedsDiscordLinkReminder(&models.User{IsSubscribed: false}))
+ assert.False(t, userNeedsDiscordLinkReminder(&models.User{
+ IsSubscribed: true,
+ DiscordUser: &models.DiscordUser{},
+ }))
+ assert.True(t, userNeedsDiscordLinkReminder(&models.User{IsSubscribed: true}))
+ assert.False(t, userNeedsDiscordLinkReminder(&models.User{
+ IsSubscribed: true,
+ DismissedMembershipDiscordLinkBanner: true,
+ }))
+}
+
+func TestSyncSupporterDiscordRoleNoOpsWithoutConfig(t *testing.T) {
+ original := config.Config.Discord.SupporterRoleID
+ config.Config.Discord.SupporterRoleID = ""
+ defer func() {
+ config.Config.Discord.SupporterRoleID = original
+ }()
+
+ // Should return without panicking when role ID is unset.
+ SyncSupporterDiscordRole(t.Context(), nil, 1)
+}
diff --git a/src/website/routes.go b/src/website/routes.go
index 7359e787..798e11a2 100644
--- a/src/website/routes.go
+++ b/src/website/routes.go
@@ -209,6 +209,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, DiscordOAuthCallback)
hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink)))
+ hmnOnly.POST(hmnurl.RegexDismissMembershipDiscordLinkBanner, needsAuth(csrfMiddleware(DismissMembershipDiscordLinkBanner)))
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog)))
hmnOnly.GET(hmnurl.RegexDiscordBotDebugPage, adminsOnly(DiscordBotDebugPage))
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index 925771d5..ec8ea15a 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -89,6 +89,7 @@ func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now tim
return err
}
logging.Info().Int("userID", userID).Time("graceEndsAt", endsAt).Msg("started subscription grace period")
+ SyncSupporterDiscordRole(ctx, conn, userID)
return nil
}
@@ -124,11 +125,12 @@ func expireGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error
return err
}
logging.Info().Int("userID", userID).Msg("expired subscription grace period without payment")
+ SyncSupporterDiscordRole(ctx, conn, userID)
return nil
}
-func expireDueGracePeriods(ctx context.Context, conn db.ConnOrTx, now time.Time) (int64, error) {
- tag, err := conn.Exec(ctx, `
+func expireDueGracePeriods(ctx context.Context, conn db.ConnOrTx, now time.Time) ([]int, error) {
+ userIDPtrs, err := db.Query[int](ctx, conn, `
UPDATE hmn_user
SET
is_subscribed = false,
@@ -139,11 +141,16 @@ func expireDueGracePeriods(ctx context.Context, conn db.ConnOrTx, now time.Time)
WHERE subscription_status = $2
AND grace_period_ends_at IS NOT NULL
AND grace_period_ends_at < $3
+ RETURNING id
`, SubscriptionStatusGraceFailed, SubscriptionStatusGracePeriod, now)
if err != nil {
- return 0, err
+ return nil, err
+ }
+ userIDs := make([]int, len(userIDPtrs))
+ for i, id := range userIDPtrs {
+ userIDs[i] = *id
}
- return tag.RowsAffected(), nil
+ return userIDs, nil
}
func userInGracePeriod(user *models.User) bool {
@@ -185,7 +192,14 @@ func StartSubscriptionGracePeriod(ctx context.Context, conn db.ConnOrTx, userID
}
func ExpireSubscriptionGracePeriods(ctx context.Context, conn db.ConnOrTx) (int64, error) {
- return expireDueGracePeriods(ctx, conn, SubscriptionNow())
+ userIDs, err := expireDueGracePeriods(ctx, conn, SubscriptionNow())
+ if err != nil {
+ return 0, err
+ }
+ for _, userID := range userIDs {
+ SyncSupporterDiscordRole(ctx, conn, userID)
+ }
+ return int64(len(userIDs)), nil
}
func shouldRetrySubscriptionPayment(user *models.User) bool {
@@ -239,6 +253,7 @@ func retryPastDueSubscriptionPayment(ctx context.Context, conn db.ConnOrTx, sc *
if err != nil {
return err
}
+ SyncSupporterDiscordRole(ctx, conn, user.ID)
}
return nil
diff --git a/src/website/subscription_grace_job.go b/src/website/subscription_grace_job.go
index a36753e0..48373874 100644
--- a/src/website/subscription_grace_job.go
+++ b/src/website/subscription_grace_job.go
@@ -20,7 +20,7 @@ func ExpireSubscriptionGracePeriodsJob(dbConn *pgxpool.Pool) *jobs.Job {
err := func() (err error) {
defer utils.RecoverPanicAsError(&err)
- n, err := expireDueGracePeriods(job.Ctx, dbConn, SubscriptionNow())
+ n, err := ExpireSubscriptionGracePeriods(job.Ctx, dbConn)
if err != nil {
job.Logger.Error().Err(err).Msg("failed to expire subscription grace periods")
return err
diff --git a/src/website/subscription_grace_revoke.go b/src/website/subscription_grace_revoke.go
index 17689b7d..ea1c2c2e 100644
--- a/src/website/subscription_grace_revoke.go
+++ b/src/website/subscription_grace_revoke.go
@@ -25,5 +25,6 @@ func revokeSubscriptionAccessAfterDeclinedPayment(ctx context.Context, conn db.C
return err
}
logging.Info().Int("userID", userID).Str("status", subscriptionStatus).Msg("revoked subscription access after declined payment")
+ SyncSupporterDiscordRole(ctx, conn, userID)
return nil
}
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index a4283962..f579b4a0 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -455,6 +455,7 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
if err != nil {
logging.Error().Err(err).Int("userID", userID).Msg("failed to link incomplete checkout subscription")
}
+ SyncSupporterDiscordRole(c, c.Conn, userID)
}
logging.Info().
@@ -485,6 +486,7 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
} else {
logging.Info().Int("userID", userID).Msg("user subscription linked, attempting thank you email")
attemptThankYouEmail(c, user.ID, session.AmountTotal, session.Currency)
+ SyncSupporterDiscordRole(c, c.Conn, userID)
}
}
@@ -552,6 +554,7 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook")
}
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
return
}
@@ -572,6 +575,7 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription grace state from webhook")
}
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
return
}
}
@@ -597,6 +601,7 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook")
}
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
if isCancelling && !user.CancelAtPeriodEnd {
var expirationDate *time.Time
@@ -648,6 +653,7 @@ func handleSubscriptionDeleted(c *RequestContext, sc *stripe.Client, sub *stripe
}
logging.Info().Int("userID", user.ID).Msg("user subscription deactivated")
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
if !sub.CancelAtPeriodEnd {
err = email.SendSubscriptionCancelledEmail(user.Email, user.BestName(), nil, c.Perf)
@@ -702,6 +708,7 @@ func handleInvoicePaid(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice
}
attemptThankYouEmail(c, user.ID, inv.AmountPaid, inv.Currency)
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
}
func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice) {
@@ -738,6 +745,7 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to mark subscription grace_failed")
}
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
}
user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", user.ID)
From c33aa6dcda1eb23d5382a3e41c6826d662893271 Mon Sep 17 00:00:00 2001
From: reece365
Date: Sat, 30 May 2026 23:35:12 -0500
Subject: [PATCH 07/15] Trigger grace period after payment renewal decline,
changes to tests, Stripe backend bugfixes
---
src/admintools/adminsubscription.go | 576 +++++++++++++++--
src/templates/src/include/header-2024.html | 604 +++++++++---------
src/website/subscription_grace.go | 51 +-
src/website/subscription_grace_eligibility.go | 45 +-
.../subscription_grace_eligibility_test.go | 44 ++
src/website/subscription_grace_revoke.go | 6 +
src/website/subscription_grace_test.go | 21 +
.../subscription_payment_intent_webhook.go | 22 +-
src/website/subscription_stripe_testutil.go | 67 ++
src/website/subscriptions.go | 83 ++-
10 files changed, 1126 insertions(+), 393 deletions(-)
create mode 100644 src/website/subscription_stripe_testutil.go
diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go
index 9aec7145..f746e8c5 100644
--- a/src/admintools/adminsubscription.go
+++ b/src/admintools/adminsubscription.go
@@ -16,6 +16,7 @@ import (
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/website"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
"github.com/spf13/cobra"
"github.com/stripe/stripe-go/v84"
)
@@ -41,8 +42,8 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
}
ctx := context.Background()
- conn := db.NewConn()
- defer conn.Close(ctx)
+ pool := db.NewConnPool()
+ defer pool.Close()
if override := config.Config.Stripe.SubscriptionNowOverride; override != "" {
fmt.Printf("Using subscription time override: %s\n", override)
@@ -64,6 +65,10 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
})
},
},
+ {
+ Name: "Credit card declined (tok_chargeDeclined)",
+ Run: runDeclinedCardScenario,
+ },
{
Name: "ACH (US bank account)",
CreatePaymentMethod: createACHPaymentMethod,
@@ -76,12 +81,16 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
Name: "ACH verification after 2 day clock advance",
Run: runACHVerificationAfterAdvanceScenario,
},
+ {
+ Name: "Card renewal failure → grace → payment method update",
+ Run: runCardRenewalFailureGraceRecoveryScenario,
+ },
}
failed := false
for i, scenario := range scenarios {
fmt.Printf("\n========== Scenario %d/%d: %s ==========\n", i+1, len(scenarios), scenario.Name)
- result, err := runSubscriptionScenario(ctx, conn, sc, scenario)
+ result, err := runSubscriptionScenario(ctx, pool, sc, scenario)
if err != nil {
failed = true
fmt.Printf("RESULT: FAIL\n")
@@ -105,7 +114,7 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
type subscriptionTestScenario struct {
Name string
CreatePaymentMethod func(context.Context, *stripe.Client) (*stripe.PaymentMethod, error)
- Run func(context.Context, db.ConnOrTx, *stripe.Client) (subscriptionTestResult, error)
+ Run func(context.Context, *pgxpool.Pool, *stripe.Client) (subscriptionTestResult, error)
}
type subscriptionTestResult int
@@ -122,17 +131,17 @@ type achTestSetup struct {
testClockID string
}
-func runSubscriptionScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
+func runSubscriptionScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
if scenario.Run != nil {
- return scenario.Run(ctx, conn, sc)
+ return scenario.Run(ctx, pool, sc)
}
- return runCardOrACHScenario(ctx, conn, sc, scenario)
+ return runCardOrACHScenario(ctx, pool, sc, scenario)
}
-func runCardOrACHScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
+func runCardOrACHScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
fmt.Printf("[1/6] Creating test user: %s\n", username)
- userID, emailAddress := createSubscriptionTestUser(ctx, conn, username)
+ userID, emailAddress := createSubscriptionTestUser(ctx, pool, username)
fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress)
fmt.Printf("[2/6] Creating Stripe customer\n")
@@ -166,19 +175,152 @@ func runCardOrACHScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Clie
if err != nil {
if isExpectedACHVerificationPending(err) {
fmt.Printf(" ACH verification is pending; subscription will complete after verification.\n")
- if updateErr := persistPendingVerificationState(ctx, conn, userID, customer.ID); updateErr != nil {
+ if updateErr := persistPendingVerificationState(ctx, pool, userID, customer.ID); updateErr != nil {
return subscriptionTestResultPass, updateErr
}
- printSubscriptionData(ctx, conn, userID)
+ printSubscriptionData(ctx, pool, userID)
return subscriptionTestResultPending, nil
}
return subscriptionTestResultPass, err
}
- return completeSubscription(ctx, conn, sc, userID, customer.ID, paymentMethod.ID)
+ return completeSubscription(ctx, pool, sc, userID, customer.ID, paymentMethod.ID)
+}
+
+func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
+ username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
+ fmt.Printf("[1/6] Creating test user: %s\n", username)
+ userID, emailAddress := createSubscriptionTestUser(ctx, pool, username)
+ fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress)
+
+ fmt.Printf("[2/6] Creating Stripe customer\n")
+ customerParams := &stripe.CustomerCreateParams{
+ Email: stripe.String(emailAddress),
+ Name: stripe.String(username),
+ Metadata: map[string]string{
+ "user_id": strconv.Itoa(userID),
+ },
+ }
+ if testClockID := config.Config.Stripe.TestClockID; testClockID != "" {
+ customerParams.TestClock = stripe.String(testClockID)
+ }
+ customer, err := sc.V1Customers.Create(ctx, customerParams)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" customer_id=%s\n", customer.ID)
+
+ fmt.Printf("[3/6] Creating declined card payment method (tok_chargeDeclined)\n")
+ paymentMethod, err := sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{
+ Type: stripe.String("card"),
+ Card: &stripe.PaymentMethodCreateCardParams{
+ Token: stripe.String("tok_chargeDeclined"),
+ },
+ })
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" payment_method_id=%s\n", paymentMethod.ID)
+
+ fmt.Printf("[4/6] Attaching payment method and creating subscription\n")
+ _, attachErr := sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{
+ Customer: stripe.String(customer.ID),
+ })
+ if attachErr != nil && !isStripeCardDeclined(attachErr) {
+ return subscriptionTestResultPass, attachErr
+ }
+ if attachErr != nil {
+ fmt.Printf(" card declined during payment method attach (expected)\n")
+ }
+
+ var subscriptionID string
+ stripeStatus := "incomplete"
+
+ if attachErr == nil {
+ subscriptionParams := &stripe.SubscriptionCreateParams{
+ Customer: stripe.String(customer.ID),
+ DefaultPaymentMethod: stripe.String(paymentMethod.ID),
+ CollectionMethod: stripe.String("charge_automatically"),
+ PaymentBehavior: stripe.String("allow_incomplete"),
+ Items: []*stripe.SubscriptionCreateItemParams{
+ {Price: stripe.String(config.Config.Stripe.PriceID)},
+ },
+ Metadata: map[string]string{
+ "user_id": strconv.Itoa(userID),
+ },
+ }
+ subscriptionParams.AddExpand("latest_invoice.payments.data.payment.payment_intent")
+
+ subscription, createErr := sc.V1Subscriptions.Create(ctx, subscriptionParams)
+ if createErr != nil && !isStripeCardDeclined(createErr) {
+ return subscriptionTestResultPass, createErr
+ }
+ if createErr != nil {
+ fmt.Printf(" card declined during subscription create (expected)\n")
+ } else {
+ fmt.Printf(" subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
+ if subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing {
+ return subscriptionTestResultPass, fmt.Errorf("expected subscription to fail payment, got status=%s", subscription.Status)
+ }
+ subscriptionID = subscription.ID
+ stripeStatus = string(subscription.Status)
+ }
+ }
+
+ fmt.Printf("[5/6] Simulating declined payment access revoke\n")
+ if err := website.RevokeSubscriptionAccessAfterDeclinedPayment(ctx, pool, userID, stripeStatus); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if subscriptionID != "" {
+ _, err = pool.Exec(ctx, `
+ UPDATE hmn_user
+ SET stripe_customer_id = $1, stripe_subscription_id = $2, cancel_at_period_end = false
+ WHERE id = $3
+ `, customer.ID, subscriptionID, userID)
+ } else {
+ _, err = pool.Exec(ctx, `
+ UPDATE hmn_user
+ SET stripe_customer_id = $1, cancel_at_period_end = false
+ WHERE id = $2
+ `, customer.ID, userID)
+ }
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ fmt.Printf("[6/6] Verifying stored subscription data after decline\n")
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if user.IsSubscribed {
+ return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=false after card decline")
+ }
+ if user.SubscriptionStatus == nil || *user.SubscriptionStatus != stripeStatus {
+ return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=%s, got %s", stripeStatus, stringOrEmpty(user.SubscriptionStatus))
+ }
+ if !user.GraceAvailable {
+ return subscriptionTestResultPass, fmt.Errorf("expected grace_available=true after card decline (grace not consumed)")
+ }
+ if user.GracePeriodStartedAt != nil || user.GracePeriodEndsAt != nil {
+ return subscriptionTestResultPass, fmt.Errorf("expected no grace period after card decline")
+ }
+
+ payments, err := db.Query[models.UserPayment](ctx, pool, `
+ SELECT $columns FROM user_payment WHERE user_id = $1
+ `, userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if len(payments) > 0 {
+ return subscriptionTestResultPass, fmt.Errorf("expected no paid invoices after card decline, got %d payment rows", len(payments))
+ }
+
+ printSubscriptionData(ctx, pool, userID)
+ return subscriptionTestResultPass, nil
}
-func runACHGraceExpiryScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client) (subscriptionTestResult, error) {
+func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
defer website.ClearSubscriptionNowForTests()
fmt.Printf("[1/7] Creating Stripe test clock\n")
@@ -189,14 +331,14 @@ func runACHGraceExpiryScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe
fmt.Printf(" test_clock_id=%s frozen_time=%s\n", testClock.ID, time.Unix(testClock.FrozenTime, 0).UTC().Format(time.RFC3339))
defer deleteTestClock(ctx, sc, testClock.ID)
- setup, err := setupACHPendingOnClock(ctx, conn, sc, testClock.ID)
+ setup, err := setupACHPendingOnClock(ctx, pool, sc, testClock.ID)
if err != nil {
return subscriptionTestResultPass, err
}
fmt.Printf("[5/7] Starting grace period (simulating payment failure while ACH verification is pending)\n")
syncSubscriptionNowToTestClock(ctx, sc, testClock.ID)
- if err := website.StartSubscriptionGracePeriod(ctx, conn, setup.userID); err != nil {
+ if err := website.StartSubscriptionGracePeriod(ctx, pool, setup.userID); err != nil {
return subscriptionTestResultPass, err
}
@@ -209,13 +351,13 @@ func runACHGraceExpiryScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe
website.SetSubscriptionNowForTests(clockTime)
fmt.Printf("[7/7] Expiring due grace periods and verifying final state\n")
- expiredCount, err := website.ExpireSubscriptionGracePeriods(ctx, conn)
+ expiredCount, err := website.ExpireSubscriptionGracePeriods(ctx, pool)
if err != nil {
return subscriptionTestResultPass, err
}
fmt.Printf(" expired grace periods: %d\n", expiredCount)
- user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
if err != nil {
return subscriptionTestResultPass, err
}
@@ -229,11 +371,11 @@ func runACHGraceExpiryScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe
return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace expiry")
}
- printSubscriptionData(ctx, conn, setup.userID)
+ printSubscriptionData(ctx, pool, setup.userID)
return subscriptionTestResultPass, nil
}
-func runACHVerificationAfterAdvanceScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client) (subscriptionTestResult, error) {
+func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
defer website.ClearSubscriptionNowForTests()
fmt.Printf("[1/8] Creating Stripe test clock\n")
@@ -244,7 +386,7 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, conn db.ConnOrT
fmt.Printf(" test_clock_id=%s frozen_time=%s\n", testClock.ID, time.Unix(testClock.FrozenTime, 0).UTC().Format(time.RFC3339))
defer deleteTestClock(ctx, sc, testClock.ID)
- setup, err := setupACHPendingOnClock(ctx, conn, sc, testClock.ID)
+ setup, err := setupACHPendingOnClock(ctx, pool, sc, testClock.ID)
if err != nil {
return subscriptionTestResultPass, err
}
@@ -270,13 +412,13 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, conn db.ConnOrT
return subscriptionTestResultPass, fmt.Errorf("attach verified ACH payment method: %w", err)
}
- result, err := completeSubscription(ctx, conn, sc, setup.userID, setup.customerID, setup.paymentMethodID)
+ result, err := completeSubscription(ctx, pool, sc, setup.userID, setup.customerID, setup.paymentMethodID)
if err != nil {
return result, err
}
fmt.Printf("[8/8] Verifying subscription is active after ACH verification\n")
- user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
if err != nil {
return subscriptionTestResultPass, err
}
@@ -290,10 +432,348 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, conn db.ConnOrT
return result, nil
}
-func setupACHPendingOnClock(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, testClockID string) (*achTestSetup, error) {
+func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
+ defer website.ClearSubscriptionNowForTests()
+
+ fmt.Printf("[1/10] Creating Stripe test clock\n")
+ testClock, err := createTestClock(ctx, sc, "card-renewal-grace-recovery")
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" test_clock_id=%s frozen_time=%s\n", testClock.ID, time.Unix(testClock.FrozenTime, 0).UTC().Format(time.RFC3339))
+ defer deleteTestClock(ctx, sc, testClock.ID)
+
+ username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
+ fmt.Printf("[2/10] Creating test user: %s\n", username)
+ userID, emailAddress := createSubscriptionTestUser(ctx, pool, username)
+ fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress)
+
+ fmt.Printf("[3/10] Creating Stripe customer on test clock\n")
+ customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{
+ Email: stripe.String(emailAddress),
+ Name: stripe.String(username),
+ TestClock: stripe.String(testClock.ID),
+ Metadata: map[string]string{
+ "user_id": strconv.Itoa(userID),
+ },
+ })
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" customer_id=%s\n", customer.ID)
+
+ fmt.Printf("[4/10] Creating subscription with tok_visa\n")
+ visaPM, err := createCardPaymentMethod(ctx, sc, "tok_visa")
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ _, err = sc.V1PaymentMethods.Attach(ctx, visaPM.ID, &stripe.PaymentMethodAttachParams{
+ Customer: stripe.String(customer.ID),
+ })
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ _, err = sc.V1Customers.Update(ctx, customer.ID, &stripe.CustomerUpdateParams{
+ InvoiceSettings: &stripe.CustomerUpdateInvoiceSettingsParams{
+ DefaultPaymentMethod: stripe.String(visaPM.ID),
+ },
+ })
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ result, err := completeSubscription(ctx, pool, sc, userID, customer.ID, visaPM.ID)
+ if err != nil {
+ return result, err
+ }
+
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if !user.IsSubscribed || user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" {
+ return subscriptionTestResultPass, fmt.Errorf("expected active subscription before renewal, got is_subscribed=%v status=%s", user.IsSubscribed, stringOrEmpty(user.SubscriptionStatus))
+ }
+ subscriptionID := *user.StripeSubscriptionID
+
+ subParams := &stripe.SubscriptionRetrieveParams{}
+ subParams.AddExpand("items")
+ subscription, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, subParams)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if subscription.Items == nil || len(subscription.Items.Data) == 0 {
+ return subscriptionTestResultPass, fmt.Errorf("subscription has no items")
+ }
+ periodEnd := subscription.Items.Data[0].CurrentPeriodEnd
+
+ fmt.Printf("[5/10] Swapping default payment method to tok_chargeCustomerFail (fails on charge)\n")
+ failPM, err := createCardPaymentMethod(ctx, sc, "tok_chargeCustomerFail")
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ _, err = sc.V1PaymentMethods.Attach(ctx, failPM.ID, &stripe.PaymentMethodAttachParams{
+ Customer: stripe.String(customer.ID),
+ })
+ if err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("attach failing card: %w", err)
+ }
+ if err := setDefaultPaymentMethod(ctx, sc, customer.ID, subscriptionID, failPM.ID); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ fmt.Printf("[6/10] Advancing test clock past billing period end\n")
+ targetTime := time.Unix(periodEnd, 0).Add(time.Hour)
+ clockTime, err := advanceTestClockTo(ctx, sc, testClock.ID, targetTime)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" clock frozen_time=%s (period_end was %s)\n",
+ clockTime.UTC().Format(time.RFC3339),
+ time.Unix(periodEnd, 0).UTC().Format(time.RFC3339))
+ website.SetSubscriptionNowForTests(clockTime)
+
+ fmt.Printf("[7/10] Waiting for renewal payment attempt to fail\n")
+ subscription, err = waitForSubscriptionStatus(ctx, sc, subscriptionID, "past_due", "unpaid", "incomplete")
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" subscription status=%s\n", subscription.Status)
+
+ invParams := &stripe.InvoiceRetrieveParams{}
+ invParams.AddExpand("payments.data.payment.payment_intent")
+ failedInvoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if failedInvoice == nil {
+ return subscriptionTestResultPass, fmt.Errorf("expected open renewal invoice after failed payment")
+ }
+ failedInvoice, err = sc.V1Invoices.Retrieve(ctx, failedInvoice.ID, invParams)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" renewal invoice_id=%s status=%s\n", failedInvoice.ID, failedInvoice.Status)
+
+ fmt.Printf("[8/10] Processing renewal failure webhooks\n")
+ // Stripe test clock may deliver real webhooks if `stripe listen` is running; reset to the
+ // expected pre-grace subscriber state so this scenario exercises our handlers.
+ _, err = pool.Exec(ctx, `
+ UPDATE hmn_user
+ SET
+ is_subscribed = true,
+ subscription_status = 'active',
+ grace_available = true,
+ grace_period_started_at = NULL,
+ grace_period_ends_at = NULL
+ WHERE id = $1
+ `, userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ failedInvoice, err = sc.V1Invoices.Retrieve(ctx, failedInvoice.ID, invParams)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.payment_failed", failedInvoice); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if pi, _, err := website.InvoicePaymentIntentForTests(ctx, sc, failedInvoice); err != nil {
+ return subscriptionTestResultPass, err
+ } else if pi != nil {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "payment_intent.payment_failed", pi); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if user.SubscriptionStatus == nil || *user.SubscriptionStatus != website.SubscriptionStatusGracePeriod {
+ return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=%s after renewal failure, got %s", website.SubscriptionStatusGracePeriod, stringOrEmpty(user.SubscriptionStatus))
+ }
+ if !user.IsSubscribed {
+ return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true during grace period")
+ }
+ if user.GraceAvailable {
+ return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace started")
+ }
+ if user.GracePeriodStartedAt == nil || user.GracePeriodEndsAt == nil {
+ return subscriptionTestResultPass, fmt.Errorf("expected grace period dates to be set")
+ }
+ fmt.Printf(" grace period started, ends %s\n", user.GracePeriodEndsAt.UTC().Format(time.RFC3339))
+
+ fmt.Printf("[9/10] Updating payment method to tok_visa and retrying payment\n")
+ recoveryPM, err := createCardPaymentMethod(ctx, sc, "tok_visa")
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ _, err = sc.V1PaymentMethods.Attach(ctx, recoveryPM.ID, &stripe.PaymentMethodAttachParams{
+ Customer: stripe.String(customer.ID),
+ })
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if err := setDefaultPaymentMethod(ctx, sc, customer.ID, subscriptionID, recoveryPM.ID); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ recoveryPMObj, err := sc.V1PaymentMethods.Retrieve(ctx, recoveryPM.ID, nil)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "payment_method.attached", recoveryPMObj); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ subscription, err = waitForSubscriptionStatus(ctx, sc, subscriptionID, "active", "trialing")
+ if err != nil {
+ // Retry may have paid the invoice without flipping status yet; process invoice.paid if present.
+ fmt.Printf(" subscription not active yet (%v); checking for paid invoice\n", err)
+ }
+
+ paidInvoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if paidInvoice != nil && paidInvoice.Status == stripe.InvoiceStatusPaid {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", paidInvoice); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ if subscription != nil {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+
+ fmt.Printf("[10/10] Verifying membership reinstated\n")
+ user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if !user.IsSubscribed {
+ return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after payment method update")
+ }
+ if user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" {
+ return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=active after recovery, got %s", stringOrEmpty(user.SubscriptionStatus))
+ }
+ if user.GracePeriodStartedAt != nil || user.GracePeriodEndsAt != nil {
+ return subscriptionTestResultPass, fmt.Errorf("expected grace period cleared after successful payment")
+ }
+ if !user.GraceAvailable {
+ return subscriptionTestResultPass, fmt.Errorf("expected grace_available=true after grace consumed and cleared")
+ }
+
+ printSubscriptionData(ctx, pool, userID)
+ return subscriptionTestResultPass, nil
+}
+
+func createCardPaymentMethod(ctx context.Context, sc *stripe.Client, token string) (*stripe.PaymentMethod, error) {
+ pm, err := sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{
+ Type: stripe.String("card"),
+ Card: &stripe.PaymentMethodCreateCardParams{
+ Token: stripe.String(token),
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ fmt.Printf(" payment_method_id=%s token=%s\n", pm.ID, token)
+ return pm, nil
+}
+
+func setDefaultPaymentMethod(ctx context.Context, sc *stripe.Client, customerID, subscriptionID, paymentMethodID string) error {
+ _, err := sc.V1Customers.Update(ctx, customerID, &stripe.CustomerUpdateParams{
+ InvoiceSettings: &stripe.CustomerUpdateInvoiceSettingsParams{
+ DefaultPaymentMethod: stripe.String(paymentMethodID),
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("update customer default payment method: %w", err)
+ }
+ _, err = sc.V1Subscriptions.Update(ctx, subscriptionID, &stripe.SubscriptionUpdateParams{
+ DefaultPaymentMethod: stripe.String(paymentMethodID),
+ })
+ if err != nil {
+ return fmt.Errorf("update subscription default payment method: %w", err)
+ }
+ return nil
+}
+
+func advanceTestClockTo(ctx context.Context, sc *stripe.Client, testClockID string, target time.Time) (time.Time, error) {
+ _, err := sc.V1TestHelpersTestClocks.Advance(ctx, testClockID, &stripe.TestHelpersTestClockAdvanceParams{
+ FrozenTime: stripe.Int64(target.Unix()),
+ })
+ if err != nil {
+ return time.Time{}, err
+ }
+ clock, err := waitForTestClockReady(ctx, sc, testClockID)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return time.Unix(clock.FrozenTime, 0), nil
+}
+
+func waitForSubscriptionStatus(ctx context.Context, sc *stripe.Client, subscriptionID string, statuses ...string) (*stripe.Subscription, error) {
+ want := make(map[string]struct{}, len(statuses))
+ for _, s := range statuses {
+ want[s] = struct{}{}
+ }
+
+ deadline := time.Now().Add(2 * time.Minute)
+ for time.Now().Before(deadline) {
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := want[string(sub.Status)]; ok {
+ return sub, nil
+ }
+ time.Sleep(2 * time.Second)
+ }
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
+ if err != nil {
+ return nil, err
+ }
+ return nil, fmt.Errorf("subscription %s did not reach status %v within timeout (last status=%s)", subscriptionID, statuses, sub.Status)
+}
+
+func retrieveLatestSubscriptionInvoice(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) (*stripe.Invoice, error) {
+ if sub == nil {
+ return nil, fmt.Errorf("subscription is nil")
+ }
+ subParams := &stripe.SubscriptionRetrieveParams{}
+ subParams.AddExpand("latest_invoice")
+ fresh, err := sc.V1Subscriptions.Retrieve(ctx, sub.ID, subParams)
+ if err != nil {
+ return nil, err
+ }
+ if fresh.LatestInvoice != nil && fresh.LatestInvoice.ID != "" {
+ return sc.V1Invoices.Retrieve(ctx, fresh.LatestInvoice.ID, nil)
+ }
+ return nil, nil
+}
+
+func dispatchMembershipWebhook(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, eventType string, obj any) error {
+ event, err := website.StripeEventFromObject(stripe.EventType(eventType), obj)
+ if err != nil {
+ return fmt.Errorf("build stripe event %s: %w", eventType, err)
+ }
+ website.ProcessMembershipStripeWebhookForTests(ctx, pool, sc, event)
+ return nil
+}
+
+func setupACHPendingOnClock(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, testClockID string) (*achTestSetup, error) {
username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
fmt.Printf("[2/7] Creating test user: %s\n", username)
- userID, emailAddress := createSubscriptionTestUser(ctx, conn, username)
+ userID, emailAddress := createSubscriptionTestUser(ctx, pool, username)
fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress)
fmt.Printf("[3/7] Creating Stripe customer on test clock\n")
@@ -327,10 +807,10 @@ func setupACHPendingOnClock(ctx context.Context, conn db.ConnOrTx, sc *stripe.Cl
return nil, fmt.Errorf("attach ACH payment method: %w", err)
}
fmt.Printf(" ACH verification is pending; subscription will complete after verification.\n")
- if err := persistPendingVerificationState(ctx, conn, userID, customer.ID); err != nil {
+ if err := persistPendingVerificationState(ctx, pool, userID, customer.ID); err != nil {
return nil, err
}
- printSubscriptionData(ctx, conn, userID)
+ printSubscriptionData(ctx, pool, userID)
return &achTestSetup{
userID: userID,
@@ -397,7 +877,7 @@ func verifyACHPaymentMethod(ctx context.Context, sc *stripe.Client, customerID,
return nil
}
-func completeSubscription(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, userID int, customerID, paymentMethodID string) (subscriptionTestResult, error) {
+func completeSubscription(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, userID int, customerID, paymentMethodID string) (subscriptionTestResult, error) {
subscriptionParams := &stripe.SubscriptionCreateParams{
Customer: stripe.String(customerID),
DefaultPaymentMethod: stripe.String(paymentMethodID),
@@ -421,7 +901,7 @@ func completeSubscription(ctx context.Context, conn db.ConnOrTx, sc *stripe.Clie
fmt.Printf("[5/6] Writing subscription state to database\n")
renewalDate := getSubscriptionPeriodEndFromStripe(subscription)
isSubscribed := subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing
- _, err = conn.Exec(ctx, `
+ _, err = pool.Exec(ctx, `
UPDATE hmn_user
SET
is_subscribed = $1,
@@ -445,7 +925,7 @@ func completeSubscription(ctx context.Context, conn db.ConnOrTx, sc *stripe.Clie
}
if invoice != nil && invoice.StatusTransitions != nil && invoice.StatusTransitions.PaidAt > 0 {
paidAt := time.Unix(invoice.StatusTransitions.PaidAt, 0)
- _, err = conn.Exec(ctx, `
+ _, err = pool.Exec(ctx, `
INSERT INTO user_payment (user_id, stripe_invoice_id, amount_cents, currency, paid_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stripe_invoice_id) DO UPDATE SET
@@ -459,10 +939,10 @@ func completeSubscription(ctx context.Context, conn db.ConnOrTx, sc *stripe.Clie
}
fmt.Printf("[6/6] Verifying and printing stored subscription data\n")
- if err := validateStoredSubscriptionData(ctx, conn, userID, customerID, subscription.ID); err != nil {
+ if err := validateStoredSubscriptionData(ctx, pool, userID, customerID, subscription.ID); err != nil {
return subscriptionTestResultPass, err
}
- printSubscriptionData(ctx, conn, userID)
+ printSubscriptionData(ctx, pool, userID)
return subscriptionTestResultPass, nil
}
@@ -524,8 +1004,8 @@ func deleteTestClock(ctx context.Context, sc *stripe.Client, testClockID string)
}
}
-func validateStoredSubscriptionData(ctx context.Context, conn db.ConnOrTx, userID int, customerID string, subscriptionID string) error {
- user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+func validateStoredSubscriptionData(ctx context.Context, pool *pgxpool.Pool, userID int, customerID string, subscriptionID string) error {
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
if err != nil {
return err
}
@@ -541,12 +1021,12 @@ func validateStoredSubscriptionData(ctx context.Context, conn db.ConnOrTx, userI
return nil
}
-func createSubscriptionTestUser(ctx context.Context, conn db.ConnOrTx, username string) (int, string) {
+func createSubscriptionTestUser(ctx context.Context, pool *pgxpool.Pool, username string) (int, string) {
emailAddress := uuid.New().String() + "@example.com"
hashedPassword := auth.HashPassword("password")
var userID int
- err := conn.QueryRow(ctx, `
+ err := pool.QueryRow(ctx, `
INSERT INTO hmn_user (username, email, password, date_joined, registration_ip, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
@@ -567,8 +1047,8 @@ func getSubscriptionPeriodEndFromStripe(sub *stripe.Subscription) *time.Time {
return &t
}
-func printSubscriptionData(ctx context.Context, conn db.ConnOrTx, userID int) {
- user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+func printSubscriptionData(ctx context.Context, pool *pgxpool.Pool, userID int) {
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
if err != nil {
panic(err)
}
@@ -594,7 +1074,7 @@ func printSubscriptionData(ctx context.Context, conn db.ConnOrTx, userID int) {
}
fmt.Printf(" grace_available: %v\n", user.GraceAvailable)
- payments, err := db.Query[models.UserPayment](ctx, conn, `
+ payments, err := db.Query[models.UserPayment](ctx, pool, `
SELECT $columns
FROM user_payment
WHERE user_id = $1
@@ -631,8 +1111,24 @@ func isExpectedACHVerificationPending(err error) bool {
return strings.Contains(err.Error(), "must be verified before they can be attached to a customer")
}
-func persistPendingVerificationState(ctx context.Context, conn db.ConnOrTx, userID int, customerID string) error {
- _, err := conn.Exec(ctx, `
+func isStripeCardDeclined(err error) bool {
+ if err == nil {
+ return false
+ }
+ if strings.Contains(err.Error(), "card_declined") {
+ return true
+ }
+ var stripeErr *stripe.Error
+ if errors.As(err, &stripeErr) {
+ return stripeErr.Code == stripe.ErrorCodeCardDeclined ||
+ stripeErr.Type == stripe.ErrorTypeCard ||
+ stripeErr.DeclineCode == stripe.DeclineCodeGenericDecline
+ }
+ return false
+}
+
+func persistPendingVerificationState(ctx context.Context, pool *pgxpool.Pool, userID int, customerID string) error {
+ _, err := pool.Exec(ctx, `
UPDATE hmn_user
SET
is_subscribed = false,
diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html
index 9afec5ed..456dfe3c 100644
--- a/src/templates/src/include/header-2024.html
+++ b/src/templates/src/include/header-2024.html
@@ -1,303 +1,303 @@
-
-
-{{ if and .Header.ShowMembershipVerificationBanner (not .Header.SuppressBanners) }}
-
- Bank account verification pending{{ if gt .Header.MembershipGraceDaysRemaining 0 }} — membership benefits remain active for {{ .Header.MembershipGraceDaysRemaining }} more {{ if eq .Header.MembershipGraceDaysRemaining 1 }}day{{ else }}days{{ end }}{{ end }}.
- Verify bank account{{ svg "arrow-right" }}
-
-{{ end }}
-
-{{ if and .Header.ShowMembershipDiscordLinkBanner (not .Header.SuppressBanners) }}
-
-{{ end }}
-
-{{ if and (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }}
-
-
- {{ range $i, $breadcrumb := .Header.Breadcrumbs }}
- {{ if gt $i 0 }}
/{{ end }}
-
- {{ if $breadcrumb.Project }}
-
{{ template "project_logo.html" $breadcrumb.Project }}
- {{ end }}
-
{{ $breadcrumb.Name }}
-
- {{ end }}
-
-
-
-{{ else if and .Header.BannerEvent (not .Header.SuppressBanners) }}
- {{ with .Header.BannerEvent }}
- {{ template "event banner" . }}
- {{ end }}
-{{ end }}
-
-
-
-{{ define "event banner" }}
-{{ if eq .Slug "Essentials2026" }}
-
- {{ if gt .DaysUntilEnd 0 }}
- {{ if eq .DaysUntilStart 0 }}
- The Handmade Essentials Jam is currently underway!
- {{ else }}
- Join us for the Handmade Essentials Jam, April 13-19, 2026.
- {{ end }}
- More info{{ svg "arrow-right" }}
- {{ else }}
- The Handmade Essentials Jam just concluded.
- See the results{{ svg "arrow-right" }}
- {{ end }}
-
-{{ end }}
-{{ if eq .Slug "WRJ2025" }}
-
- {{ if gt .DaysUntilEnd 0 }}
- {{ if eq .DaysUntilStart 0 }}
- The 2025 Wheel Reinvention Jam is happening now.
- {{ else if eq .DaysUntilStart 1 }}
- Starting tomorrow.
- {{ else }}
- The 2025 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days.
- {{ end }}
- September 22-28, 2025.
- More info{{ svg "arrow-right" }}
- {{ else }}
- The 2025 Wheel Reinvention Jam just concluded. See the results.
- {{ end }}
-
-{{ end }}
-{{ if eq .Slug "XRay2025" }}
-
- X-Ray Jam. June 9-15, 2025.
- {{ if gt .DaysUntilEnd 0 }}
- {{ if eq .DaysUntilStart 0 }}
- Happening now.
- {{ else if eq .DaysUntilStart 1 }}
- Tomorrow.
- {{ else }}
- In {{ .DaysUntilStart }} days.
- {{ end }}
- {{ else }}
- See the results.
- {{ end }}
-
-{{ end }}
-{{ if eq .Slug "VJ2024" }}
-
- Visibility Jam. July 19-21, 2024.
- {{ if gt .DaysUntilEnd 0 }}
- {{ if eq .DaysUntilStart 0 }}
- Happening now.
- {{ else if eq .DaysUntilStart 1 }}
- Starting tomorrow.
- {{ else }}
- In {{ .DaysUntilStart }} days.
- {{ end }}
- {{ else }}
- See the results.
- {{ end }}
-
-{{ end }}
-{{ if eq .Slug "WRJ2024" }}
-
- {{ if gt .DaysUntilEnd 0 }}
- {{ if eq .DaysUntilStart 0 }}
- The 2024 Wheel Reinvention Jam is happening now.
- {{ else if eq .DaysUntilStart 1 }}
- Starting tomorrow.
- {{ else }}
- The 2024 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days.
- {{ end }}
- September 23-29, 2024.
- More info{{ svg "arrow-right" }}
- {{ else }}
- The 2024 Wheel Reinvention Jam just concluded. See the results.
- {{ end }}
-
-{{ end }}
+
+
+{{ if and .Header.ShowMembershipVerificationBanner (not .Header.SuppressBanners) }}
+
+ Bank account verification pending{{ if gt .Header.MembershipGraceDaysRemaining 0 }} — membership benefits remain active for {{ .Header.MembershipGraceDaysRemaining }} more {{ if eq .Header.MembershipGraceDaysRemaining 1 }}day{{ else }}days{{ end }}{{ end }}.
+ Verify bank account{{ svg "arrow-right" }}
+
+{{ end }}
+
+{{ if and .Header.ShowMembershipDiscordLinkBanner (not .Header.SuppressBanners) }}
+
+{{ end }}
+
+{{ if and (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }}
+
+
+ {{ range $i, $breadcrumb := .Header.Breadcrumbs }}
+ {{ if gt $i 0 }}
/{{ end }}
+
+ {{ if $breadcrumb.Project }}
+
{{ template "project_logo.html" $breadcrumb.Project }}
+ {{ end }}
+
{{ $breadcrumb.Name }}
+
+ {{ end }}
+
+
+
+{{ else if and .Header.BannerEvent (not .Header.SuppressBanners) }}
+ {{ with .Header.BannerEvent }}
+ {{ template "event banner" . }}
+ {{ end }}
+{{ end }}
+
+
+
+{{ define "event banner" }}
+{{ if eq .Slug "Essentials2026" }}
+
+ {{ if gt .DaysUntilEnd 0 }}
+ {{ if eq .DaysUntilStart 0 }}
+ The Handmade Essentials Jam is currently underway!
+ {{ else }}
+ Join us for the Handmade Essentials Jam, April 13-19, 2026.
+ {{ end }}
+ More info{{ svg "arrow-right" }}
+ {{ else }}
+ The Handmade Essentials Jam just concluded.
+ See the results{{ svg "arrow-right" }}
+ {{ end }}
+
+{{ end }}
+{{ if eq .Slug "WRJ2025" }}
+
+ {{ if gt .DaysUntilEnd 0 }}
+ {{ if eq .DaysUntilStart 0 }}
+ The 2025 Wheel Reinvention Jam is happening now.
+ {{ else if eq .DaysUntilStart 1 }}
+ Starting tomorrow.
+ {{ else }}
+ The 2025 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days.
+ {{ end }}
+ September 22-28, 2025.
+ More info{{ svg "arrow-right" }}
+ {{ else }}
+ The 2025 Wheel Reinvention Jam just concluded. See the results.
+ {{ end }}
+
+{{ end }}
+{{ if eq .Slug "XRay2025" }}
+
+ X-Ray Jam. June 9-15, 2025.
+ {{ if gt .DaysUntilEnd 0 }}
+ {{ if eq .DaysUntilStart 0 }}
+ Happening now.
+ {{ else if eq .DaysUntilStart 1 }}
+ Tomorrow.
+ {{ else }}
+ In {{ .DaysUntilStart }} days.
+ {{ end }}
+ {{ else }}
+ See the results.
+ {{ end }}
+
+{{ end }}
+{{ if eq .Slug "VJ2024" }}
+
+ Visibility Jam. July 19-21, 2024.
+ {{ if gt .DaysUntilEnd 0 }}
+ {{ if eq .DaysUntilStart 0 }}
+ Happening now.
+ {{ else if eq .DaysUntilStart 1 }}
+ Starting tomorrow.
+ {{ else }}
+ In {{ .DaysUntilStart }} days.
+ {{ end }}
+ {{ else }}
+ See the results.
+ {{ end }}
+
+{{ end }}
+{{ if eq .Slug "WRJ2024" }}
+
+ {{ if gt .DaysUntilEnd 0 }}
+ {{ if eq .DaysUntilStart 0 }}
+ The 2024 Wheel Reinvention Jam is happening now.
+ {{ else if eq .DaysUntilStart 1 }}
+ Starting tomorrow.
+ {{ else }}
+ The 2024 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days.
+ {{ end }}
+ September 23-29, 2024.
+ More info{{ svg "arrow-right" }}
+ {{ else }}
+ The 2024 Wheel Reinvention Jam just concluded. See the results.
+ {{ end }}
+
+{{ end }}
{{ end }}
\ No newline at end of file
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index ec8ea15a..df7f5e34 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -109,6 +109,43 @@ func clearGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error {
return nil
}
+func activateSubscriptionAfterSuccessfulPayment(ctx context.Context, conn db.ConnOrTx, userID int, currentPeriodEnd *time.Time) error {
+ if err := clearGracePeriod(ctx, conn, userID); err != nil {
+ return err
+ }
+ if currentPeriodEnd != nil {
+ _, err := conn.Exec(ctx, `
+ UPDATE hmn_user
+ SET is_subscribed = true, subscription_status = 'active', current_period_end = $1
+ WHERE id = $2
+ `, currentPeriodEnd, userID)
+ return err
+ }
+ _, err := conn.Exec(ctx, `
+ UPDATE hmn_user
+ SET is_subscribed = true, subscription_status = 'active'
+ WHERE id = $1
+ `, userID)
+ return err
+}
+
+func subscriptionIDFromInvoice(inv *stripe.Invoice) string {
+ if inv == nil {
+ return ""
+ }
+ if inv.Lines != nil {
+ for _, line := range inv.Lines.Data {
+ if line.Subscription != nil && line.Subscription.ID != "" {
+ return line.Subscription.ID
+ }
+ }
+ }
+ if inv.Parent != nil && inv.Parent.SubscriptionDetails != nil && inv.Parent.SubscriptionDetails.Subscription != nil {
+ return inv.Parent.SubscriptionDetails.Subscription.ID
+ }
+ return ""
+}
+
func expireGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error {
_, err := conn.Exec(ctx, `
UPDATE hmn_user
@@ -242,15 +279,13 @@ func retryPastDueSubscriptionPayment(ctx context.Context, conn db.ConnOrTx, sc *
logging.Info().Int("userID", user.ID).Str("invoiceID", invoiceID).Str("status", string(inv.Status)).Msg("retried open subscription invoice payment")
if inv.Status == stripe.InvoiceStatusPaid {
- if err := clearGracePeriod(ctx, conn, user.ID); err != nil {
- return err
+ var renewalDate *time.Time
+ if user.StripeSubscriptionID != nil {
+ if sub, retrieveErr := sc.V1Subscriptions.Retrieve(ctx, *user.StripeSubscriptionID, nil); retrieveErr == nil {
+ renewalDate = getSubscriptionPeriodEnd(sub)
+ }
}
- _, err = conn.Exec(ctx, `
- UPDATE hmn_user
- SET is_subscribed = true, subscription_status = 'active'
- WHERE id = $1
- `, user.ID)
- if err != nil {
+ if err := activateSubscriptionAfterSuccessfulPayment(ctx, conn, user.ID, renewalDate); err != nil {
return err
}
SyncSupporterDiscordRole(ctx, conn, user.ID)
diff --git a/src/website/subscription_grace_eligibility.go b/src/website/subscription_grace_eligibility.go
index 837a9d96..4ded7852 100644
--- a/src/website/subscription_grace_eligibility.go
+++ b/src/website/subscription_grace_eligibility.go
@@ -2,7 +2,9 @@ package website
import (
"context"
+ "time"
+ "git.handmade.network/hmn/hmn/src/models"
"github.com/stripe/stripe-go/v84"
)
@@ -25,19 +27,35 @@ func paymentIntentHasMicrodepositVerification(pi *stripe.PaymentIntent) bool {
// shouldGrantGraceForPaymentIntent returns true when payment is in-flight for an async
// method (e.g. ACH processing or microdeposit verification), not a card decline.
func shouldGrantGraceForPaymentIntent(pi *stripe.PaymentIntent, paymentMethodType string) bool {
- if pi == nil || !isAsyncPaymentMethodType(paymentMethodType) {
+ if pi == nil {
return false
}
switch pi.Status {
- case stripe.PaymentIntentStatusProcessing:
- return true
case stripe.PaymentIntentStatusRequiresAction:
+ // Bank microdeposit verification; payment method type is often unset on the PI this early.
return paymentIntentHasMicrodepositVerification(pi)
+ case stripe.PaymentIntentStatusProcessing:
+ return isAsyncPaymentMethodType(resolvePaymentMethodType(pi, paymentMethodType))
default:
return false
}
}
+func resolvePaymentMethodType(pi *stripe.PaymentIntent, explicit string) string {
+ if explicit != "" {
+ return explicit
+ }
+ if pi.PaymentMethod != nil && pi.PaymentMethod.Type != "" {
+ return string(pi.PaymentMethod.Type)
+ }
+ for _, t := range pi.PaymentMethodTypes {
+ if isAsyncPaymentMethodType(t) {
+ return t
+ }
+ }
+ return ""
+}
+
func paymentIntentIsHardDecline(pi *stripe.PaymentIntent, paymentMethodType string) bool {
if pi == nil || isAsyncPaymentMethodType(paymentMethodType) {
return false
@@ -72,8 +90,8 @@ func paymentIntentPaymentMethodType(ctx context.Context, sc *stripe.Client, pi *
if pi == nil {
return ""
}
- if pi.PaymentMethod != nil && pi.PaymentMethod.Type != "" {
- return string(pi.PaymentMethod.Type)
+ if resolved := resolvePaymentMethodType(pi, ""); resolved != "" {
+ return resolved
}
if pi.PaymentMethod == nil || pi.PaymentMethod.ID == "" {
return ""
@@ -113,7 +131,7 @@ func invoicePaymentIntent(ctx context.Context, sc *stripe.Client, inv *stripe.In
params := &stripe.InvoicePaymentListParams{
Invoice: stripe.String(inv.ID),
}
- params.AddExpand("data.payment.payment_intent.payment_method")
+ params.AddExpand("data.payment.payment_intent")
var pi *stripe.PaymentIntent
sc.V1InvoicePayments.List(ctx, params)(func(ip *stripe.InvoicePayment, err error) bool {
@@ -141,7 +159,7 @@ func shouldGrantGraceForSubscription(ctx context.Context, sc *stripe.Client, sub
return false
}
invParams := &stripe.InvoiceRetrieveParams{}
- invParams.AddExpand("payments.data.payment.payment_intent.payment_method")
+ invParams.AddExpand("payments.data.payment.payment_intent")
inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams)
if err != nil {
return false
@@ -168,3 +186,16 @@ func invoicePaymentIsHardDecline(ctx context.Context, sc *stripe.Client, inv *st
}
return paymentIntentIsHardDecline(pi, pmType)
}
+
+// shouldStartGraceOnPaymentFailure returns true when a failed payment should begin the
+// one-time grace period. Async methods (ACH processing / verification) always qualify;
+// card declines qualify only for existing subscribers (renewal), not initial sign-up.
+func shouldStartGraceOnPaymentFailure(user *models.User, now time.Time, asyncGraceEligible bool) bool {
+ if user == nil || !canStartGrace(user, now) {
+ return false
+ }
+ if asyncGraceEligible {
+ return true
+ }
+ return user.IsSubscribed
+}
diff --git a/src/website/subscription_grace_eligibility_test.go b/src/website/subscription_grace_eligibility_test.go
index 9f0d84b3..fb18d1ac 100644
--- a/src/website/subscription_grace_eligibility_test.go
+++ b/src/website/subscription_grace_eligibility_test.go
@@ -2,9 +2,12 @@ package website
import (
"testing"
+ "time"
"github.com/stripe/stripe-go/v84"
"github.com/stretchr/testify/assert"
+
+ "git.handmade.network/hmn/hmn/src/models"
)
func TestShouldGrantGraceForPaymentIntent(t *testing.T) {
@@ -22,6 +25,23 @@ func TestShouldGrantGraceForPaymentIntent(t *testing.T) {
},
}
assert.True(t, shouldGrantGraceForPaymentIntent(achVerify, "us_bank_account"))
+ assert.True(t, shouldGrantGraceForPaymentIntent(achVerify, ""))
+
+ cardVerify := &stripe.PaymentIntent{
+ Status: stripe.PaymentIntentStatusRequiresAction,
+ NextAction: &stripe.PaymentIntentNextAction{
+ Type: stripe.PaymentIntentNextActionTypeUseStripeSDK,
+ },
+ }
+ assert.False(t, shouldGrantGraceForPaymentIntent(cardVerify, "card"))
+}
+
+func TestResolvePaymentMethodType(t *testing.T) {
+ pi := &stripe.PaymentIntent{
+ PaymentMethodTypes: []string{"card", "us_bank_account"},
+ }
+ assert.Equal(t, "us_bank_account", resolvePaymentMethodType(pi, ""))
+ assert.Equal(t, "card", resolvePaymentMethodType(pi, "card"))
}
func TestPaymentIntentIsHardDecline(t *testing.T) {
@@ -42,3 +62,27 @@ func TestIsAsyncPaymentMethodType(t *testing.T) {
assert.True(t, isAsyncPaymentMethodType("us_bank_account"))
assert.False(t, isAsyncPaymentMethodType("card"))
}
+
+func TestShouldStartGraceOnPaymentFailure(t *testing.T) {
+ now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC)
+
+ activeSubscriber := &models.User{
+ IsSubscribed: true,
+ GraceAvailable: true,
+ }
+ assert.True(t, shouldStartGraceOnPaymentFailure(activeSubscriber, now, false))
+ assert.True(t, shouldStartGraceOnPaymentFailure(activeSubscriber, now, true))
+
+ initialSignup := &models.User{
+ IsSubscribed: false,
+ GraceAvailable: true,
+ }
+ assert.False(t, shouldStartGraceOnPaymentFailure(initialSignup, now, false))
+ assert.True(t, shouldStartGraceOnPaymentFailure(initialSignup, now, true))
+
+ noGraceLeft := &models.User{
+ IsSubscribed: true,
+ GraceAvailable: false,
+ }
+ assert.False(t, shouldStartGraceOnPaymentFailure(noGraceLeft, now, false))
+}
diff --git a/src/website/subscription_grace_revoke.go b/src/website/subscription_grace_revoke.go
index ea1c2c2e..2b63636f 100644
--- a/src/website/subscription_grace_revoke.go
+++ b/src/website/subscription_grace_revoke.go
@@ -7,6 +7,12 @@ import (
"git.handmade.network/hmn/hmn/src/logging"
)
+// RevokeSubscriptionAccessAfterDeclinedPayment clears member access when a payment
+// was declined (not processing). Exported for admin subscription test tooling.
+func RevokeSubscriptionAccessAfterDeclinedPayment(ctx context.Context, conn db.ConnOrTx, userID int, subscriptionStatus string) error {
+ return revokeSubscriptionAccessAfterDeclinedPayment(ctx, conn, userID, subscriptionStatus)
+}
+
// revokeSubscriptionAccessAfterDeclinedPayment clears member access when a payment
// was declined (not processing). Restores grace_available so a future ACH attempt can
// still use the one-time grace period.
diff --git a/src/website/subscription_grace_test.go b/src/website/subscription_grace_test.go
index 53c082b8..b518b1bd 100644
--- a/src/website/subscription_grace_test.go
+++ b/src/website/subscription_grace_test.go
@@ -6,6 +6,7 @@ import (
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/models"
+ "github.com/stripe/stripe-go/v84"
"github.com/stretchr/testify/assert"
)
@@ -143,3 +144,23 @@ func TestUserNeedsBankVerificationReminder(t *testing.T) {
SubscriptionStatus: statusPtr("active"),
}))
}
+
+func TestSubscriptionIDFromInvoice(t *testing.T) {
+ assert.Equal(t, "", subscriptionIDFromInvoice(nil))
+
+ assert.Equal(t, "sub_line", subscriptionIDFromInvoice(&stripe.Invoice{
+ Lines: &stripe.InvoiceLineItemList{
+ Data: []*stripe.InvoiceLineItem{
+ {Subscription: &stripe.Subscription{ID: "sub_line"}},
+ },
+ },
+ }))
+
+ assert.Equal(t, "sub_parent", subscriptionIDFromInvoice(&stripe.Invoice{
+ Parent: &stripe.InvoiceParent{
+ SubscriptionDetails: &stripe.InvoiceParentSubscriptionDetails{
+ Subscription: &stripe.Subscription{ID: "sub_parent"},
+ },
+ },
+ }))
+}
diff --git a/src/website/subscription_payment_intent_webhook.go b/src/website/subscription_payment_intent_webhook.go
index 6ccedbe5..5d5c0505 100644
--- a/src/website/subscription_payment_intent_webhook.go
+++ b/src/website/subscription_payment_intent_webhook.go
@@ -12,7 +12,9 @@ import (
func tryHandleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client, event *stripe.Event) bool {
switch event.Type {
- case stripe.EventTypePaymentIntentProcessing, stripe.EventTypePaymentIntentPaymentFailed:
+ case stripe.EventTypePaymentIntentProcessing,
+ stripe.EventTypePaymentIntentRequiresAction,
+ stripe.EventTypePaymentIntentPaymentFailed:
default:
return false
}
@@ -39,19 +41,31 @@ func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client,
return false
}
+ fullPI, err := retrievePaymentIntent(c, sc, pi.ID)
+ if err != nil {
+ logging.Warn().Err(err).Str("paymentIntentID", pi.ID).Msg("failed to retrieve payment intent for membership webhook")
+ fullPI = pi
+ } else if fullPI != nil {
+ pi = fullPI
+ }
+
pmType := paymentIntentPaymentMethodType(c, sc, pi)
now := SubscriptionNow()
switch eventType {
- case stripe.EventTypePaymentIntentProcessing:
+ case stripe.EventTypePaymentIntentProcessing, stripe.EventTypePaymentIntentRequiresAction:
if shouldGrantGraceForPaymentIntent(pi, pmType) && canStartGrace(user, now) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
- logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment_intent.processing")
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment intent webhook")
}
}
case stripe.EventTypePaymentIntentPaymentFailed:
if paymentIntentIsHardDecline(pi, pmType) {
- if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, "incomplete"); err != nil {
+ if shouldStartGraceOnPaymentFailure(user, now, false) {
+ if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment_intent.payment_failed")
+ }
+ } else if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, "incomplete"); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to revoke access from payment_intent.payment_failed")
}
}
diff --git a/src/website/subscription_stripe_testutil.go b/src/website/subscription_stripe_testutil.go
new file mode 100644
index 00000000..bbc1d7ce
--- /dev/null
+++ b/src/website/subscription_stripe_testutil.go
@@ -0,0 +1,67 @@
+package website
+
+import (
+ "context"
+ "encoding/json"
+
+ "git.handmade.network/hmn/hmn/src/logging"
+ "git.handmade.network/hmn/hmn/src/perf"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stripe/stripe-go/v84"
+)
+
+// ProcessMembershipStripeWebhookForTests routes a membership Stripe event through the
+// same handlers used by StripeWebhook. Intended for admin subscription integration tests.
+func ProcessMembershipStripeWebhookForTests(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, event *stripe.Event) {
+ if event == nil {
+ return
+ }
+
+ logger := logging.GlobalLogger()
+ c := &RequestContext{
+ ctx: ctx,
+ Conn: pool,
+ Logger: logger,
+ Perf: perf.MakeNewRequestPerf("subscription-test", "POST", "/stripe/webhook"),
+ }
+
+ if isMembershipGracePaymentRetryEvent(event) {
+ handleMembershipGracePaymentRetryWebhook(c, sc, event)
+ }
+
+ if tryHandleMembershipPaymentIntentWebhook(c, sc, event) {
+ return
+ }
+
+ handleMembershipStripeEvent(c, sc, event)
+}
+
+// StripeEventFromObject builds a synthetic Stripe webhook event from a Stripe object.
+func StripeEventFromObject(eventType stripe.EventType, obj any) (*stripe.Event, error) {
+ raw, err := json.Marshal(obj)
+ if err != nil {
+ return nil, err
+ }
+ return &stripe.Event{
+ Type: eventType,
+ Data: &stripe.EventData{Raw: raw},
+ }, nil
+}
+
+// InvoicePaymentIntentForTests resolves the payment intent on an invoice. Exported for admin tests.
+func InvoicePaymentIntentForTests(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) (*stripe.PaymentIntent, string, error) {
+ return invoicePaymentIntent(ctx, sc, inv)
+}
+
+// Exported for admin subscription integration tests.
+func RetrySubscriptionPaymentForTests(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, customerID string) error {
+ logger := logging.GlobalLogger()
+ c := &RequestContext{
+ ctx: ctx,
+ Conn: pool,
+ Logger: logger,
+ Perf: perf.MakeNewRequestPerf("subscription-test", "POST", "/stripe/webhook"),
+ }
+ maybeRetrySubscriptionPaymentForCustomer(c, sc, customerID)
+ return nil
+}
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index f579b4a0..785556f7 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -23,10 +23,10 @@ import (
// handleMembershipStripeEvent handles membership Stripe webhook events.
func handleMembershipStripeEvent(c *RequestContext, sc *stripe.Client, event *stripe.Event) {
switch event.Type {
- case "checkout.session.completed":
+ case "checkout.session.completed", "checkout.session.async_payment_succeeded":
var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
- c.Logger.Error().Err(err).Msg("failed to unmarshal checkout.session.completed")
+ c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to unmarshal checkout session")
return
}
handleCheckoutSessionCompleted(c, sc, &session)
@@ -441,6 +441,22 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
if err := startGracePeriod(c, c.Conn, userID, now); err != nil {
logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for processing checkout payment")
}
+ } else if isGraceActive(user, now) {
+ // Grace may already have started via payment_intent.requires_action/processing.
+ _, err = c.Conn.Exec(c, `
+ UPDATE hmn_user
+ SET
+ stripe_customer_id = $1,
+ stripe_subscription_id = $2,
+ cancel_at_period_end = false,
+ is_subscribed = true,
+ subscription_status = $4
+ WHERE id = $3
+ `, session.Customer.ID, session.Subscription.ID, userID, SubscriptionStatusGracePeriod)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to link checkout subscription during active grace")
+ }
+ SyncSupporterDiscordRole(c, c.Conn, userID)
} else {
_, err = c.Conn.Exec(c, `
UPDATE hmn_user
@@ -534,7 +550,7 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
}
}
- if isFailedPaymentStripeStatus(stripeStatus) && canStartGrace(user, now) && shouldGrantGraceForSubscription(c, sc, sub) {
+ if isFailedPaymentStripeStatus(stripeStatus) && shouldStartGraceOnPaymentFailure(user, now, shouldGrantGraceForSubscription(c, sc, sub)) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period")
return
@@ -560,23 +576,23 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
if !isFailedPaymentStripeStatus(stripeStatus) {
// Payment retry cleared the past-due state; fall through to active handling.
- } else {
- _, err = c.Conn.Exec(c, `
- UPDATE hmn_user
- SET
- stripe_customer_id = $1,
- stripe_subscription_id = $2,
- subscription_status = $3,
- cancel_at_period_end = $4,
- is_subscribed = true,
- current_period_end = $5
- WHERE id = $6
- `, sub.Customer.ID, sub.ID, SubscriptionStatusGracePeriod, isCancelling, renewalDate, user.ID)
- if err != nil {
- logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription grace state from webhook")
- }
- SyncSupporterDiscordRole(c, c.Conn, user.ID)
- return
+ } else if isGraceActive(user, now) {
+ // Grace already started via startGracePeriod; sync Stripe metadata only.
+ _, err = c.Conn.Exec(c, `
+ UPDATE hmn_user
+ SET
+ stripe_customer_id = $1,
+ stripe_subscription_id = $2,
+ cancel_at_period_end = $3,
+ is_subscribed = true,
+ current_period_end = $4
+ WHERE id = $5
+ `, sub.Customer.ID, sub.ID, isCancelling, renewalDate, user.ID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to sync subscription metadata during grace")
+ }
+ SyncSupporterDiscordRole(c, c.Conn, user.ID)
+ return
}
}
@@ -692,20 +708,22 @@ func handleInvoicePaid(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to record user payment")
}
- if err := clearGracePeriod(c, c.Conn, user.ID); err != nil {
- logging.Error().Err(err).Int("userID", user.ID).Msg("failed to clear subscription grace period after payment")
+ subscriptionID := subscriptionIDFromInvoice(inv)
+ if subscriptionID == "" && user.StripeSubscriptionID != nil {
+ subscriptionID = *user.StripeSubscriptionID
}
-
- if inv.Lines != nil && len(inv.Lines.Data) > 0 && inv.Lines.Data[0].Subscription != nil {
- sub, err := sc.V1Subscriptions.Retrieve(c, inv.Lines.Data[0].Subscription.ID, nil)
+ var renewalDate *time.Time
+ if subscriptionID != "" {
+ sub, err := sc.V1Subscriptions.Retrieve(c, subscriptionID, nil)
if err == nil {
- renewalDate := getSubscriptionPeriodEnd(sub)
- _, err = c.Conn.Exec(c, "UPDATE hmn_user SET current_period_end = $1, is_subscribed = true, subscription_status = 'active' WHERE id = $2", renewalDate, user.ID)
- if err != nil {
- logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update renewal date from invoice")
- }
+ renewalDate = getSubscriptionPeriodEnd(sub)
+ } else {
+ logging.Warn().Err(err).Int("userID", user.ID).Str("subscriptionID", subscriptionID).Msg("failed to retrieve subscription after invoice payment")
}
}
+ if err := activateSubscriptionAfterSuccessfulPayment(c, c.Conn, user.ID, renewalDate); err != nil {
+ logging.Error().Err(err).Int("userID", user.ID).Msg("failed to activate subscription after invoice payment")
+ }
attemptThankYouEmail(c, user.ID, inv.AmountPaid, inv.Currency)
SyncSupporterDiscordRole(c, c.Conn, user.ID)
@@ -724,11 +742,12 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip
now := SubscriptionNow()
grantGrace := shouldGrantGraceForInvoice(c, sc, inv)
- if grantGrace && canStartGrace(user, now) {
+ hardDecline := invoicePaymentIsHardDecline(c, sc, inv)
+ if shouldStartGraceOnPaymentFailure(user, now, grantGrace) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period from invoice.payment_failed")
}
- } else if invoicePaymentIsHardDecline(c, sc, inv) || userInGracePeriod(user) {
+ } else if hardDecline && !isGraceActive(user, now) {
status := "incomplete"
if inv.Status != "" {
status = string(inv.Status)
From 74b104ee1ec5990b4273ced50384bed4f5daf35d Mon Sep 17 00:00:00 2001
From: reece365
Date: Sun, 31 May 2026 16:06:57 -0500
Subject: [PATCH 08/15] Better email notifications for users to verify bank
account
---
src/email/email.go | 50 ++++++++++++++-
.../src/email_ach_verification_grace.html | 19 ++++++
src/templates/src/include/header-2024.html | 64 +++++++++++++++++++
src/templates/types.go | 1 +
src/website/base_data.go | 25 +++++++-
.../subscription_payment_intent_webhook.go | 14 ++++
src/website/subscriptions.go | 58 ++++++++++++++++-
7 files changed, 227 insertions(+), 4 deletions(-)
create mode 100644 src/templates/src/email_ach_verification_grace.html
diff --git a/src/email/email.go b/src/email/email.go
index 731ab99c..ce5a417f 100644
--- a/src/email/email.go
+++ b/src/email/email.go
@@ -265,6 +265,13 @@ type PaymentFailedEmailData struct {
GracePeriodEnd string
}
+type ACHVerificationGraceEmailData struct {
+ Name string
+ HomepageUrl string
+ ManageSubscriptionUrl string
+ GracePeriodEnd string
+}
+
func SendPaymentFailedEmail(
toAddress string,
toName string,
@@ -311,6 +318,43 @@ func SendPaymentFailedEmail(
return nil
}
+func SendACHVerificationGraceEmail(
+ toAddress string,
+ toName string,
+ gracePeriodEnd *time.Time,
+ perf *perf.RequestPerf,
+) error {
+ defer perf.StartBlock("EMAIL", "ACH verification grace email").End()
+
+ gracePeriodEndStr := ""
+ if gracePeriodEnd != nil && !gracePeriodEnd.IsZero() {
+ gracePeriodEndStr = gracePeriodEnd.Format("January 2, 2006")
+ }
+
+ b1 := perf.StartBlock("EMAIL", "Rendering template")
+ defer b1.End()
+ contents, err := renderTemplate("email_ach_verification_grace.html", ACHVerificationGraceEmailData{
+ Name: toName,
+ HomepageUrl: hmnurl.BuildHomepage(),
+ ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(),
+ GracePeriodEnd: gracePeriodEndStr,
+ })
+ if err != nil {
+ return err
+ }
+ b1.End()
+
+ b2 := perf.StartBlock("EMAIL", "Sending email")
+ defer b2.End()
+ err = sendMail(toAddress, toName, "[Handmade Software Foundation] Verify your bank account", contents)
+ if err != nil {
+ return oops.New(err, "Failed to send email")
+ }
+ b2.End()
+
+ return nil
+}
+
func SendExpoTicketPurchaseEmail(toAddress string, toName string, ticket *models.Ticket) error {
event, ok := hmndata.FindTicketEventBySlug(ticket.EventSlug)
if !ok {
@@ -380,9 +424,13 @@ func sendMail(toAddress, toName, subject string, contentHTML []byte) error {
subject,
processedHTML,
)
+ var auth smtp.Auth
+ if config.Config.Email.MailerUsername != "" || config.Config.Email.MailerPassword != "" {
+ auth = smtp.PlainAuth("", config.Config.Email.MailerUsername, config.Config.Email.MailerPassword, config.Config.Email.ServerAddress)
+ }
return smtp.SendMail(
fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort),
- smtp.PlainAuth("", config.Config.Email.MailerUsername, config.Config.Email.MailerPassword, config.Config.Email.ServerAddress),
+ auth,
config.Config.Email.FromAddress,
[]string{toAddress},
contents,
diff --git a/src/templates/src/email_ach_verification_grace.html b/src/templates/src/email_ach_verification_grace.html
new file mode 100644
index 00000000..87457082
--- /dev/null
+++ b/src/templates/src/email_ach_verification_grace.html
@@ -0,0 +1,19 @@
+
+ Hello {{ .Name }},
+
+
+ Thank you for supporting the Handmade Network.
+
+
+ Your membership has started, and we are waiting for your bank account verification to complete.
+
+
+ Please verify your bank account within 7 days by visiting {{ .ManageSubscriptionUrl }}.
+
+{{ if .GracePeriodEnd }}
+
+ Your grace period ends on {{ .GracePeriodEnd }}. If verification is not completed by then, your membership access will end until payment is completed.
+
+{{ end }}
+Thanks,
+ The Handmade Network staff.
diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html
index 456dfe3c..3a0e8134 100644
--- a/src/templates/src/include/header-2024.html
+++ b/src/templates/src/include/header-2024.html
@@ -77,13 +77,77 @@
{{ if and .Header.ShowMembershipVerificationBanner (not .Header.SuppressBanners) }}
Bank account verification pending{{ if gt .Header.MembershipGraceDaysRemaining 0 }} — membership benefits remain active for {{ .Header.MembershipGraceDaysRemaining }} more {{ if eq .Header.MembershipGraceDaysRemaining 1 }}day{{ else }}days{{ end }}{{ end }}.
Verify bank account{{ svg "arrow-right" }}
+
{{ end }}
{{ if and .Header.ShowMembershipDiscordLinkBanner (not .Header.SuppressBanners) }}
diff --git a/src/templates/types.go b/src/templates/types.go
index 9e6fd43d..c16a3103 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -84,6 +84,7 @@ type Header struct {
ShowMembershipVerificationBanner bool
MembershipVerificationUrl string
MembershipGraceDaysRemaining int
+ MembershipVerificationStateKey string
ShowMembershipDiscordLinkBanner bool
MembershipDiscordLinkUrl string
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 98bf3f5a..44f4f173 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -1,6 +1,7 @@
package website
import (
+ "fmt"
"time"
"git.handmade.network/hmn/hmn/src/buildcss"
@@ -133,7 +134,8 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
if c.CurrentUser != nil {
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
- if userNeedsBankVerificationReminder(c.CurrentUser) {
+ bankVerificationJustCompleted := c.Req != nil && c.Req.URL != nil && c.Req.URL.Query().Get("bank_verified") == "1"
+ if userNeedsBankVerificationReminder(c.CurrentUser) && !bankVerificationJustCompleted {
baseData.Header.ShowMembershipVerificationBanner = true
bannerURL := hmnurl.BuildHSFMembership()
if c.CurrentUser.StripeSubscriptionID != nil && config.Config.Stripe.SecretKey != "" {
@@ -144,6 +146,13 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
}
baseData.Header.MembershipVerificationUrl = bannerURL
baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow())
+ baseData.Header.MembershipVerificationStateKey = fmt.Sprintf(
+ "user:%d|status:%s|grace_end:%s|days:%d",
+ c.CurrentUser.ID,
+ stringOrEmpty(c.CurrentUser.SubscriptionStatus),
+ timeOrEmpty(c.CurrentUser.GracePeriodEndsAt),
+ baseData.Header.MembershipGraceDaysRemaining,
+ )
}
if userNeedsDiscordLinkReminder(c.CurrentUser) {
baseData.Header.ShowMembershipDiscordLinkBanner = true
@@ -178,6 +187,20 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
return baseData
}
+func stringOrEmpty(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
+
+func timeOrEmpty(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return t.UTC().Format(time.RFC3339)
+}
+
func buildDefaultOpenGraphItems(project *models.Project, projectLogoUrl string, title string) []templates.OpenGraphItem {
if title == "" {
title = "Handmade Network"
diff --git a/src/website/subscription_payment_intent_webhook.go b/src/website/subscription_payment_intent_webhook.go
index 5d5c0505..a86071c6 100644
--- a/src/website/subscription_payment_intent_webhook.go
+++ b/src/website/subscription_payment_intent_webhook.go
@@ -55,8 +55,22 @@ func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client,
switch eventType {
case stripe.EventTypePaymentIntentProcessing, stripe.EventTypePaymentIntentRequiresAction:
if shouldGrantGraceForPaymentIntent(pi, pmType) && canStartGrace(user, now) {
+ if user.StripeCustomerID == nil || user.StripeSubscriptionID == nil {
+ return true
+ }
+ invoiceID, invoiceErr := findOpenSubscriptionInvoice(c, sc, *user.StripeCustomerID, *user.StripeSubscriptionID)
+ if invoiceErr != nil {
+ logging.Warn().Err(invoiceErr).Int("userID", user.ID).Msg("failed to resolve open subscription invoice for payment intent webhook")
+ return true
+ }
+ if invoiceID == "" {
+ logging.Info().Int("userID", user.ID).Str("eventType", string(eventType)).Msg("skipping grace start; no open subscription invoice")
+ return true
+ }
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment intent webhook")
+ } else {
+ sendACHVerificationGraceEmail(c, user.ID)
}
}
case stripe.EventTypePaymentIntentPaymentFailed:
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index 785556f7..f9358519 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "net/url"
"strconv"
"strings"
"time"
@@ -440,6 +441,8 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
}
if err := startGracePeriod(c, c.Conn, userID, now); err != nil {
logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for processing checkout payment")
+ } else {
+ sendACHVerificationGraceEmail(c, userID)
}
} else if isGraceActive(user, now) {
// Grace may already have started via payment_intent.requires_action/processing.
@@ -506,6 +509,19 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
}
}
+func sendACHVerificationGraceEmail(c *RequestContext, userID int) {
+ user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to fetch user for ACH verification grace email")
+ return
+ }
+
+ err = email.SendACHVerificationGraceEmail(user.Email, user.BestName(), user.GracePeriodEndsAt, c.Perf)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to send ACH verification grace email")
+ }
+}
+
func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) {
renewalDate := getSubscriptionPeriodEnd(sub)
now := SubscriptionNow()
@@ -550,10 +566,13 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
}
}
- if isFailedPaymentStripeStatus(stripeStatus) && shouldStartGraceOnPaymentFailure(user, now, shouldGrantGraceForSubscription(c, sc, sub)) {
+ asyncGraceEligible := shouldGrantGraceForSubscription(c, sc, sub)
+ if isFailedPaymentStripeStatus(stripeStatus) && shouldStartGraceOnPaymentFailure(user, now, asyncGraceEligible) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period")
return
+ } else if asyncGraceEligible {
+ sendACHVerificationGraceEmail(c, user.ID)
}
} else if isFailedPaymentStripeStatus(stripeStatus) && !isGraceActive(user, now) {
_, err = c.Conn.Exec(c, `
@@ -746,6 +765,8 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip
if shouldStartGraceOnPaymentFailure(user, now, grantGrace) {
if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period from invoice.payment_failed")
+ } else if grantGrace {
+ sendACHVerificationGraceEmail(c, user.ID)
}
} else if hardDecline && !isGraceActive(user, now) {
status := "incomplete"
@@ -987,5 +1008,38 @@ func paymentIntentHostedVerificationURL(pi *stripe.PaymentIntent) string {
if pi == nil || pi.NextAction == nil || pi.NextAction.VerifyWithMicrodeposits == nil {
return ""
}
- return pi.NextAction.VerifyWithMicrodeposits.HostedVerificationURL
+ hostedURL := pi.NextAction.VerifyWithMicrodeposits.HostedVerificationURL
+ if hostedURL == "" {
+ return ""
+ }
+ return appendReturnURLParam(hostedURL, bankVerificationReturnURL())
+}
+
+func appendReturnURLParam(rawURL, returnURL string) string {
+ if rawURL == "" || returnURL == "" {
+ return rawURL
+ }
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return rawURL
+ }
+ q := u.Query()
+ q.Set("return_url", returnURL)
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+func bankVerificationReturnURL() string {
+ const verificationCompleteParam = "bank_verified"
+ const verificationCompleteValue = "1"
+
+ base := hmnurl.BuildHSFMembership()
+ u, err := url.Parse(base)
+ if err != nil {
+ return base
+ }
+ q := u.Query()
+ q.Set(verificationCompleteParam, verificationCompleteValue)
+ u.RawQuery = q.Encode()
+ return u.String()
}
From 68d1207f42d533f156ca372fde8fa66f32eac325 Mon Sep 17 00:00:00 2001
From: reece365
Date: Mon, 1 Jun 2026 17:49:19 -0500
Subject: [PATCH 09/15] Split up membership admin tools for readability of
tests
---
src/admintools/adminsubscription.go | 339 +++++++++---------
src/admintools/adminsubscription_cmd.go | 78 ++++
src/admintools/adminsubscription_helpers.go | 28 ++
src/admintools/adminsubscription_scenarios.go | 68 ++++
...-01T055600Z_AddStripeWebhookEventLedger.go | 45 +++
src/website/stripe.go | 27 ++
src/website/subscription_grace.go | 16 +-
.../subscription_payment_intent_webhook.go | 7 +-
src/website/subscriptions.go | 15 +-
9 files changed, 442 insertions(+), 181 deletions(-)
create mode 100644 src/admintools/adminsubscription_cmd.go
create mode 100644 src/admintools/adminsubscription_helpers.go
create mode 100644 src/admintools/adminsubscription_scenarios.go
create mode 100644 src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go
diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go
index f746e8c5..0922289e 100644
--- a/src/admintools/adminsubscription.go
+++ b/src/admintools/adminsubscription.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net"
- "os"
"strconv"
"strings"
"time"
@@ -17,113 +16,9 @@ import (
"git.handmade.network/hmn/hmn/src/website"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
- "github.com/spf13/cobra"
"github.com/stripe/stripe-go/v84"
)
-func addSubscriptionCommands(adminCommand *cobra.Command) {
- cmd := &cobra.Command{
- Use: "subscription",
- Short: "Admin commands for subscription testing",
- }
- adminCommand.AddCommand(cmd)
-
- addSubscriptionTestCommand(cmd)
-}
-
-func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
- cmd := &cobra.Command{
- Use: "test",
- Short: "Run subscription test scenarios and print stored DB results",
- Run: func(cmd *cobra.Command, _ []string) {
- if config.Config.Stripe.SecretKey == "" || config.Config.Stripe.PriceID == "" {
- fmt.Fprintf(os.Stderr, "Stripe.SecretKey and Stripe.PriceID must be set in config.\n")
- os.Exit(1)
- }
-
- ctx := context.Background()
- pool := db.NewConnPool()
- defer pool.Close()
-
- if override := config.Config.Stripe.SubscriptionNowOverride; override != "" {
- fmt.Printf("Using subscription time override: %s\n", override)
- }
- if testClockID := config.Config.Stripe.TestClockID; testClockID != "" {
- fmt.Printf("Using Stripe test clock: %s\n", testClockID)
- }
-
- sc := stripe.NewClient(config.Config.Stripe.SecretKey)
- scenarios := []subscriptionTestScenario{
- {
- Name: "Credit card (tok_visa)",
- CreatePaymentMethod: func(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) {
- return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{
- Type: stripe.String("card"),
- Card: &stripe.PaymentMethodCreateCardParams{
- Token: stripe.String("tok_visa"),
- },
- })
- },
- },
- {
- Name: "Credit card declined (tok_chargeDeclined)",
- Run: runDeclinedCardScenario,
- },
- {
- Name: "ACH (US bank account)",
- CreatePaymentMethod: createACHPaymentMethod,
- },
- {
- Name: "ACH grace expires after 2 week clock advance",
- Run: runACHGraceExpiryScenario,
- },
- {
- Name: "ACH verification after 2 day clock advance",
- Run: runACHVerificationAfterAdvanceScenario,
- },
- {
- Name: "Card renewal failure → grace → payment method update",
- Run: runCardRenewalFailureGraceRecoveryScenario,
- },
- }
-
- failed := false
- for i, scenario := range scenarios {
- fmt.Printf("\n========== Scenario %d/%d: %s ==========\n", i+1, len(scenarios), scenario.Name)
- result, err := runSubscriptionScenario(ctx, pool, sc, scenario)
- if err != nil {
- failed = true
- fmt.Printf("RESULT: FAIL\n")
- fmt.Printf("ERROR: %v\n", err)
- } else if result == subscriptionTestResultPending {
- fmt.Printf("RESULT: PENDING (expected for ACH verification)\n")
- } else {
- fmt.Printf("RESULT: PASS\n")
- }
- }
-
- if failed {
- os.Exit(1)
- }
- },
- }
-
- subscriptionCommand.AddCommand(cmd)
-}
-
-type subscriptionTestScenario struct {
- Name string
- CreatePaymentMethod func(context.Context, *stripe.Client) (*stripe.PaymentMethod, error)
- Run func(context.Context, *pgxpool.Pool, *stripe.Client) (subscriptionTestResult, error)
-}
-
-type subscriptionTestResult int
-
-const (
- subscriptionTestResultPass subscriptionTestResult = iota
- subscriptionTestResultPending
-)
-
type achTestSetup struct {
userID int
customerID string
@@ -131,58 +26,78 @@ type achTestSetup struct {
testClockID string
}
-func runSubscriptionScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
- if scenario.Run != nil {
- return scenario.Run(ctx, pool, sc)
- }
- return runCardOrACHScenario(ctx, pool, sc, scenario)
-}
-
func runCardOrACHScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
- username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
- fmt.Printf("[1/6] Creating test user: %s\n", username)
- userID, emailAddress := createSubscriptionTestUser(ctx, pool, username)
- fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress)
+ sctx := newScenarioCtx(scenario.Name, 6)
- fmt.Printf("[2/6] Creating Stripe customer\n")
- customerParams := &stripe.CustomerCreateParams{
- Email: stripe.String(emailAddress),
- Name: stripe.String(username),
- Metadata: map[string]string{
- "user_id": strconv.Itoa(userID),
- },
- }
- if testClockID := config.Config.Stripe.TestClockID; testClockID != "" {
- customerParams.TestClock = stripe.String(testClockID)
+ username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
+ var userID int
+ var emailAddress string
+ if err := sctx.step(fmt.Sprintf("Creating test user: %s", username), func() error {
+ userID, emailAddress = createSubscriptionTestUser(ctx, pool, username)
+ sctx.printf("user_id=%d email=%s\n", userID, emailAddress)
+ return nil
+ }); err != nil {
+ return subscriptionTestResultPass, err
}
- customer, err := sc.V1Customers.Create(ctx, customerParams)
- if err != nil {
+
+ var customer *stripe.Customer
+ if err := sctx.step("Creating Stripe customer", func() error {
+ customerParams := &stripe.CustomerCreateParams{
+ Email: stripe.String(emailAddress),
+ Name: stripe.String(username),
+ Metadata: map[string]string{
+ "user_id": strconv.Itoa(userID),
+ },
+ }
+ if testClockID := config.Config.Stripe.TestClockID; testClockID != "" {
+ customerParams.TestClock = stripe.String(testClockID)
+ }
+ var err error
+ customer, err = sc.V1Customers.Create(ctx, customerParams)
+ if err != nil {
+ return err
+ }
+ sctx.printf("customer_id=%s\n", customer.ID)
+ return nil
+ }); err != nil {
return subscriptionTestResultPass, err
}
- fmt.Printf(" customer_id=%s\n", customer.ID)
- fmt.Printf("[3/6] Creating payment method (%s)\n", scenario.Name)
- paymentMethod, err := scenario.CreatePaymentMethod(ctx, sc)
- if err != nil {
+ var paymentMethod *stripe.PaymentMethod
+ if err := sctx.step(fmt.Sprintf("Creating payment method (%s)", scenario.Name), func() error {
+ var err error
+ paymentMethod, err = scenario.CreatePaymentMethod(ctx, sc)
+ if err != nil {
+ return err
+ }
+ sctx.printf("payment_method_id=%s\n", paymentMethod.ID)
+ return nil
+ }); err != nil {
return subscriptionTestResultPass, err
}
- fmt.Printf(" payment_method_id=%s\n", paymentMethod.ID)
- fmt.Printf("[4/6] Attaching payment method and creating subscription\n")
- _, err = sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{
- Customer: stripe.String(customer.ID),
- })
- if err != nil {
- if isExpectedACHVerificationPending(err) {
- fmt.Printf(" ACH verification is pending; subscription will complete after verification.\n")
+ var attachErr error
+ if err := sctx.step("Attaching payment method and creating membership", func() error {
+ _, attachErr = sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{
+ Customer: stripe.String(customer.ID),
+ })
+ if attachErr != nil && isExpectedACHVerificationPending(attachErr) {
+ sctx.printf("ACH verification is pending; membership will complete after verification.\n")
if updateErr := persistPendingVerificationState(ctx, pool, userID, customer.ID); updateErr != nil {
- return subscriptionTestResultPass, updateErr
+ return updateErr
}
printSubscriptionData(ctx, pool, userID)
- return subscriptionTestResultPending, nil
+ return nil
}
+ return attachErr
+ }); err != nil {
return subscriptionTestResultPass, err
}
+ if attachErr != nil {
+ if isExpectedACHVerificationPending(attachErr) {
+ return subscriptionTestResultPending, nil
+ }
+ }
return completeSubscription(ctx, pool, sc, userID, customer.ID, paymentMethod.ID)
}
@@ -222,7 +137,7 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
}
fmt.Printf(" payment_method_id=%s\n", paymentMethod.ID)
- fmt.Printf("[4/6] Attaching payment method and creating subscription\n")
+ fmt.Printf("[4/6] Attaching payment method and creating membership\n")
_, attachErr := sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(customer.ID),
})
@@ -256,11 +171,11 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
return subscriptionTestResultPass, createErr
}
if createErr != nil {
- fmt.Printf(" card declined during subscription create (expected)\n")
+ fmt.Printf(" card declined during membership create (expected)\n")
} else {
- fmt.Printf(" subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
+ fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
if subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing {
- return subscriptionTestResultPass, fmt.Errorf("expected subscription to fail payment, got status=%s", subscription.Status)
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription to fail payment, got status=%s", subscription.Status)
}
subscriptionID = subscription.ID
stripeStatus = string(subscription.Status)
@@ -288,7 +203,7 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
return subscriptionTestResultPass, err
}
- fmt.Printf("[6/6] Verifying stored subscription data after decline\n")
+ fmt.Printf("[6/6] Verifying stored membership data after decline\n")
user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
if err != nil {
return subscriptionTestResultPass, err
@@ -297,7 +212,7 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=false after card decline")
}
if user.SubscriptionStatus == nil || *user.SubscriptionStatus != stripeStatus {
- return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=%s, got %s", stripeStatus, stringOrEmpty(user.SubscriptionStatus))
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s, got %s", stripeStatus, stringOrEmpty(user.SubscriptionStatus))
}
if !user.GraceAvailable {
return subscriptionTestResultPass, fmt.Errorf("expected grace_available=true after card decline (grace not consumed)")
@@ -320,6 +235,96 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
return subscriptionTestResultPass, nil
}
+func runEuroCardChargeScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
+ sctx := newScenarioCtx("Credit card one-time charge (EUR)", 5)
+
+ username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8])
+ var userID int
+ var emailAddress string
+ if err := sctx.step(fmt.Sprintf("Creating test user for EUR charge: %s", username), func() error {
+ userID, emailAddress = createSubscriptionTestUser(ctx, pool, username)
+ sctx.printf("user_id=%d email=%s\n", userID, emailAddress)
+ return nil
+ }); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ var customer *stripe.Customer
+ if err := sctx.step("Creating Stripe customer", func() error {
+ customerParams := &stripe.CustomerCreateParams{
+ Email: stripe.String(emailAddress),
+ Name: stripe.String(username),
+ Metadata: map[string]string{
+ "user_id": strconv.Itoa(userID),
+ },
+ }
+ if testClockID := config.Config.Stripe.TestClockID; testClockID != "" {
+ customerParams.TestClock = stripe.String(testClockID)
+ }
+ var err error
+ customer, err = sc.V1Customers.Create(ctx, customerParams)
+ if err != nil {
+ return err
+ }
+ sctx.printf("customer_id=%s\n", customer.ID)
+ return nil
+ }); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ var pm *stripe.PaymentMethod
+ if err := sctx.step("Creating and attaching tok_visa payment method", func() error {
+ var err error
+ pm, err = createCardPaymentMethod(ctx, sc, "tok_visa")
+ if err != nil {
+ return err
+ }
+ _, err = sc.V1PaymentMethods.Attach(ctx, pm.ID, &stripe.PaymentMethodAttachParams{
+ Customer: stripe.String(customer.ID),
+ })
+ return err
+ }); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ var pi *stripe.PaymentIntent
+ if err := sctx.step("Creating one-time EUR card charge", func() error {
+ var err error
+ pi, err = sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{
+ Amount: stripe.Int64(500), // EUR 5.00
+ Currency: stripe.String("eur"),
+ Customer: stripe.String(customer.ID),
+ PaymentMethod: stripe.String(pm.ID),
+ PaymentMethodTypes: []*string{
+ stripe.String("card"),
+ },
+ Confirm: stripe.Bool(true),
+ Description: stripe.String("HMN admin membership test EUR card charge"),
+ })
+ if err != nil {
+ return err
+ }
+ sctx.printf("payment_intent_id=%s status=%s amount=%d currency=%s\n", pi.ID, pi.Status, pi.Amount, pi.Currency)
+ return nil
+ }); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ if err := sctx.step("Verifying one-time EUR charge success", func() error {
+ if pi.Currency != stripe.CurrencyEUR {
+ return fmt.Errorf("expected currency=eur, got %s", pi.Currency)
+ }
+ if pi.Status != stripe.PaymentIntentStatusSucceeded {
+ return fmt.Errorf("expected payment_intent status=succeeded, got %s", pi.Status)
+ }
+ return nil
+ }); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ return subscriptionTestResultPass, nil
+}
+
func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
defer website.ClearSubscriptionNowForTests()
@@ -365,7 +370,7 @@ func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stri
return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=false after grace expiry")
}
if user.SubscriptionStatus == nil || *user.SubscriptionStatus != website.SubscriptionStatusGraceFailed {
- return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=%s, got %s", website.SubscriptionStatusGraceFailed, stringOrEmpty(user.SubscriptionStatus))
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s, got %s", website.SubscriptionStatusGraceFailed, stringOrEmpty(user.SubscriptionStatus))
}
if user.GraceAvailable {
return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace expiry")
@@ -404,7 +409,7 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.P
return subscriptionTestResultPass, err
}
- fmt.Printf("[7/8] Attaching verified payment method and creating subscription\n")
+ fmt.Printf("[7/8] Attaching verified payment method and creating membership\n")
_, err = sc.V1PaymentMethods.Attach(ctx, setup.paymentMethodID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(setup.customerID),
})
@@ -417,7 +422,7 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.P
return result, err
}
- fmt.Printf("[8/8] Verifying subscription is active after ACH verification\n")
+ fmt.Printf("[8/8] Verifying membership is active after ACH verification\n")
user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
if err != nil {
return subscriptionTestResultPass, err
@@ -426,7 +431,7 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.P
return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after ACH verification")
}
if user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" {
- return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=active, got %s", stringOrEmpty(user.SubscriptionStatus))
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=active, got %s", stringOrEmpty(user.SubscriptionStatus))
}
return result, nil
@@ -462,7 +467,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
}
fmt.Printf(" customer_id=%s\n", customer.ID)
- fmt.Printf("[4/10] Creating subscription with tok_visa\n")
+ fmt.Printf("[4/10] Creating membership with tok_visa\n")
visaPM, err := createCardPaymentMethod(ctx, sc, "tok_visa")
if err != nil {
return subscriptionTestResultPass, err
@@ -492,7 +497,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, err
}
if !user.IsSubscribed || user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" {
- return subscriptionTestResultPass, fmt.Errorf("expected active subscription before renewal, got is_subscribed=%v status=%s", user.IsSubscribed, stringOrEmpty(user.SubscriptionStatus))
+ return subscriptionTestResultPass, fmt.Errorf("expected active membership before renewal, got is_subscribed=%v status=%s", user.IsSubscribed, stringOrEmpty(user.SubscriptionStatus))
}
subscriptionID := *user.StripeSubscriptionID
@@ -503,7 +508,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, err
}
if subscription.Items == nil || len(subscription.Items.Data) == 0 {
- return subscriptionTestResultPass, fmt.Errorf("subscription has no items")
+ return subscriptionTestResultPass, fmt.Errorf("membership subscription has no items")
}
periodEnd := subscription.Items.Data[0].CurrentPeriodEnd
@@ -538,7 +543,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
if err != nil {
return subscriptionTestResultPass, err
}
- fmt.Printf(" subscription status=%s\n", subscription.Status)
+ fmt.Printf(" membership subscription status=%s\n", subscription.Status)
invParams := &stripe.InvoiceRetrieveParams{}
invParams.AddExpand("payments.data.payment.payment_intent")
@@ -596,7 +601,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, err
}
if user.SubscriptionStatus == nil || *user.SubscriptionStatus != website.SubscriptionStatusGracePeriod {
- return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=%s after renewal failure, got %s", website.SubscriptionStatusGracePeriod, stringOrEmpty(user.SubscriptionStatus))
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s after renewal failure, got %s", website.SubscriptionStatusGracePeriod, stringOrEmpty(user.SubscriptionStatus))
}
if !user.IsSubscribed {
return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true during grace period")
@@ -635,7 +640,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
subscription, err = waitForSubscriptionStatus(ctx, sc, subscriptionID, "active", "trialing")
if err != nil {
// Retry may have paid the invoice without flipping status yet; process invoice.paid if present.
- fmt.Printf(" subscription not active yet (%v); checking for paid invoice\n", err)
+ fmt.Printf(" membership not active yet (%v); checking for paid invoice\n", err)
}
paidInvoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription)
@@ -662,7 +667,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after payment method update")
}
if user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" {
- return subscriptionTestResultPass, fmt.Errorf("expected subscription_status=active after recovery, got %s", stringOrEmpty(user.SubscriptionStatus))
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=active after recovery, got %s", stringOrEmpty(user.SubscriptionStatus))
}
if user.GracePeriodStartedAt != nil || user.GracePeriodEndsAt != nil {
return subscriptionTestResultPass, fmt.Errorf("expected grace period cleared after successful payment")
@@ -702,7 +707,7 @@ func setDefaultPaymentMethod(ctx context.Context, sc *stripe.Client, customerID,
DefaultPaymentMethod: stripe.String(paymentMethodID),
})
if err != nil {
- return fmt.Errorf("update subscription default payment method: %w", err)
+ return fmt.Errorf("update membership default payment method: %w", err)
}
return nil
}
@@ -742,12 +747,12 @@ func waitForSubscriptionStatus(ctx context.Context, sc *stripe.Client, subscript
if err != nil {
return nil, err
}
- return nil, fmt.Errorf("subscription %s did not reach status %v within timeout (last status=%s)", subscriptionID, statuses, sub.Status)
+ return nil, fmt.Errorf("membership subscription %s did not reach status %v within timeout (last status=%s)", subscriptionID, statuses, sub.Status)
}
func retrieveLatestSubscriptionInvoice(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) (*stripe.Invoice, error) {
if sub == nil {
- return nil, fmt.Errorf("subscription is nil")
+ return nil, fmt.Errorf("membership subscription is nil")
}
subParams := &stripe.SubscriptionRetrieveParams{}
subParams.AddExpand("latest_invoice")
@@ -806,7 +811,7 @@ func setupACHPendingOnClock(ctx context.Context, pool *pgxpool.Pool, sc *stripe.
if !isExpectedACHVerificationPending(err) {
return nil, fmt.Errorf("attach ACH payment method: %w", err)
}
- fmt.Printf(" ACH verification is pending; subscription will complete after verification.\n")
+ fmt.Printf(" ACH verification is pending; membership will complete after verification.\n")
if err := persistPendingVerificationState(ctx, pool, userID, customer.ID); err != nil {
return nil, err
}
@@ -896,9 +901,9 @@ func completeSubscription(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Cl
if err != nil {
return subscriptionTestResultPass, err
}
- fmt.Printf(" subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
+ fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
- fmt.Printf("[5/6] Writing subscription state to database\n")
+ fmt.Printf("[5/6] Writing membership state to database\n")
renewalDate := getSubscriptionPeriodEndFromStripe(subscription)
isSubscribed := subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing
_, err = pool.Exec(ctx, `
@@ -938,7 +943,7 @@ func completeSubscription(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Cl
}
}
- fmt.Printf("[6/6] Verifying and printing stored subscription data\n")
+ fmt.Printf("[6/6] Verifying and printing stored membership data\n")
if err := validateStoredSubscriptionData(ctx, pool, userID, customerID, subscription.ID); err != nil {
return subscriptionTestResultPass, err
}
@@ -1053,7 +1058,7 @@ func printSubscriptionData(ctx context.Context, pool *pgxpool.Pool, userID int)
panic(err)
}
- fmt.Printf("\nStored user subscription data:\n")
+ fmt.Printf("\nStored user membership data:\n")
fmt.Printf(" user_id: %d\n", user.ID)
fmt.Printf(" username: %s\n", user.Username)
fmt.Printf(" is_subscribed: %v\n", user.IsSubscribed)
diff --git a/src/admintools/adminsubscription_cmd.go b/src/admintools/adminsubscription_cmd.go
new file mode 100644
index 00000000..7c6009f8
--- /dev/null
+++ b/src/admintools/adminsubscription_cmd.go
@@ -0,0 +1,78 @@
+package admintools
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "git.handmade.network/hmn/hmn/src/config"
+ "git.handmade.network/hmn/hmn/src/db"
+ "github.com/spf13/cobra"
+ "github.com/stripe/stripe-go/v84"
+)
+
+func addSubscriptionCommands(adminCommand *cobra.Command) {
+ cmd := &cobra.Command{
+ Use: "membership",
+ Short: "Admin commands for membership testing",
+ }
+ adminCommand.AddCommand(cmd)
+
+ legacyCmd := &cobra.Command{
+ Use: "subscription",
+ Short: "Alias for membership commands",
+ Hidden: true,
+ }
+ adminCommand.AddCommand(legacyCmd)
+
+ addSubscriptionTestCommand(cmd)
+ addSubscriptionTestCommand(legacyCmd)
+}
+
+func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
+ cmd := &cobra.Command{
+ Use: "test",
+ Short: "Run membership test scenarios and print stored DB results",
+ Run: func(cmd *cobra.Command, _ []string) {
+ if config.Config.Stripe.SecretKey == "" || config.Config.Stripe.PriceID == "" {
+ fmt.Fprintf(os.Stderr, "Stripe.SecretKey and Stripe.PriceID must be set in config.\n")
+ os.Exit(1)
+ }
+
+ ctx := context.Background()
+ pool := db.NewConnPool()
+ defer pool.Close()
+
+ if override := config.Config.Stripe.SubscriptionNowOverride; override != "" {
+ fmt.Printf("Using membership time override: %s\n", override)
+ }
+ if testClockID := config.Config.Stripe.TestClockID; testClockID != "" {
+ fmt.Printf("Using Stripe test clock: %s\n", testClockID)
+ }
+
+ sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ scenarios := membershipScenarios()
+
+ failed := false
+ for i, scenario := range scenarios {
+ fmt.Printf("\n========== Scenario %d/%d: %s ==========\n", i+1, len(scenarios), scenario.Name)
+ result, err := runSubscriptionScenario(ctx, pool, sc, scenario)
+ if err != nil {
+ failed = true
+ fmt.Printf("RESULT: FAIL\n")
+ fmt.Printf("ERROR: %v\n", err)
+ } else if result == subscriptionTestResultPending {
+ fmt.Printf("RESULT: PENDING (expected for ACH verification)\n")
+ } else {
+ fmt.Printf("RESULT: PASS\n")
+ }
+ }
+
+ if failed {
+ os.Exit(1)
+ }
+ },
+ }
+
+ subscriptionCommand.AddCommand(cmd)
+}
diff --git a/src/admintools/adminsubscription_helpers.go b/src/admintools/adminsubscription_helpers.go
new file mode 100644
index 00000000..1a5c9d93
--- /dev/null
+++ b/src/admintools/adminsubscription_helpers.go
@@ -0,0 +1,28 @@
+package admintools
+
+import (
+ "fmt"
+)
+
+type scenarioCtx struct {
+ name string
+ steps int
+ index int
+}
+
+func newScenarioCtx(name string, steps int) *scenarioCtx {
+ return &scenarioCtx{
+ name: name,
+ steps: steps,
+ }
+}
+
+func (s *scenarioCtx) step(msg string, fn func() error) error {
+ s.index++
+ fmt.Printf("[%d/%d] %s\n", s.index, s.steps, msg)
+ return fn()
+}
+
+func (s *scenarioCtx) printf(format string, args ...any) {
+ fmt.Printf(" "+format, args...)
+}
diff --git a/src/admintools/adminsubscription_scenarios.go b/src/admintools/adminsubscription_scenarios.go
new file mode 100644
index 00000000..326403ee
--- /dev/null
+++ b/src/admintools/adminsubscription_scenarios.go
@@ -0,0 +1,68 @@
+package admintools
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stripe/stripe-go/v84"
+)
+
+type subscriptionTestScenario struct {
+ Name string
+ CreatePaymentMethod func(context.Context, *stripe.Client) (*stripe.PaymentMethod, error)
+ Run func(context.Context, *pgxpool.Pool, *stripe.Client) (subscriptionTestResult, error)
+}
+
+type subscriptionTestResult int
+
+const (
+ subscriptionTestResultPass subscriptionTestResult = iota
+ subscriptionTestResultPending
+)
+
+func membershipScenarios() []subscriptionTestScenario {
+ return []subscriptionTestScenario{
+ {
+ Name: "Credit card (tok_visa)",
+ CreatePaymentMethod: func(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) {
+ return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{
+ Type: stripe.String("card"),
+ Card: &stripe.PaymentMethodCreateCardParams{
+ Token: stripe.String("tok_visa"),
+ },
+ })
+ },
+ },
+ {
+ Name: "Credit card one-time charge (EUR)",
+ Run: runEuroCardChargeScenario,
+ },
+ {
+ Name: "Credit card declined (tok_chargeDeclined)",
+ Run: runDeclinedCardScenario,
+ },
+ {
+ Name: "ACH (US bank account)",
+ CreatePaymentMethod: createACHPaymentMethod,
+ },
+ {
+ Name: "ACH grace expires after 2 week clock advance",
+ Run: runACHGraceExpiryScenario,
+ },
+ {
+ Name: "ACH verification after 2 day clock advance",
+ Run: runACHVerificationAfterAdvanceScenario,
+ },
+ {
+ Name: "Card renewal failure → grace → payment method update",
+ Run: runCardRenewalFailureGraceRecoveryScenario,
+ },
+ }
+}
+
+func runSubscriptionScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
+ if scenario.Run != nil {
+ return scenario.Run(ctx, pool, sc)
+ }
+ return runCardOrACHScenario(ctx, pool, sc, scenario)
+}
diff --git a/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go b/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go
new file mode 100644
index 00000000..c090eaac
--- /dev/null
+++ b/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go
@@ -0,0 +1,45 @@
+package migrations
+
+import (
+ "context"
+ "time"
+
+ "git.handmade.network/hmn/hmn/src/migration/types"
+ "github.com/jackc/pgx/v5"
+)
+
+func init() {
+ registerMigration(AddStripeWebhookEventLedger{})
+}
+
+type AddStripeWebhookEventLedger struct{}
+
+func (m AddStripeWebhookEventLedger) Version() types.MigrationVersion {
+ return types.MigrationVersion(time.Date(2026, 6, 1, 5, 56, 0, 0, time.UTC))
+}
+
+func (m AddStripeWebhookEventLedger) Name() string {
+ return "AddStripeWebhookEventLedger"
+}
+
+func (m AddStripeWebhookEventLedger) Description() string {
+ return "Add Stripe webhook event idempotency ledger table"
+}
+
+func (m AddStripeWebhookEventLedger) Up(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ CREATE TABLE stripe_webhook_event (
+ event_id TEXT PRIMARY KEY,
+ event_type TEXT NOT NULL,
+ received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+ )
+ `)
+ return err
+}
+
+func (m AddStripeWebhookEventLedger) Down(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ DROP TABLE stripe_webhook_event
+ `)
+ return err
+}
diff --git a/src/website/stripe.go b/src/website/stripe.go
index 944492dc..e69d6170 100644
--- a/src/website/stripe.go
+++ b/src/website/stripe.go
@@ -36,6 +36,21 @@ func StripeWebhook(c *RequestContext) ResponseData {
return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "failed to verify Stripe webhook signature"))
}
+ if event.ID == "" {
+ c.Logger.Warn().Str("type", string(event.Type)).Msg("Stripe webhook missing event ID; ignoring")
+ return ResponseData{StatusCode: http.StatusOK}
+ }
+
+ wasNewEvent, err := recordStripeWebhookEvent(c, c.Conn, &event)
+ if err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to record Stripe webhook event")
+ return ResponseData{StatusCode: http.StatusOK}
+ }
+ if !wasNewEvent {
+ c.Logger.Info().Str("eventID", event.ID).Str("type", string(event.Type)).Msg("duplicate Stripe webhook event; ignoring")
+ return ResponseData{StatusCode: http.StatusOK}
+ }
+
c.Logger.Info().Str("type", string(event.Type)).Msg("received Stripe webhook")
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
@@ -75,6 +90,18 @@ func StripeWebhook(c *RequestContext) ResponseData {
}
}
+func recordStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
+ tag, err := conn.Exec(ctx, `
+ INSERT INTO stripe_webhook_event (event_id, event_type)
+ VALUES ($1, $2)
+ ON CONFLICT (event_id) DO NOTHING
+ `, event.ID, string(event.Type))
+ if err != nil {
+ return false, oops.New(err, "failed to insert stripe webhook event id")
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
type stripeWebhookKind int
const (
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index df7f5e34..c6a21206 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -73,9 +73,9 @@ func isFailedPaymentStripeStatus(status string) bool {
}
}
-func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now time.Time) error {
+func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now time.Time) (bool, error) {
endsAt := now.Add(subscriptionGracePeriodDuration)
- _, err := conn.Exec(ctx, `
+ tag, err := conn.Exec(ctx, `
UPDATE hmn_user
SET
is_subscribed = true,
@@ -84,13 +84,18 @@ func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now tim
grace_period_ends_at = $3,
grace_available = false
WHERE id = $4
+ AND grace_available = true
+ AND (grace_period_ends_at IS NULL OR grace_period_ends_at <= $2)
`, SubscriptionStatusGracePeriod, now, endsAt, userID)
if err != nil {
- return err
+ return false, err
+ }
+ if tag.RowsAffected() == 0 {
+ return false, nil
}
logging.Info().Int("userID", userID).Time("graceEndsAt", endsAt).Msg("started subscription grace period")
SyncSupporterDiscordRole(ctx, conn, userID)
- return nil
+ return true, nil
}
func clearGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error {
@@ -225,7 +230,8 @@ func gracePeriodDaysRemaining(user *models.User, now time.Time) int {
}
func StartSubscriptionGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error {
- return startGracePeriod(ctx, conn, userID, SubscriptionNow())
+ _, err := startGracePeriod(ctx, conn, userID, SubscriptionNow())
+ return err
}
func ExpireSubscriptionGracePeriods(ctx context.Context, conn db.ConnOrTx) (int64, error) {
diff --git a/src/website/subscription_payment_intent_webhook.go b/src/website/subscription_payment_intent_webhook.go
index a86071c6..f0bad8cf 100644
--- a/src/website/subscription_payment_intent_webhook.go
+++ b/src/website/subscription_payment_intent_webhook.go
@@ -67,16 +67,17 @@ func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client,
logging.Info().Int("userID", user.ID).Str("eventType", string(eventType)).Msg("skipping grace start; no open subscription invoice")
return true
}
- if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
+ startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now)
+ if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment intent webhook")
- } else {
+ } else if startedGrace {
sendACHVerificationGraceEmail(c, user.ID)
}
}
case stripe.EventTypePaymentIntentPaymentFailed:
if paymentIntentIsHardDecline(pi, pmType) {
if shouldStartGraceOnPaymentFailure(user, now, false) {
- if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
+ if _, err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment_intent.payment_failed")
}
} else if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, "incomplete"); err != nil {
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index f9358519..f91674b0 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -439,9 +439,10 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
logging.Error().Err(err).Int("userID", userID).Msg("failed to link pending checkout subscription")
return
}
- if err := startGracePeriod(c, c.Conn, userID, now); err != nil {
+ startedGrace, err := startGracePeriod(c, c.Conn, userID, now)
+ if err != nil {
logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for processing checkout payment")
- } else {
+ } else if startedGrace {
sendACHVerificationGraceEmail(c, userID)
}
} else if isGraceActive(user, now) {
@@ -568,10 +569,11 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
asyncGraceEligible := shouldGrantGraceForSubscription(c, sc, sub)
if isFailedPaymentStripeStatus(stripeStatus) && shouldStartGraceOnPaymentFailure(user, now, asyncGraceEligible) {
- if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
+ startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now)
+ if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period")
return
- } else if asyncGraceEligible {
+ } else if startedGrace && asyncGraceEligible {
sendACHVerificationGraceEmail(c, user.ID)
}
} else if isFailedPaymentStripeStatus(stripeStatus) && !isGraceActive(user, now) {
@@ -763,9 +765,10 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip
grantGrace := shouldGrantGraceForInvoice(c, sc, inv)
hardDecline := invoicePaymentIsHardDecline(c, sc, inv)
if shouldStartGraceOnPaymentFailure(user, now, grantGrace) {
- if err := startGracePeriod(c, c.Conn, user.ID, now); err != nil {
+ startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now)
+ if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period from invoice.payment_failed")
- } else if grantGrace {
+ } else if startedGrace && grantGrace {
sendACHVerificationGraceEmail(c, user.ID)
}
} else if hardDecline && !isGraceActive(user, now) {
From 6ebcfddaacaf8ba9fb11d3a259084ca1102e8d92 Mon Sep 17 00:00:00 2001
From: reece365
Date: Tue, 2 Jun 2026 00:06:04 -0500
Subject: [PATCH 10/15] Minor bug fixes in webhook handler; fixed `inspect`
tool
---
src/admintools/adminsubscription_cmd.go | 55 +++++++++
src/admintools/adminsubscription_scenarios.go | 4 -
...-01T055600Z_AddStripeWebhookEventLedger.go | 6 +-
src/website/base_data.go | 4 +-
src/website/stripe.go | 105 ++++++++++++++++--
5 files changed, 158 insertions(+), 16 deletions(-)
diff --git a/src/admintools/adminsubscription_cmd.go b/src/admintools/adminsubscription_cmd.go
index 7c6009f8..204a7217 100644
--- a/src/admintools/adminsubscription_cmd.go
+++ b/src/admintools/adminsubscription_cmd.go
@@ -2,6 +2,7 @@ package admintools
import (
"context"
+ "errors"
"fmt"
"os"
@@ -27,6 +28,8 @@ func addSubscriptionCommands(adminCommand *cobra.Command) {
addSubscriptionTestCommand(cmd)
addSubscriptionTestCommand(legacyCmd)
+ addSubscriptionInspectCommand(cmd)
+ addSubscriptionInspectCommand(legacyCmd)
}
func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
@@ -54,20 +57,40 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
scenarios := membershipScenarios()
failed := false
+ passCount := 0
+ pendingCount := 0
+ failCount := 0
+ var failedScenarioNames []string
for i, scenario := range scenarios {
fmt.Printf("\n========== Scenario %d/%d: %s ==========\n", i+1, len(scenarios), scenario.Name)
result, err := runSubscriptionScenario(ctx, pool, sc, scenario)
if err != nil {
failed = true
+ failCount++
+ failedScenarioNames = append(failedScenarioNames, scenario.Name)
fmt.Printf("RESULT: FAIL\n")
fmt.Printf("ERROR: %v\n", err)
} else if result == subscriptionTestResultPending {
+ pendingCount++
fmt.Printf("RESULT: PENDING (expected for ACH verification)\n")
} else {
+ passCount++
fmt.Printf("RESULT: PASS\n")
}
}
+ fmt.Printf("\n========== Membership Test Summary ==========\n")
+ fmt.Printf("Total scenarios: %d\n", len(scenarios))
+ fmt.Printf("PASS: %d\n", passCount)
+ fmt.Printf("PENDING: %d\n", pendingCount)
+ fmt.Printf("FAIL: %d\n", failCount)
+ if len(failedScenarioNames) > 0 {
+ fmt.Printf("Failed scenarios:\n")
+ for _, name := range failedScenarioNames {
+ fmt.Printf(" - %s\n", name)
+ }
+ }
+
if failed {
os.Exit(1)
}
@@ -76,3 +99,35 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
subscriptionCommand.AddCommand(cmd)
}
+
+func addSubscriptionInspectCommand(subscriptionCommand *cobra.Command) {
+ cmd := &cobra.Command{
+ Use: "inspect ",
+ Short: "Print membership/payment debug info for a user",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ username := args[0]
+
+ ctx := context.Background()
+ pool := db.NewConnPool()
+ defer pool.Close()
+
+ userID, err := db.QueryOneScalar[int](ctx, pool, `
+ SELECT id
+ FROM hmn_user
+ WHERE LOWER(username) = LOWER($1)
+ `, username)
+ if err != nil {
+ if errors.Is(err, db.NotFound) {
+ fmt.Printf("User not found: %s\n", username)
+ os.Exit(1)
+ }
+ panic(err)
+ }
+
+ printSubscriptionData(ctx, pool, userID)
+ },
+ }
+
+ subscriptionCommand.AddCommand(cmd)
+}
diff --git a/src/admintools/adminsubscription_scenarios.go b/src/admintools/adminsubscription_scenarios.go
index 326403ee..b9df2577 100644
--- a/src/admintools/adminsubscription_scenarios.go
+++ b/src/admintools/adminsubscription_scenarios.go
@@ -41,10 +41,6 @@ func membershipScenarios() []subscriptionTestScenario {
Name: "Credit card declined (tok_chargeDeclined)",
Run: runDeclinedCardScenario,
},
- {
- Name: "ACH (US bank account)",
- CreatePaymentMethod: createACHPaymentMethod,
- },
{
Name: "ACH grace expires after 2 week clock advance",
Run: runACHGraceExpiryScenario,
diff --git a/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go b/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go
index c090eaac..d497683d 100644
--- a/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go
+++ b/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go
@@ -31,7 +31,11 @@ func (m AddStripeWebhookEventLedger) Up(ctx context.Context, tx pgx.Tx) error {
CREATE TABLE stripe_webhook_event (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
- received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+ status TEXT NOT NULL DEFAULT 'processing',
+ last_error TEXT,
+ received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ processed_at TIMESTAMP WITH TIME ZONE
)
`)
return err
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 44f4f173..fe9d831e 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -134,8 +134,10 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
if c.CurrentUser != nil {
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
+ showMembershipVerificationBanner := false
bankVerificationJustCompleted := c.Req != nil && c.Req.URL != nil && c.Req.URL.Query().Get("bank_verified") == "1"
if userNeedsBankVerificationReminder(c.CurrentUser) && !bankVerificationJustCompleted {
+ showMembershipVerificationBanner = true
baseData.Header.ShowMembershipVerificationBanner = true
bannerURL := hmnurl.BuildHSFMembership()
if c.CurrentUser.StripeSubscriptionID != nil && config.Config.Stripe.SecretKey != "" {
@@ -154,7 +156,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
baseData.Header.MembershipGraceDaysRemaining,
)
}
- if userNeedsDiscordLinkReminder(c.CurrentUser) {
+ if !showMembershipVerificationBanner && userNeedsDiscordLinkReminder(c.CurrentUser) {
baseData.Header.ShowMembershipDiscordLinkBanner = true
baseData.Header.MembershipDiscordLinkDismissUrl = hmnurl.BuildDismissMembershipDiscordLinkBanner()
if c.CurrentSession != nil {
diff --git a/src/website/stripe.go b/src/website/stripe.go
index e69d6170..34ce1853 100644
--- a/src/website/stripe.go
+++ b/src/website/stripe.go
@@ -41,15 +41,23 @@ func StripeWebhook(c *RequestContext) ResponseData {
return ResponseData{StatusCode: http.StatusOK}
}
- wasNewEvent, err := recordStripeWebhookEvent(c, c.Conn, &event)
+ shouldProcess, err := beginStripeWebhookEvent(c, c.Conn, &event)
if err != nil {
- c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to record Stripe webhook event")
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to initialize Stripe webhook event state")
return ResponseData{StatusCode: http.StatusOK}
}
- if !wasNewEvent {
- c.Logger.Info().Str("eventID", event.ID).Str("type", string(event.Type)).Msg("duplicate Stripe webhook event; ignoring")
+ if !shouldProcess {
+ c.Logger.Info().Str("eventID", event.ID).Str("type", string(event.Type)).Msg("already processed Stripe webhook event; ignoring")
return ResponseData{StatusCode: http.StatusOK}
}
+ markFailed := func(processErr error) {
+ if processErr == nil {
+ return
+ }
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, processErr); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event failure state")
+ }
+ }
c.Logger.Info().Str("type", string(event.Type)).Msg("received Stripe webhook")
@@ -60,46 +68,123 @@ func StripeWebhook(c *RequestContext) ResponseData {
}
if tryHandleMembershipPaymentIntentWebhook(c, sc, &event) {
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
+ }
return ResponseData{StatusCode: http.StatusOK}
}
priceIDs, err := stripePriceIDsForEvent(c, sc, &event)
if err != nil {
+ markFailed(err)
c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to resolve price IDs for stripe event")
return ResponseData{StatusCode: http.StatusOK}
}
kind, err := classifyStripePriceIDs(c, c.Conn, priceIDs)
if err != nil {
+ markFailed(err)
c.Logger.Error().Err(err).Msg("failed to classify stripe webhook by price")
return ResponseData{StatusCode: http.StatusOK}
}
switch kind {
case stripeWebhookKindTicket:
- return handleTicketStripeEvent(c, sc, &event)
+ res := handleTicketStripeEvent(c, sc, &event)
+ if res.StatusCode >= http.StatusBadRequest {
+ markFailed(oops.New(nil, "ticket Stripe webhook handler returned status %d", res.StatusCode))
+ return res
+ }
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
+ }
+ return res
case stripeWebhookKindMembership:
handleMembershipStripeEvent(c, sc, &event)
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
+ }
return ResponseData{StatusCode: http.StatusOK}
default:
c.Logger.Warn().
Str("type", string(event.Type)).
Strs("prices", priceIDs).
Msg("Stripe webhook did not match any known ticket or membership price; ignoring")
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
+ }
return ResponseData{StatusCode: http.StatusOK}
}
}
-func recordStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
- tag, err := conn.Exec(ctx, `
- INSERT INTO stripe_webhook_event (event_id, event_type)
- VALUES ($1, $2)
+func beginStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
+ status, err := db.QueryOneScalar[string](ctx, conn, `
+ SELECT status
+ FROM stripe_webhook_event
+ WHERE event_id = $1
+ `, event.ID)
+ if err != nil && err != db.NotFound {
+ return false, oops.New(err, "failed to read Stripe webhook event state")
+ }
+ if err == nil && status == "processed" {
+ return false, nil
+ }
+
+ _, err = conn.Exec(ctx, `
+ INSERT INTO stripe_webhook_event (event_id, event_type, status, last_error, updated_at, processed_at)
+ VALUES ($1, $2, 'processing', NULL, NOW(), NULL)
ON CONFLICT (event_id) DO NOTHING
`, event.ID, string(event.Type))
if err != nil {
return false, oops.New(err, "failed to insert stripe webhook event id")
}
- return tag.RowsAffected() == 1, nil
+
+ _, err = conn.Exec(ctx, `
+ UPDATE stripe_webhook_event
+ SET
+ event_type = $2,
+ status = 'processing',
+ last_error = NULL,
+ updated_at = NOW(),
+ processed_at = NULL
+ WHERE event_id = $1
+ AND status <> 'processed'
+ `, event.ID, string(event.Type))
+ if err != nil {
+ return false, oops.New(err, "failed to mark stripe webhook event as processing")
+ }
+ return true, nil
+}
+
+func finishStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event, processErr error) error {
+ if processErr == nil {
+ _, err := conn.Exec(ctx, `
+ UPDATE stripe_webhook_event
+ SET
+ status = 'processed',
+ last_error = NULL,
+ updated_at = NOW(),
+ processed_at = NOW()
+ WHERE event_id = $1
+ `, event.ID)
+ if err != nil {
+ return oops.New(err, "failed to mark stripe webhook event as processed")
+ }
+ return nil
+ }
+
+ _, err := conn.Exec(ctx, `
+ UPDATE stripe_webhook_event
+ SET
+ status = 'failed',
+ last_error = $2,
+ updated_at = NOW()
+ WHERE event_id = $1
+ `, event.ID, processErr.Error())
+ if err != nil {
+ return oops.New(err, "failed to mark stripe webhook event as failed")
+ }
+ return nil
}
type stripeWebhookKind int
From 6d864e32c84356619fd8a21be597cbe0786c1e9a Mon Sep 17 00:00:00 2001
From: reece365
Date: Tue, 2 Jun 2026 03:59:26 -0500
Subject: [PATCH 11/15] Added mailpit support to admin membership tests
---
src/admintools/adminsubscription.go | 243 ++++++++++++++----
src/admintools/adminsubscription_cmd.go | 99 +++++++
src/admintools/adminsubscription_mailpit.go | 204 +++++++++++++++
src/email/email.go | 36 +++
.../src/email_grace_period_ended.html | 14 +
src/templates/src/hsf_membership.html | 4 +
src/website/base_data.go | 29 ++-
src/website/hsf.go | 17 ++
src/website/subscription_grace.go | 19 ++
src/website/subscription_grace_eligibility.go | 37 +++
.../subscription_payment_intent_webhook.go | 3 +-
src/website/subscriptions.go | 34 +--
12 files changed, 666 insertions(+), 73 deletions(-)
create mode 100644 src/admintools/adminsubscription_mailpit.go
create mode 100644 src/templates/src/email_grace_period_ended.html
diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go
index 0922289e..d53e5712 100644
--- a/src/admintools/adminsubscription.go
+++ b/src/admintools/adminsubscription.go
@@ -26,6 +26,21 @@ type achTestSetup struct {
testClockID string
}
+const (
+ subjectThankYou = "[Handmade Software Foundation] Thank you!"
+ subjectPaymentFailed = "[Handmade Software Foundation] Payment failed"
+ subjectACHVerificationGrace = "[Handmade Software Foundation] Verify your bank account"
+ subjectGracePeriodEnded = "[Handmade Software Foundation] Grace period ended"
+)
+
+func expectScenarioEmailSubjects(ctx context.Context, expected []string) error {
+ mailpit := membershipMailpitFromContext(ctx)
+ if mailpit == nil {
+ return nil
+ }
+ return mailpit.WaitForSubjects(expected, 10*time.Second)
+}
+
func runCardOrACHScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) {
sctx := newScenarioCtx(scenario.Name, 6)
@@ -86,7 +101,7 @@ func runCardOrACHScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Cl
if updateErr := persistPendingVerificationState(ctx, pool, userID, customer.ID); updateErr != nil {
return updateErr
}
- printSubscriptionData(ctx, pool, userID)
+ printSubscriptionDataSummary(ctx, pool, userID)
return nil
}
return attachErr
@@ -99,7 +114,64 @@ func runCardOrACHScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Cl
}
}
- return completeSubscription(ctx, pool, sc, userID, customer.ID, paymentMethod.ID)
+ result, err := completeSubscriptionE2E(ctx, pool, sc, userID, customer.ID, paymentMethod.ID)
+ if err != nil {
+ return result, err
+ }
+ if err := expectScenarioEmailSubjects(ctx, []string{subjectThankYou}); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("verify thank-you email: %w", err)
+ }
+ return result, nil
+}
+
+func completeSubscriptionE2E(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, userID int, customerID, paymentMethodID string) (subscriptionTestResult, error) {
+ subscriptionParams := &stripe.SubscriptionCreateParams{
+ Customer: stripe.String(customerID),
+ DefaultPaymentMethod: stripe.String(paymentMethodID),
+ CollectionMethod: stripe.String("charge_automatically"),
+ PaymentBehavior: stripe.String("allow_incomplete"),
+ Items: []*stripe.SubscriptionCreateItemParams{
+ {Price: stripe.String(config.Config.Stripe.PriceID)},
+ },
+ Metadata: map[string]string{
+ "user_id": strconv.Itoa(userID),
+ },
+ }
+ subscriptionParams.AddExpand("latest_invoice")
+
+ subscription, err := sc.V1Subscriptions.Create(ctx, subscriptionParams)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
+
+ fmt.Printf("[5/6] Processing membership webhooks\n")
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.created", subscription); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ var invoice *stripe.Invoice
+ if subscription.LatestInvoice != nil && subscription.LatestInvoice.ID != "" {
+ invoice, err = sc.V1Invoices.Retrieve(ctx, subscription.LatestInvoice.ID, nil)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ if invoice != nil && invoice.Status == stripe.InvoiceStatusPaid {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", invoice); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil {
+ return subscriptionTestResultPass, err
+ }
+
+ fmt.Printf("[6/6] Verifying and printing stored membership data\n")
+ if err := validateStoredSubscriptionData(ctx, pool, userID, customerID, subscription.ID); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ printSubscriptionDataSummary(ctx, pool, userID)
+ return subscriptionTestResultPass, nil
}
func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) {
@@ -125,11 +197,11 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
}
fmt.Printf(" customer_id=%s\n", customer.ID)
- fmt.Printf("[3/6] Creating declined card payment method (tok_chargeDeclined)\n")
+ fmt.Printf("[3/6] Creating failing card payment method (tok_chargeCustomerFail)\n")
paymentMethod, err := sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCreateCardParams{
- Token: stripe.String("tok_chargeDeclined"),
+ Token: stripe.String("tok_chargeCustomerFail"),
},
})
if err != nil {
@@ -145,10 +217,9 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
return subscriptionTestResultPass, attachErr
}
if attachErr != nil {
- fmt.Printf(" card declined during payment method attach (expected)\n")
+ return subscriptionTestResultPass, fmt.Errorf("unexpected decline during payment method attach: %w", attachErr)
}
- var subscriptionID string
stripeStatus := "incomplete"
if attachErr == nil {
@@ -164,43 +235,34 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
"user_id": strconv.Itoa(userID),
},
}
- subscriptionParams.AddExpand("latest_invoice.payments.data.payment.payment_intent")
+ subscriptionParams.AddExpand("latest_invoice")
subscription, createErr := sc.V1Subscriptions.Create(ctx, subscriptionParams)
- if createErr != nil && !isStripeCardDeclined(createErr) {
+ if createErr != nil {
return subscriptionTestResultPass, createErr
}
- if createErr != nil {
- fmt.Printf(" card declined during membership create (expected)\n")
- } else {
- fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
- if subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing {
- return subscriptionTestResultPass, fmt.Errorf("expected membership subscription to fail payment, got status=%s", subscription.Status)
- }
- subscriptionID = subscription.ID
- stripeStatus = string(subscription.Status)
+ fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status)
+ if subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing {
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription to fail payment, got status=%s", subscription.Status)
}
- }
+ stripeStatus = string(subscription.Status)
- fmt.Printf("[5/6] Simulating declined payment access revoke\n")
- if err := website.RevokeSubscriptionAccessAfterDeclinedPayment(ctx, pool, userID, stripeStatus); err != nil {
- return subscriptionTestResultPass, err
- }
- if subscriptionID != "" {
- _, err = pool.Exec(ctx, `
- UPDATE hmn_user
- SET stripe_customer_id = $1, stripe_subscription_id = $2, cancel_at_period_end = false
- WHERE id = $3
- `, customer.ID, subscriptionID, userID)
- } else {
- _, err = pool.Exec(ctx, `
- UPDATE hmn_user
- SET stripe_customer_id = $1, cancel_at_period_end = false
- WHERE id = $2
- `, customer.ID, userID)
- }
- if err != nil {
- return subscriptionTestResultPass, err
+ fmt.Printf("[5/6] Dispatching decline webhooks end-to-end\n")
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.created", subscription); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if invoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription); err != nil {
+ return subscriptionTestResultPass, err
+ } else if invoice != nil {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.payment_failed", invoice); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ if refreshedSub, err := sc.V1Subscriptions.Retrieve(ctx, subscription.ID, nil); err != nil {
+ return subscriptionTestResultPass, err
+ } else if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", refreshedSub); err != nil {
+ return subscriptionTestResultPass, err
+ }
}
fmt.Printf("[6/6] Verifying stored membership data after decline\n")
@@ -231,7 +293,7 @@ func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe
return subscriptionTestResultPass, fmt.Errorf("expected no paid invoices after card decline, got %d payment rows", len(payments))
}
- printSubscriptionData(ctx, pool, userID)
+ printSubscriptionDataSummary(ctx, pool, userID)
return subscriptionTestResultPass, nil
}
@@ -341,11 +403,67 @@ func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stri
return subscriptionTestResultPass, err
}
- fmt.Printf("[5/7] Starting grace period (simulating payment failure while ACH verification is pending)\n")
+ fmt.Printf("[5/7] Processing pending ACH checkout webhook (starts grace period)\n")
syncSubscriptionNowToTestClock(ctx, sc, testClock.ID)
- if err := website.StartSubscriptionGracePeriod(ctx, pool, setup.userID); err != nil {
+
+ pi, err := sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{
+ Amount: stripe.Int64(500),
+ Currency: stripe.String("usd"),
+ Customer: stripe.String(setup.customerID),
+ PaymentMethod: stripe.String(setup.paymentMethodID),
+ PaymentMethodTypes: []*string{
+ stripe.String("us_bank_account"),
+ },
+ Confirm: stripe.Bool(true),
+ MandateData: &stripe.PaymentIntentCreateMandateDataParams{
+ CustomerAcceptance: &stripe.PaymentIntentCreateMandateDataCustomerAcceptanceParams{
+ Type: stripe.String("online"),
+ Online: &stripe.PaymentIntentCreateMandateDataCustomerAcceptanceOnlineParams{
+ IPAddress: stripe.String("127.0.0.1"),
+ UserAgent: stripe.String("HMN Admin Subscription Test"),
+ },
+ },
+ },
+ })
+ if err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("create pending ACH payment intent for grace scenario: %w", err)
+ }
+
+ session := &stripe.CheckoutSession{
+ ClientReferenceID: strconv.Itoa(setup.userID),
+ PaymentStatus: stripe.CheckoutSessionPaymentStatusUnpaid,
+ Customer: &stripe.Customer{ID: setup.customerID},
+ Subscription: &stripe.Subscription{ID: fmt.Sprintf("sub_pending_grace_%d", setup.userID)},
+ PaymentIntent: &stripe.PaymentIntent{ID: pi.ID},
+ }
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "checkout.session.completed", session); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("dispatch checkout.session.completed for grace scenario: %w", err)
+ }
+
+ userAfterGraceStart, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
+ if err != nil {
return subscriptionTestResultPass, err
}
+ if userAfterGraceStart.SubscriptionStatus == nil || *userAfterGraceStart.SubscriptionStatus != website.SubscriptionStatusGracePeriod {
+ return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s after pending ACH checkout, got %s", website.SubscriptionStatusGracePeriod, stringOrEmpty(userAfterGraceStart.SubscriptionStatus))
+ }
+ if !userAfterGraceStart.IsSubscribed {
+ return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after pending ACH checkout grace start")
+ }
+ if userAfterGraceStart.GracePeriodStartedAt == nil || userAfterGraceStart.GracePeriodEndsAt == nil {
+ return subscriptionTestResultPass, fmt.Errorf("expected grace period dates after pending ACH checkout")
+ }
+
+ if userAfterGraceStart.StripeCustomerID == nil || *userAfterGraceStart.StripeCustomerID != setup.customerID {
+ return subscriptionTestResultPass, fmt.Errorf("expected stripe_customer_id to remain linked after pending ACH checkout")
+ }
+ if userAfterGraceStart.StripeSubscriptionID == nil || *userAfterGraceStart.StripeSubscriptionID == "" {
+ return subscriptionTestResultPass, fmt.Errorf("expected stripe_subscription_id to be linked after pending ACH checkout")
+ }
+
+ if err := expectScenarioEmailSubjects(ctx, []string{subjectACHVerificationGrace}); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("verify ACH verification grace email: %w", err)
+ }
fmt.Printf("[6/7] Advancing test clock by 14 days (past 7-day grace period)\n")
clockTime, err := advanceTestClockBy(ctx, sc, testClock.ID, 14*24*time.Hour)
@@ -361,6 +479,9 @@ func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stri
return subscriptionTestResultPass, err
}
fmt.Printf(" expired grace periods: %d\n", expiredCount)
+ if err := expectScenarioEmailSubjects(ctx, []string{subjectACHVerificationGrace, subjectGracePeriodEnded}); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("verify grace period ended email: %w", err)
+ }
user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
if err != nil {
@@ -376,7 +497,7 @@ func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stri
return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace expiry")
}
- printSubscriptionData(ctx, pool, setup.userID)
+ printSubscriptionDataSummary(ctx, pool, setup.userID)
return subscriptionTestResultPass, nil
}
@@ -417,10 +538,13 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.P
return subscriptionTestResultPass, fmt.Errorf("attach verified ACH payment method: %w", err)
}
- result, err := completeSubscription(ctx, pool, sc, setup.userID, setup.customerID, setup.paymentMethodID)
+ result, err := completeSubscriptionE2E(ctx, pool, sc, setup.userID, setup.customerID, setup.paymentMethodID)
if err != nil {
return result, err
}
+ if err := expectScenarioEmailSubjects(ctx, []string{subjectThankYou}); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("verify thank-you email after ACH verification: %w", err)
+ }
fmt.Printf("[8/8] Verifying membership is active after ACH verification\n")
user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
@@ -595,6 +719,9 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil {
return subscriptionTestResultPass, err
}
+ if err := expectScenarioEmailSubjects(ctx, []string{subjectPaymentFailed}); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("verify payment failed email: %w", err)
+ }
user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
if err != nil {
@@ -657,6 +784,9 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, err
}
}
+ if err := expectScenarioEmailSubjects(ctx, []string{subjectPaymentFailed, subjectThankYou}); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("verify payment-recovery emails: %w", err)
+ }
fmt.Printf("[10/10] Verifying membership reinstated\n")
user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
@@ -676,7 +806,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, fmt.Errorf("expected grace_available=true after grace consumed and cleared")
}
- printSubscriptionData(ctx, pool, userID)
+ printSubscriptionDataSummary(ctx, pool, userID)
return subscriptionTestResultPass, nil
}
@@ -815,7 +945,7 @@ func setupACHPendingOnClock(ctx context.Context, pool *pgxpool.Pool, sc *stripe.
if err := persistPendingVerificationState(ctx, pool, userID, customer.ID); err != nil {
return nil, err
}
- printSubscriptionData(ctx, pool, userID)
+ printSubscriptionDataSummary(ctx, pool, userID)
return &achTestSetup{
userID: userID,
@@ -947,7 +1077,7 @@ func completeSubscription(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Cl
if err := validateStoredSubscriptionData(ctx, pool, userID, customerID, subscription.ID); err != nil {
return subscriptionTestResultPass, err
}
- printSubscriptionData(ctx, pool, userID)
+ printSubscriptionDataSummary(ctx, pool, userID)
return subscriptionTestResultPass, nil
}
@@ -1101,6 +1231,29 @@ func printSubscriptionData(ctx context.Context, pool *pgxpool.Pool, userID int)
}
}
+func printSubscriptionDataSummary(ctx context.Context, pool *pgxpool.Pool, userID int) {
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ panic(err)
+ }
+
+ paymentCount, err := db.QueryOneScalar[int](ctx, pool, `
+ SELECT COUNT(*)
+ FROM user_payment
+ WHERE user_id = $1
+ `, userID)
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Printf(" DB: status=%s subscribed=%v grace_available=%v payments=%d\n",
+ stringOrEmpty(user.SubscriptionStatus),
+ user.IsSubscribed,
+ user.GraceAvailable,
+ paymentCount,
+ )
+}
+
func stringOrEmpty(s *string) string {
if s == nil {
return ""
diff --git a/src/admintools/adminsubscription_cmd.go b/src/admintools/adminsubscription_cmd.go
index 204a7217..05297826 100644
--- a/src/admintools/adminsubscription_cmd.go
+++ b/src/admintools/adminsubscription_cmd.go
@@ -5,6 +5,10 @@ import (
"errors"
"fmt"
"os"
+ "os/exec"
+ "runtime"
+ "strconv"
+ "strings"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
@@ -33,6 +37,9 @@ func addSubscriptionCommands(adminCommand *cobra.Command) {
}
func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
+ var scenarioFilter string
+ var openMailpit bool
+
cmd := &cobra.Command{
Use: "test",
Short: "Run membership test scenarios and print stored DB results",
@@ -46,6 +53,33 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
pool := db.NewConnPool()
defer pool.Close()
+ originalEmailConfig := config.Config.Email
+ defer func() {
+ config.Config.Email = originalEmailConfig
+ }()
+
+ mailpit, mailpitInstalled, err := startMembershipMailpit()
+ if err != nil {
+ fmt.Printf("WARNING: failed to start Mailpit, email checks disabled: %v\n", err)
+ }
+ if !mailpitInstalled {
+ fmt.Printf("Mailpit binary not found; skipping email checks.\n")
+ }
+ if mailpit != nil {
+ fmt.Printf("Mailpit started: HTTP=%s SMTP=%s\n", mailpit.httpBaseURL, mailpit.smtpAddr)
+ if openMailpit {
+ if err := openURLInBrowser(mailpit.httpBaseURL); err != nil {
+ fmt.Printf("WARNING: failed to open Mailpit UI: %v\n", err)
+ }
+ }
+ defer func() {
+ if stopErr := mailpit.Stop(); stopErr != nil {
+ fmt.Printf("WARNING: failed to stop Mailpit: %v\n", stopErr)
+ }
+ }()
+ ctx = withMembershipMailpit(ctx, mailpit)
+ }
+
if override := config.Config.Stripe.SubscriptionNowOverride; override != "" {
fmt.Printf("Using membership time override: %s\n", override)
}
@@ -55,6 +89,14 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
scenarios := membershipScenarios()
+ if scenarioFilter != "" {
+ selected, err := selectMembershipScenarios(scenarios, scenarioFilter)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Invalid --scenario value %q: %v\n", scenarioFilter, err)
+ os.Exit(1)
+ }
+ scenarios = selected
+ }
failed := false
passCount := 0
@@ -62,8 +104,29 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
failCount := 0
var failedScenarioNames []string
for i, scenario := range scenarios {
+ if mailpit != nil {
+ if err := mailpit.ClearMessages(); err != nil {
+ fmt.Printf("WARNING: failed to clear Mailpit mailbox before scenario: %v\n", err)
+ }
+ }
+
fmt.Printf("\n========== Scenario %d/%d: %s ==========\n", i+1, len(scenarios), scenario.Name)
result, err := runSubscriptionScenario(ctx, pool, sc, scenario)
+
+ if mailpit != nil {
+ subjects, subjErr := mailpit.messageSubjects()
+ if subjErr != nil {
+ fmt.Printf("EMAILS: unable to list received messages (%v)\n", subjErr)
+ } else if len(subjects) == 0 {
+ fmt.Printf("EMAILS: none received\n")
+ } else {
+ fmt.Printf("EMAILS: received %d\n", len(subjects))
+ for _, subject := range subjects {
+ fmt.Printf(" - %s\n", subject)
+ }
+ }
+ }
+
if err != nil {
failed = true
failCount++
@@ -96,10 +159,46 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) {
}
},
}
+ cmd.Flags().StringVar(&scenarioFilter, "scenario", "", "Run a single scenario by 1-based index or exact name")
+ cmd.Flags().BoolVar(&openMailpit, "open-mailpit", false, "Open Mailpit web UI in the default browser when available")
subscriptionCommand.AddCommand(cmd)
}
+func selectMembershipScenarios(scenarios []subscriptionTestScenario, filter string) ([]subscriptionTestScenario, error) {
+ if idx, err := strconv.Atoi(filter); err == nil {
+ if idx < 1 || idx > len(scenarios) {
+ return nil, fmt.Errorf("index out of range (1-%d)", len(scenarios))
+ }
+ return []subscriptionTestScenario{scenarios[idx-1]}, nil
+ }
+
+ needle := strings.TrimSpace(filter)
+ if needle == "" {
+ return nil, errors.New("scenario name is blank")
+ }
+ for _, scenario := range scenarios {
+ if strings.EqualFold(scenario.Name, needle) {
+ return []subscriptionTestScenario{scenario}, nil
+ }
+ }
+
+ return nil, fmt.Errorf("not found; use 1-%d or one of the scenario names", len(scenarios))
+}
+
+func openURLInBrowser(url string) error {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ default:
+ cmd = exec.Command("xdg-open", url)
+ }
+ return cmd.Start()
+}
+
func addSubscriptionInspectCommand(subscriptionCommand *cobra.Command) {
cmd := &cobra.Command{
Use: "inspect ",
diff --git a/src/admintools/adminsubscription_mailpit.go b/src/admintools/adminsubscription_mailpit.go
new file mode 100644
index 00000000..1c67c982
--- /dev/null
+++ b/src/admintools/adminsubscription_mailpit.go
@@ -0,0 +1,204 @@
+package admintools
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "os/exec"
+ "time"
+
+ "git.handmade.network/hmn/hmn/src/config"
+)
+
+type membershipMailpitContextKey struct{}
+
+func withMembershipMailpit(ctx context.Context, m *membershipMailpit) context.Context {
+ return context.WithValue(ctx, membershipMailpitContextKey{}, m)
+}
+
+func membershipMailpitFromContext(ctx context.Context) *membershipMailpit {
+ if ctx == nil {
+ return nil
+ }
+ if m, ok := ctx.Value(membershipMailpitContextKey{}).(*membershipMailpit); ok {
+ return m
+ }
+ return nil
+}
+
+type membershipMailpit struct {
+ httpBaseURL string
+ smtpAddr string
+ cmd *exec.Cmd
+ client *http.Client
+}
+
+func startMembershipMailpit() (*membershipMailpit, bool, error) {
+ _, err := exec.LookPath("mailpit")
+ if err != nil {
+ return nil, false, nil
+ }
+
+ smtpPort, err := reserveTCPPort()
+ if err != nil {
+ return nil, true, fmt.Errorf("reserve smtp port: %w", err)
+ }
+ httpPort, err := reserveTCPPort()
+ if err != nil {
+ return nil, true, fmt.Errorf("reserve http port: %w", err)
+ }
+
+ m := &membershipMailpit{
+ httpBaseURL: fmt.Sprintf("http://127.0.0.1:%d", httpPort),
+ smtpAddr: fmt.Sprintf("127.0.0.1:%d", smtpPort),
+ client: &http.Client{
+ Timeout: 2 * time.Second,
+ },
+ }
+ m.cmd = exec.Command("mailpit",
+ "--smtp", fmt.Sprintf("127.0.0.1:%d", smtpPort),
+ "--listen", fmt.Sprintf("127.0.0.1:%d", httpPort),
+ )
+ if err := m.cmd.Start(); err != nil {
+ return nil, true, fmt.Errorf("start mailpit: %w", err)
+ }
+
+ if err := m.waitReady(10 * time.Second); err != nil {
+ _ = m.Stop()
+ return nil, true, err
+ }
+
+ config.Config.Email.ServerAddress = "127.0.0.1"
+ config.Config.Email.ServerPort = smtpPort
+ config.Config.Email.MailerUsername = ""
+ config.Config.Email.MailerPassword = ""
+ config.Config.Email.ForceToAddress = ""
+
+ if err := m.ClearMessages(); err != nil {
+ _ = m.Stop()
+ return nil, true, err
+ }
+
+ return m, true, nil
+}
+
+func (m *membershipMailpit) Stop() error {
+ if m == nil || m.cmd == nil || m.cmd.Process == nil {
+ return nil
+ }
+ _ = m.cmd.Process.Kill()
+ _, _ = m.cmd.Process.Wait()
+ return nil
+}
+
+func (m *membershipMailpit) waitReady(timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ req, err := http.NewRequest(http.MethodGet, m.httpBaseURL+"/api/v1/info", nil)
+ if err == nil {
+ resp, err := m.client.Do(req)
+ if err == nil {
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ return nil
+ }
+ }
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ return fmt.Errorf("mailpit did not become ready at %s", m.httpBaseURL)
+}
+
+func (m *membershipMailpit) ClearMessages() error {
+ req, err := http.NewRequest(http.MethodDelete, m.httpBaseURL+"/api/v1/messages", nil)
+ if err != nil {
+ return err
+ }
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("mailpit clear messages returned %d", resp.StatusCode)
+ }
+ return nil
+}
+
+func (m *membershipMailpit) messageSubjects() ([]string, error) {
+ req, err := http.NewRequest(http.MethodGet, m.httpBaseURL+"/api/v1/messages?start=0&limit=200", nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := m.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("mailpit list messages returned %d", resp.StatusCode)
+ }
+
+ var payload struct {
+ Messages []struct {
+ Subject string `json:"Subject"`
+ } `json:"messages"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, err
+ }
+
+ // Mailpit returns newest-first; reverse so assertions read chronologically.
+ subjects := make([]string, 0, len(payload.Messages))
+ for i := len(payload.Messages) - 1; i >= 0; i-- {
+ subjects = append(subjects, payload.Messages[i].Subject)
+ }
+ return subjects, nil
+}
+
+func (m *membershipMailpit) WaitForSubjects(expected []string, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ subjects, err := m.messageSubjects()
+ if err == nil {
+ if err := assertSubjectsEqual(subjects, expected); err == nil {
+ return nil
+ }
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ subjects, err := m.messageSubjects()
+ if err != nil {
+ return err
+ }
+ return assertSubjectsEqual(subjects, expected)
+}
+
+func assertSubjectsEqual(actual, expected []string) error {
+ if len(actual) != len(expected) {
+ return fmt.Errorf("email subject count mismatch: got %d expected %d (actual: %v)", len(actual), len(expected), actual)
+ }
+ for i := range expected {
+ if actual[i] != expected[i] {
+ return fmt.Errorf("email subject mismatch at index %d: got %q expected %q (actual: %v)", i, actual[i], expected[i], actual)
+ }
+ }
+ return nil
+}
+
+func reserveTCPPort() (int, error) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return 0, err
+ }
+ defer ln.Close()
+
+ addr, ok := ln.Addr().(*net.TCPAddr)
+ if !ok {
+ return 0, errors.New("listener is not TCP")
+ }
+ return addr.Port, nil
+}
diff --git a/src/email/email.go b/src/email/email.go
index ce5a417f..3f50b028 100644
--- a/src/email/email.go
+++ b/src/email/email.go
@@ -272,6 +272,12 @@ type ACHVerificationGraceEmailData struct {
GracePeriodEnd string
}
+type GracePeriodEndedEmailData struct {
+ Name string
+ HomepageUrl string
+ ManageSubscriptionUrl string
+}
+
func SendPaymentFailedEmail(
toAddress string,
toName string,
@@ -355,6 +361,36 @@ func SendACHVerificationGraceEmail(
return nil
}
+func SendGracePeriodEndedEmail(
+ toAddress string,
+ toName string,
+ perf *perf.RequestPerf,
+) error {
+ defer perf.StartBlock("EMAIL", "Grace period ended email").End()
+
+ b1 := perf.StartBlock("EMAIL", "Rendering template")
+ defer b1.End()
+ contents, err := renderTemplate("email_grace_period_ended.html", GracePeriodEndedEmailData{
+ Name: toName,
+ HomepageUrl: hmnurl.BuildHomepage(),
+ ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(),
+ })
+ if err != nil {
+ return err
+ }
+ b1.End()
+
+ b2 := perf.StartBlock("EMAIL", "Sending email")
+ defer b2.End()
+ err = sendMail(toAddress, toName, "[Handmade Software Foundation] Grace period ended", contents)
+ if err != nil {
+ return oops.New(err, "Failed to send email")
+ }
+ b2.End()
+
+ return nil
+}
+
func SendExpoTicketPurchaseEmail(toAddress string, toName string, ticket *models.Ticket) error {
event, ok := hmndata.FindTicketEventBySlug(ticket.EventSlug)
if !ok {
diff --git a/src/templates/src/email_grace_period_ended.html b/src/templates/src/email_grace_period_ended.html
new file mode 100644
index 00000000..f087b2e9
--- /dev/null
+++ b/src/templates/src/email_grace_period_ended.html
@@ -0,0 +1,14 @@
+
+ Hello {{ .Name }},
+
+
+ Your temporary membership grace period for the Handmade Network has ended.
+
+
+ Membership benefits are now paused until payment is completed.
+
+
+ You can update your payment details and restart membership at {{ .ManageSubscriptionUrl }}.
+
+Thanks,
+ The Handmade Network staff.
diff --git a/src/templates/src/hsf_membership.html b/src/templates/src/hsf_membership.html
index 20d4787f..91bf2796 100644
--- a/src/templates/src/hsf_membership.html
+++ b/src/templates/src/hsf_membership.html
@@ -19,8 +19,12 @@ Ongoing membership
{{ if .IsInGracePeriod }}
+ {{ if .NeedsBankVerification }}
+ You haven't verified your bank account yet. Please verify before {{ .GracePeriodEnd }} to keep your membership benefits.
+ {{ else }}
We couldn't process your latest payment. Your membership benefits remain active until {{ .GracePeriodEnd }}.
Please update your payment method to avoid losing access.
+ {{ end }}
{{ else }}
diff --git a/src/website/base_data.go b/src/website/base_data.go
index fe9d831e..3e2d7ece 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -137,24 +137,31 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
showMembershipVerificationBanner := false
bankVerificationJustCompleted := c.Req != nil && c.Req.URL != nil && c.Req.URL.Query().Get("bank_verified") == "1"
if userNeedsBankVerificationReminder(c.CurrentUser) && !bankVerificationJustCompleted {
- showMembershipVerificationBanner = true
- baseData.Header.ShowMembershipVerificationBanner = true
bannerURL := hmnurl.BuildHSFMembership()
+ hasHostedVerification := false
if c.CurrentUser.StripeSubscriptionID != nil && config.Config.Stripe.SecretKey != "" {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
if hostedURL := hostedBankVerificationURL(c, sc, *c.CurrentUser.StripeSubscriptionID); hostedURL != "" {
bannerURL = hostedURL
+ hasHostedVerification = true
}
}
- baseData.Header.MembershipVerificationUrl = bannerURL
- baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow())
- baseData.Header.MembershipVerificationStateKey = fmt.Sprintf(
- "user:%d|status:%s|grace_end:%s|days:%d",
- c.CurrentUser.ID,
- stringOrEmpty(c.CurrentUser.SubscriptionStatus),
- timeOrEmpty(c.CurrentUser.GracePeriodEndsAt),
- baseData.Header.MembershipGraceDaysRemaining,
- )
+
+ status := stringOrEmpty(c.CurrentUser.SubscriptionStatus)
+ statusImpliesVerificationPending := status == SubscriptionStatusPendingVerification || status == "incomplete"
+ if statusImpliesVerificationPending || hasHostedVerification {
+ showMembershipVerificationBanner = true
+ baseData.Header.ShowMembershipVerificationBanner = true
+ baseData.Header.MembershipVerificationUrl = bannerURL
+ baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow())
+ baseData.Header.MembershipVerificationStateKey = fmt.Sprintf(
+ "user:%d|status:%s|grace_end:%s|days:%d",
+ c.CurrentUser.ID,
+ status,
+ timeOrEmpty(c.CurrentUser.GracePeriodEndsAt),
+ baseData.Header.MembershipGraceDaysRemaining,
+ )
+ }
}
if !showMembershipVerificationBanner && userNeedsDiscordLinkReminder(c.CurrentUser) {
baseData.Header.ShowMembershipDiscordLinkBanner = true
diff --git a/src/website/hsf.go b/src/website/hsf.go
index 52715c91..3148ec70 100644
--- a/src/website/hsf.go
+++ b/src/website/hsf.go
@@ -27,6 +27,23 @@ func HSFDetails(c *RequestContext) ResponseData {
}
func HSFMembership(c *RequestContext) ResponseData {
+ // If the user just completed checkout, Stripe redirects with a session_id.
+ // Verify it before building base/header data so both header and page body
+ // are rendered from the same up-to-date subscription state.
+ if c.CurrentUser != nil && !c.CurrentUser.IsSubscribed {
+ if sessionID := c.Req.URL.Query().Get("session_id"); sessionID != "" {
+ sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ session, err := sc.V1CheckoutSessions.Retrieve(c, sessionID, nil)
+ if err == nil && session.PaymentStatus == stripe.CheckoutSessionPaymentStatusPaid {
+ c.CurrentUser.IsSubscribed = true
+ activeStatus := "active"
+ c.CurrentUser.SubscriptionStatus = &activeStatus
+ c.CurrentUser.GracePeriodStartedAt = nil
+ c.CurrentUser.GracePeriodEndsAt = nil
+ }
+ }
+ }
+
if c.Req.URL.Query().Get("payment_method_updated") == "1" && c.CurrentUser != nil {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, c.CurrentUser); err != nil {
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index c6a21206..13024d25 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -6,8 +6,10 @@ import (
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
+ "git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
+ "git.handmade.network/hmn/hmn/src/perf"
"github.com/stripe/stripe-go/v84"
)
@@ -241,10 +243,27 @@ func ExpireSubscriptionGracePeriods(ctx context.Context, conn db.ConnOrTx) (int6
}
for _, userID := range userIDs {
SyncSupporterDiscordRole(ctx, conn, userID)
+ sendGracePeriodEndedEmail(ctx, conn, userID)
}
return int64(len(userIDs)), nil
}
+func sendGracePeriodEndedEmail(ctx context.Context, conn db.ConnOrTx, userID int) {
+ user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID)
+ if err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to fetch user for grace period ended email")
+ return
+ }
+
+ p := perf.ExtractPerf(ctx)
+ if p == nil {
+ p = perf.MakeNewRequestPerf("subscription grace expiry", "JOB", "/subscription/grace-expiry")
+ }
+ if err := email.SendGracePeriodEndedEmail(user.Email, user.BestName(), p); err != nil {
+ logging.Error().Err(err).Int("userID", userID).Msg("failed to send grace period ended email")
+ }
+}
+
func shouldRetrySubscriptionPayment(user *models.User) bool {
if user == nil || user.StripeCustomerID == nil || user.StripeSubscriptionID == nil {
return false
diff --git a/src/website/subscription_grace_eligibility.go b/src/website/subscription_grace_eligibility.go
index 4ded7852..0104e15f 100644
--- a/src/website/subscription_grace_eligibility.go
+++ b/src/website/subscription_grace_eligibility.go
@@ -24,6 +24,18 @@ func paymentIntentHasMicrodepositVerification(pi *stripe.PaymentIntent) bool {
return pi.NextAction.Type == stripe.PaymentIntentNextActionTypeVerifyWithMicrodeposits
}
+// shouldSendACHVerificationEmailForPaymentIntent returns true only when Stripe
+// indicates the user still needs to complete bank verification.
+func shouldSendACHVerificationEmailForPaymentIntent(pi *stripe.PaymentIntent, paymentMethodType string) bool {
+ if pi == nil {
+ return false
+ }
+ if !isAsyncPaymentMethodType(resolvePaymentMethodType(pi, paymentMethodType)) {
+ return false
+ }
+ return paymentIntentHasMicrodepositVerification(pi)
+}
+
// shouldGrantGraceForPaymentIntent returns true when payment is in-flight for an async
// method (e.g. ACH processing or microdeposit verification), not a card decline.
func shouldGrantGraceForPaymentIntent(pi *stripe.PaymentIntent, paymentMethodType string) bool {
@@ -171,6 +183,23 @@ func shouldGrantGraceForSubscription(ctx context.Context, sc *stripe.Client, sub
return shouldGrantGraceForPaymentIntent(pi, pmType)
}
+func shouldSendACHVerificationEmailForSubscription(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) bool {
+ if sub == nil || sub.LatestInvoice == nil {
+ return false
+ }
+ invParams := &stripe.InvoiceRetrieveParams{}
+ invParams.AddExpand("payments.data.payment.payment_intent")
+ inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams)
+ if err != nil {
+ return false
+ }
+ pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
+ if err != nil {
+ return false
+ }
+ return shouldSendACHVerificationEmailForPaymentIntent(pi, pmType)
+}
+
func shouldGrantGraceForInvoice(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool {
pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
if err != nil {
@@ -179,6 +208,14 @@ func shouldGrantGraceForInvoice(ctx context.Context, sc *stripe.Client, inv *str
return shouldGrantGraceForPaymentIntent(pi, pmType)
}
+func shouldSendACHVerificationEmailForInvoice(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool {
+ pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
+ if err != nil {
+ return false
+ }
+ return shouldSendACHVerificationEmailForPaymentIntent(pi, pmType)
+}
+
func invoicePaymentIsHardDecline(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool {
pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
if err != nil || pi == nil {
diff --git a/src/website/subscription_payment_intent_webhook.go b/src/website/subscription_payment_intent_webhook.go
index f0bad8cf..9be2be6d 100644
--- a/src/website/subscription_payment_intent_webhook.go
+++ b/src/website/subscription_payment_intent_webhook.go
@@ -55,6 +55,7 @@ func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client,
switch eventType {
case stripe.EventTypePaymentIntentProcessing, stripe.EventTypePaymentIntentRequiresAction:
if shouldGrantGraceForPaymentIntent(pi, pmType) && canStartGrace(user, now) {
+ shouldSendVerificationEmail := shouldSendACHVerificationEmailForPaymentIntent(pi, pmType)
if user.StripeCustomerID == nil || user.StripeSubscriptionID == nil {
return true
}
@@ -70,7 +71,7 @@ func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client,
startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now)
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment intent webhook")
- } else if startedGrace {
+ } else if startedGrace && shouldSendVerificationEmail {
sendACHVerificationGraceEmail(c, user.ID)
}
}
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index f91674b0..bdc4bee1 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -97,6 +97,7 @@ type ManageSubscriptionTemplateData struct {
LastPaymentMethod string
GracePeriodEnd string
IsInGracePeriod bool
+ NeedsBankVerification bool
DefaultMembershipPriceID string
EurMembershipPriceID string
@@ -111,19 +112,6 @@ func SubscriptionManageRedirect(c *RequestContext) ResponseData {
}
func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) ManageSubscriptionTemplateData {
- // If the user just completed checkout, Stripe redirects with a session_id.
- // Verify it so we can show the correct "subscribed" view even if webhooks
- // haven't updated the DB yet.
- if c.CurrentUser != nil && !c.CurrentUser.IsSubscribed {
- if sessionID := c.Req.URL.Query().Get("session_id"); sessionID != "" {
- sc := stripe.NewClient(config.Config.Stripe.SecretKey)
- session, err := sc.V1CheckoutSessions.Retrieve(c, sessionID, nil)
- if err == nil && session.PaymentStatus == stripe.CheckoutSessionPaymentStatusPaid {
- c.CurrentUser.IsSubscribed = true
- }
- }
- }
-
var history []PaymentHistoryItem
currentCurrencySymbol := "$"
currentAmount := "5.00"
@@ -179,11 +167,21 @@ func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) Man
gracePeriodEnd := ""
isInGracePeriod := false
+ needsBankVerification := false
if c.CurrentUser != nil && userInGracePeriod(c.CurrentUser) {
isInGracePeriod = true
if c.CurrentUser.GracePeriodEndsAt != nil {
gracePeriodEnd = c.CurrentUser.GracePeriodEndsAt.UTC().Format("Jan 2, 2006")
}
+
+ status := stringOrEmpty(c.CurrentUser.SubscriptionStatus)
+ needsBankVerification = status == SubscriptionStatusPendingVerification || status == "incomplete"
+ if c.CurrentUser.StripeSubscriptionID != nil {
+ sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ if hostedBankVerificationURL(c, sc, *c.CurrentUser.StripeSubscriptionID) != "" {
+ needsBankVerification = true
+ }
+ }
}
return ManageSubscriptionTemplateData{
@@ -200,6 +198,7 @@ func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) Man
LastPaymentMethod: lastMethod,
GracePeriodEnd: gracePeriodEnd,
IsInGracePeriod: isInGracePeriod,
+ NeedsBankVerification: needsBankVerification,
DefaultMembershipPriceID: config.Config.Stripe.PriceID,
EurMembershipPriceID: eurPriceID,
}
@@ -424,6 +423,7 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
}
grantGrace := shouldGrantGraceForPaymentIntent(pi, pmType)
+ shouldSendVerificationEmail := shouldSendACHVerificationEmailForPaymentIntent(pi, pmType)
now := SubscriptionNow()
if grantGrace && canStartGrace(user, now) {
@@ -442,7 +442,7 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
startedGrace, err := startGracePeriod(c, c.Conn, userID, now)
if err != nil {
logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for processing checkout payment")
- } else if startedGrace {
+ } else if startedGrace && shouldSendVerificationEmail {
sendACHVerificationGraceEmail(c, userID)
}
} else if isGraceActive(user, now) {
@@ -568,12 +568,13 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe
}
asyncGraceEligible := shouldGrantGraceForSubscription(c, sc, sub)
+ shouldSendVerificationEmail := shouldSendACHVerificationEmailForSubscription(c, sc, sub)
if isFailedPaymentStripeStatus(stripeStatus) && shouldStartGraceOnPaymentFailure(user, now, asyncGraceEligible) {
startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now)
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period")
return
- } else if startedGrace && asyncGraceEligible {
+ } else if startedGrace && shouldSendVerificationEmail {
sendACHVerificationGraceEmail(c, user.ID)
}
} else if isFailedPaymentStripeStatus(stripeStatus) && !isGraceActive(user, now) {
@@ -763,12 +764,13 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip
now := SubscriptionNow()
grantGrace := shouldGrantGraceForInvoice(c, sc, inv)
+ shouldSendVerificationEmail := shouldSendACHVerificationEmailForInvoice(c, sc, inv)
hardDecline := invoicePaymentIsHardDecline(c, sc, inv)
if shouldStartGraceOnPaymentFailure(user, now, grantGrace) {
startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now)
if err != nil {
logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period from invoice.payment_failed")
- } else if startedGrace && grantGrace {
+ } else if startedGrace && shouldSendVerificationEmail {
sendACHVerificationGraceEmail(c, user.ID)
}
} else if hardDecline && !isGraceActive(user, now) {
From 6d7e5176fe2e0ddd2490954df09e3e014ca0097d Mon Sep 17 00:00:00 2001
From: reece365
Date: Tue, 2 Jun 2026 05:03:04 -0500
Subject: [PATCH 12/15] Various payment flow bug fixes
---
src/admintools/adminsubscription.go | 68 +++++-
...T093500Z_AddStripeMembershipEventCursor.go | 45 ++++
src/templates/src/hsf_membership.html | 14 ++
src/website/base_data.go | 6 +-
src/website/stripe.go | 218 ++++++++++++++----
src/website/subscription_grace.go | 3 +-
src/website/subscriptions.go | 116 ++++++++--
7 files changed, 400 insertions(+), 70 deletions(-)
create mode 100644 src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go
diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go
index d53e5712..c85e2c93 100644
--- a/src/admintools/adminsubscription.go
+++ b/src/admintools/adminsubscription.go
@@ -542,12 +542,29 @@ func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.P
if err != nil {
return result, err
}
+ user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if user.StripeSubscriptionID != nil {
+ if paidInvoice, err := waitForLatestSubscriptionInvoiceStatus(ctx, sc, *user.StripeSubscriptionID, stripe.InvoiceStatusPaid, 20*time.Second); err != nil {
+ return subscriptionTestResultPass, err
+ } else if paidInvoice != nil {
+ _, err = pool.Exec(ctx, `UPDATE hmn_user SET thank_you_email_sent = false WHERE id = $1`, setup.userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", paidInvoice); err != nil {
+ return subscriptionTestResultPass, fmt.Errorf("dispatch invoice.paid after ACH verification: %w", err)
+ }
+ }
+ }
if err := expectScenarioEmailSubjects(ctx, []string{subjectThankYou}); err != nil {
return subscriptionTestResultPass, fmt.Errorf("verify thank-you email after ACH verification: %w", err)
}
fmt.Printf("[8/8] Verifying membership is active after ACH verification\n")
- user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
+ user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID)
if err != nil {
return subscriptionTestResultPass, err
}
@@ -682,6 +699,7 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
if err != nil {
return subscriptionTestResultPass, err
}
+ failedInvoiceID := failedInvoice.ID
fmt.Printf(" renewal invoice_id=%s status=%s\n", failedInvoice.ID, failedInvoice.Status)
fmt.Printf("[8/10] Processing renewal failure webhooks\n")
@@ -719,6 +737,13 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil {
return subscriptionTestResultPass, err
}
+ if failedInvoiceID != "" {
+ if recoveredInvoice, err := sc.V1Invoices.Retrieve(ctx, failedInvoiceID, nil); err == nil && recoveredInvoice.Status == stripe.InvoiceStatusPaid {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", recoveredInvoice); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ }
if err := expectScenarioEmailSubjects(ctx, []string{subjectPaymentFailed}); err != nil {
return subscriptionTestResultPass, fmt.Errorf("verify payment failed email: %w", err)
}
@@ -784,6 +809,17 @@ func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpo
return subscriptionTestResultPass, err
}
}
+ _, err = pool.Exec(ctx, `UPDATE hmn_user SET thank_you_email_sent = false WHERE id = $1`, userID)
+ if err != nil {
+ return subscriptionTestResultPass, err
+ }
+ if failedInvoiceID != "" {
+ if recoveredInvoice, err := sc.V1Invoices.Retrieve(ctx, failedInvoiceID, nil); err == nil && recoveredInvoice.Status == stripe.InvoiceStatusPaid {
+ if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", recoveredInvoice); err != nil {
+ return subscriptionTestResultPass, err
+ }
+ }
+ }
if err := expectScenarioEmailSubjects(ctx, []string{subjectPaymentFailed, subjectThankYou}); err != nil {
return subscriptionTestResultPass, fmt.Errorf("verify payment-recovery emails: %w", err)
}
@@ -880,6 +916,36 @@ func waitForSubscriptionStatus(ctx context.Context, sc *stripe.Client, subscript
return nil, fmt.Errorf("membership subscription %s did not reach status %v within timeout (last status=%s)", subscriptionID, statuses, sub.Status)
}
+func waitForLatestSubscriptionInvoiceStatus(ctx context.Context, sc *stripe.Client, subscriptionID string, status stripe.InvoiceStatus, timeout time.Duration) (*stripe.Invoice, error) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
+ if err != nil {
+ return nil, err
+ }
+ inv, err := retrieveLatestSubscriptionInvoice(ctx, sc, sub)
+ if err != nil {
+ return nil, err
+ }
+ if inv != nil && inv.Status == status {
+ return inv, nil
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
+ if err != nil {
+ return nil, err
+ }
+ inv, err := retrieveLatestSubscriptionInvoice(ctx, sc, sub)
+ if err != nil {
+ return nil, err
+ }
+ if inv == nil {
+ return nil, fmt.Errorf("subscription %s has no latest invoice after waiting for status=%s", subscriptionID, status)
+ }
+ return nil, fmt.Errorf("latest invoice %s did not reach status=%s within timeout (last status=%s)", inv.ID, status, inv.Status)
+}
+
func retrieveLatestSubscriptionInvoice(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) (*stripe.Invoice, error) {
if sub == nil {
return nil, fmt.Errorf("membership subscription is nil")
diff --git a/src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go b/src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go
new file mode 100644
index 00000000..7986d159
--- /dev/null
+++ b/src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go
@@ -0,0 +1,45 @@
+package migrations
+
+import (
+ "context"
+ "time"
+
+ "git.handmade.network/hmn/hmn/src/migration/types"
+ "github.com/jackc/pgx/v5"
+)
+
+func init() {
+ registerMigration(AddStripeMembershipEventCursor{})
+}
+
+type AddStripeMembershipEventCursor struct{}
+
+func (m AddStripeMembershipEventCursor) Version() types.MigrationVersion {
+ return types.MigrationVersion(time.Date(2026, 6, 2, 9, 35, 0, 0, time.UTC))
+}
+
+func (m AddStripeMembershipEventCursor) Name() string {
+ return "AddStripeMembershipEventCursor"
+}
+
+func (m AddStripeMembershipEventCursor) Description() string {
+ return "Add per-customer Stripe membership event ordering cursor"
+}
+
+func (m AddStripeMembershipEventCursor) Up(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ CREATE TABLE stripe_membership_event_cursor (
+ customer_id TEXT PRIMARY KEY,
+ last_event_created BIGINT NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+ )
+ `)
+ return err
+}
+
+func (m AddStripeMembershipEventCursor) Down(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ DROP TABLE stripe_membership_event_cursor
+ `)
+ return err
+}
diff --git a/src/templates/src/hsf_membership.html b/src/templates/src/hsf_membership.html
index 91bf2796..88a8fdd8 100644
--- a/src/templates/src/hsf_membership.html
+++ b/src/templates/src/hsf_membership.html
@@ -2,18 +2,30 @@
{{ define "content" }}
+ {{ if not (and .User .User.IsSubscribed) }}
The Handmade Software Foundation is funded by its members. You can support our mission by becoming a member today.
Ongoing membership
You can become a member of the Handmade Software Foundation by paying a monthly membership fee, Patreon-style. We currently have just one tier of membership.
+ {{ end }}
+ {{ if .ShowPostCheckoutPendingInfo }}
+
+
+ Thanks! Your membership signup is processing. If your bank account still needs verification, please complete that step as soon as possible to keep your membership benefits active.
+
+
+ {{ end }}
+
{{ if and .User .User.IsSubscribed }}
Thank you for being a supporter! Your recurring donation helps us maintain the site, host events, and
advocate for better software.
{{ end }}
+ {{ if not .ShowPostCheckoutPendingInfo }}
{{ template "supporter_card" . }}
+ {{ end }}
{{ if and .User .User.IsSubscribed }}
{{ if .IsInGracePeriod }}
@@ -21,6 +33,8 @@
Ongoing membership
{{ if .NeedsBankVerification }}
You haven't verified your bank account yet. Please verify before {{ .GracePeriodEnd }} to keep your membership benefits.
+ {{ else if .IsPaymentPending }}
+ Your latest payment is still processing. Your membership benefits remain active until {{ .GracePeriodEnd }}.
{{ else }}
We couldn't process your latest payment. Your membership benefits remain active until {{ .GracePeriodEnd }}.
Please update your payment method to avoid losing access.
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 3e2d7ece..f2df68a2 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -135,8 +135,12 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
if c.CurrentUser != nil {
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
showMembershipVerificationBanner := false
+ isPostCheckoutPendingView := c.Req != nil &&
+ c.Req.URL != nil &&
+ c.Req.URL.Query().Get("session_id") != "" &&
+ !c.CurrentUser.IsSubscribed
bankVerificationJustCompleted := c.Req != nil && c.Req.URL != nil && c.Req.URL.Query().Get("bank_verified") == "1"
- if userNeedsBankVerificationReminder(c.CurrentUser) && !bankVerificationJustCompleted {
+ if userNeedsBankVerificationReminder(c.CurrentUser) && !bankVerificationJustCompleted && !isPostCheckoutPendingView {
bannerURL := hmnurl.BuildHSFMembership()
hasHostedVerification := false
if c.CurrentUser.StripeSubscriptionID != nil && config.Config.Stripe.SecretKey != "" {
diff --git a/src/website/stripe.go b/src/website/stripe.go
index 34ce1853..1a69c442 100644
--- a/src/website/stripe.go
+++ b/src/website/stripe.go
@@ -17,7 +17,170 @@ func init() {
stripe.Key = config.Config.Stripe.SecretKey
}
-// StripeWebhook verifies and routes all Stripe webhook events.
+func beginStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
+ tag, err := conn.Exec(ctx, `
+ INSERT INTO stripe_webhook_event (event_id, event_type, status, last_error, updated_at, processed_at)
+ VALUES ($1, $2, 'processing', NULL, NOW(), NULL)
+ ON CONFLICT (event_id) DO NOTHING
+ `, event.ID, string(event.Type))
+ if err != nil {
+ return false, oops.New(err, "failed to insert stripe webhook event id")
+ }
+ if tag.RowsAffected() == 1 {
+ // First claimant for this event ID.
+ return true, nil
+ }
+
+ status, err := db.QueryOneScalar[string](ctx, conn, `
+ SELECT status
+ FROM stripe_webhook_event
+ WHERE event_id = $1
+ `, event.ID)
+ if err != nil {
+ return false, oops.New(err, "failed to read Stripe webhook event state")
+ }
+ switch status {
+ case "processed", "processing":
+ return false, nil
+ case "failed":
+ tag, err = conn.Exec(ctx, `
+ UPDATE stripe_webhook_event
+ SET
+ event_type = $2,
+ status = 'processing',
+ last_error = NULL,
+ updated_at = NOW(),
+ processed_at = NULL
+ WHERE event_id = $1
+ AND status = 'failed'
+ `, event.ID, string(event.Type))
+ if err != nil {
+ return false, oops.New(err, "failed to mark stripe webhook event as processing")
+ }
+ return tag.RowsAffected() == 1, nil
+ default:
+ return false, nil
+ }
+}
+
+func shouldProcessMembershipEventOrder(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
+ customerID := membershipEventCustomerID(event)
+ if customerID == "" {
+ return true, nil
+ }
+
+ createdAt := event.Created
+ if createdAt <= 0 {
+ return true, nil
+ }
+
+ tag, err := conn.Exec(ctx, `
+ INSERT INTO stripe_membership_event_cursor (customer_id, last_event_created, updated_at)
+ VALUES ($1, $2, NOW())
+ ON CONFLICT (customer_id) DO UPDATE
+ SET
+ last_event_created = EXCLUDED.last_event_created,
+ updated_at = NOW()
+ WHERE stripe_membership_event_cursor.last_event_created <= EXCLUDED.last_event_created
+ `, customerID, createdAt)
+ if err != nil {
+ return false, oops.New(err, "failed to update membership event cursor")
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
+func membershipEventCustomerID(event *stripe.Event) string {
+ if event == nil {
+ return ""
+ }
+ switch event.Type {
+ case stripe.EventTypePaymentIntentProcessing,
+ stripe.EventTypePaymentIntentRequiresAction,
+ stripe.EventTypePaymentIntentPaymentFailed:
+ var pi stripe.PaymentIntent
+ if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
+ return ""
+ }
+ if pi.Customer == nil {
+ return ""
+ }
+ return pi.Customer.ID
+ case "customer.subscription.created",
+ "customer.subscription.updated",
+ "customer.subscription.deleted":
+ var sub stripe.Subscription
+ if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
+ return ""
+ }
+ if sub.Customer == nil {
+ return ""
+ }
+ return sub.Customer.ID
+ case "invoice.paid", "invoice.payment_failed":
+ var inv stripe.Invoice
+ if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
+ return ""
+ }
+ if inv.Customer == nil {
+ return ""
+ }
+ return inv.Customer.ID
+ case "checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed":
+ var session stripe.CheckoutSession
+ if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
+ return ""
+ }
+ if session.Customer == nil {
+ return ""
+ }
+ return session.Customer.ID
+ case "payment_method.attached":
+ var pm stripe.PaymentMethod
+ if err := json.Unmarshal(event.Data.Raw, &pm); err != nil {
+ return ""
+ }
+ if pm.Customer == nil {
+ return ""
+ }
+ return pm.Customer.ID
+ case "customer.updated":
+ var customer stripe.Customer
+ if err := json.Unmarshal(event.Data.Raw, &customer); err != nil {
+ return ""
+ }
+ return customer.ID
+ default:
+ return ""
+ }
+}
+
+func isMembershipPaymentIntentEvent(event *stripe.Event) bool {
+ if event == nil {
+ return false
+ }
+ switch event.Type {
+ case stripe.EventTypePaymentIntentProcessing,
+ stripe.EventTypePaymentIntentRequiresAction,
+ stripe.EventTypePaymentIntentPaymentFailed:
+ return true
+ default:
+ return false
+ }
+}
+
+func checkMembershipEventOrder(c *RequestContext, event *stripe.Event) bool {
+ shouldProcess, err := shouldProcessMembershipEventOrder(c, c.Conn, event)
+ if err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed membership event ordering guard")
+ return false
+ }
+ if !shouldProcess {
+ c.Logger.Info().Str("eventID", event.ID).Str("type", string(event.Type)).Msg("stale membership event by created timestamp; ignoring")
+ return false
+ }
+ return true
+}
+
func StripeWebhook(c *RequestContext) ResponseData {
const MaxBodyBytes = 65536
payload, err := io.ReadAll(io.LimitReader(c.Req.Body, MaxBodyBytes))
@@ -64,9 +227,17 @@ func StripeWebhook(c *RequestContext) ResponseData {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
if isMembershipGracePaymentRetryEvent(&event) {
- handleMembershipGracePaymentRetryWebhook(c, sc, &event)
+ if checkMembershipEventOrder(c, &event) {
+ handleMembershipGracePaymentRetryWebhook(c, sc, &event)
+ }
}
+ if isMembershipPaymentIntentEvent(&event) && !checkMembershipEventOrder(c, &event) {
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
+ }
+ return ResponseData{StatusCode: http.StatusOK}
+ }
if tryHandleMembershipPaymentIntentWebhook(c, sc, &event) {
if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
@@ -100,7 +271,9 @@ func StripeWebhook(c *RequestContext) ResponseData {
}
return res
case stripeWebhookKindMembership:
- handleMembershipStripeEvent(c, sc, &event)
+ if checkMembershipEventOrder(c, &event) {
+ handleMembershipStripeEvent(c, sc, &event)
+ }
if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
}
@@ -117,45 +290,6 @@ func StripeWebhook(c *RequestContext) ResponseData {
}
}
-func beginStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
- status, err := db.QueryOneScalar[string](ctx, conn, `
- SELECT status
- FROM stripe_webhook_event
- WHERE event_id = $1
- `, event.ID)
- if err != nil && err != db.NotFound {
- return false, oops.New(err, "failed to read Stripe webhook event state")
- }
- if err == nil && status == "processed" {
- return false, nil
- }
-
- _, err = conn.Exec(ctx, `
- INSERT INTO stripe_webhook_event (event_id, event_type, status, last_error, updated_at, processed_at)
- VALUES ($1, $2, 'processing', NULL, NOW(), NULL)
- ON CONFLICT (event_id) DO NOTHING
- `, event.ID, string(event.Type))
- if err != nil {
- return false, oops.New(err, "failed to insert stripe webhook event id")
- }
-
- _, err = conn.Exec(ctx, `
- UPDATE stripe_webhook_event
- SET
- event_type = $2,
- status = 'processing',
- last_error = NULL,
- updated_at = NOW(),
- processed_at = NULL
- WHERE event_id = $1
- AND status <> 'processed'
- `, event.ID, string(event.Type))
- if err != nil {
- return false, oops.New(err, "failed to mark stripe webhook event as processing")
- }
- return true, nil
-}
-
func finishStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event, processErr error) error {
if processErr == nil {
_, err := conn.Exec(ctx, `
diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go
index 13024d25..9547deff 100644
--- a/src/website/subscription_grace.go
+++ b/src/website/subscription_grace.go
@@ -84,7 +84,8 @@ func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now tim
subscription_status = $1,
grace_period_started_at = $2,
grace_period_ends_at = $3,
- grace_available = false
+ grace_available = false,
+ thank_you_email_sent = false
WHERE id = $4
AND grace_available = true
AND (grace_period_ends_at IS NULL OR grace_period_ends_at <= $2)
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index bdc4bee1..e807dff5 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -89,15 +89,17 @@ type ManageSubscriptionTemplateData struct {
CancelSubscriptionUrl string
ResumeSubscriptionUrl string
UpdatePaymentMethodUrl string
- CurrentCurrencySymbol string
- CurrentAmount string
- PaymentHistory []PaymentHistoryItem
- CurrentPeriodEnd string
- LastPaymentAmount string
- LastPaymentMethod string
- GracePeriodEnd string
- IsInGracePeriod bool
- NeedsBankVerification bool
+ CurrentCurrencySymbol string
+ CurrentAmount string
+ PaymentHistory []PaymentHistoryItem
+ CurrentPeriodEnd string
+ LastPaymentAmount string
+ LastPaymentMethod string
+ GracePeriodEnd string
+ IsInGracePeriod bool
+ NeedsBankVerification bool
+ IsPaymentPending bool
+ ShowPostCheckoutPendingInfo bool
DefaultMembershipPriceID string
EurMembershipPriceID string
@@ -168,6 +170,7 @@ func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) Man
gracePeriodEnd := ""
isInGracePeriod := false
needsBankVerification := false
+ isPaymentPending := false
if c.CurrentUser != nil && userInGracePeriod(c.CurrentUser) {
isInGracePeriod = true
if c.CurrentUser.GracePeriodEndsAt != nil {
@@ -181,29 +184,65 @@ func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) Man
if hostedBankVerificationURL(c, sc, *c.CurrentUser.StripeSubscriptionID) != "" {
needsBankVerification = true
}
+ if !needsBankVerification && asyncSubscriptionPaymentStillProcessing(c, sc, *c.CurrentUser.StripeSubscriptionID) {
+ isPaymentPending = true
+ }
}
}
+ showPostCheckoutPendingInfo := c.CurrentUser != nil &&
+ !c.CurrentUser.IsSubscribed &&
+ strings.TrimSpace(c.Req.URL.Query().Get("session_id")) != ""
return ManageSubscriptionTemplateData{
- BaseData: baseData,
- SubscribeUrl: hmnurl.BuildSubscriptionSubscribe(),
- CancelSubscriptionUrl: hmnurl.BuildSubscriptionCancel(),
- ResumeSubscriptionUrl: hmnurl.BuildSubscriptionResume(),
- UpdatePaymentMethodUrl: hmnurl.BuildSubscriptionUpdatePaymentMethod(),
- CurrentCurrencySymbol: currentCurrencySymbol,
- CurrentAmount: currentAmount,
- PaymentHistory: history,
- CurrentPeriodEnd: currentPeriodEnd,
- LastPaymentAmount: lastAmount,
- LastPaymentMethod: lastMethod,
- GracePeriodEnd: gracePeriodEnd,
- IsInGracePeriod: isInGracePeriod,
- NeedsBankVerification: needsBankVerification,
- DefaultMembershipPriceID: config.Config.Stripe.PriceID,
- EurMembershipPriceID: eurPriceID,
+ BaseData: baseData,
+ SubscribeUrl: hmnurl.BuildSubscriptionSubscribe(),
+ CancelSubscriptionUrl: hmnurl.BuildSubscriptionCancel(),
+ ResumeSubscriptionUrl: hmnurl.BuildSubscriptionResume(),
+ UpdatePaymentMethodUrl: hmnurl.BuildSubscriptionUpdatePaymentMethod(),
+ CurrentCurrencySymbol: currentCurrencySymbol,
+ CurrentAmount: currentAmount,
+ PaymentHistory: history,
+ CurrentPeriodEnd: currentPeriodEnd,
+ LastPaymentAmount: lastAmount,
+ LastPaymentMethod: lastMethod,
+ GracePeriodEnd: gracePeriodEnd,
+ IsInGracePeriod: isInGracePeriod,
+ NeedsBankVerification: needsBankVerification,
+ IsPaymentPending: isPaymentPending,
+ ShowPostCheckoutPendingInfo: showPostCheckoutPendingInfo,
+ DefaultMembershipPriceID: config.Config.Stripe.PriceID,
+ EurMembershipPriceID: eurPriceID,
}
}
+func asyncSubscriptionPaymentStillProcessing(ctx context.Context, sc *stripe.Client, subscriptionID string) bool {
+ if sc == nil || subscriptionID == "" {
+ return false
+ }
+
+ params := &stripe.SubscriptionRetrieveParams{}
+ params.AddExpand("latest_invoice")
+ sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, params)
+ if err != nil || sub == nil || sub.LatestInvoice == nil {
+ return false
+ }
+
+ invParams := &stripe.InvoiceRetrieveParams{}
+ invParams.AddExpand("payments.data.payment.payment_intent")
+ inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams)
+ if err != nil {
+ return false
+ }
+ pi, pmType, err := invoicePaymentIntent(ctx, sc, inv)
+ if err != nil || pi == nil {
+ return false
+ }
+
+ return pi.Status == stripe.PaymentIntentStatusProcessing &&
+ isAsyncPaymentMethodType(pmType) &&
+ !paymentIntentHasMicrodepositVerification(pi)
+}
+
func SubscriptionSubscribe(c *RequestContext) ResponseData {
if c.CurrentUser.IsSubscribed {
return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
@@ -301,6 +340,33 @@ func SubscriptionCancel(c *RequestContext) ResponseData {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
+ if userInGracePeriod(c.CurrentUser) {
+ _, err := sc.V1Subscriptions.Cancel(c, *c.CurrentUser.StripeSubscriptionID, nil)
+ if err != nil {
+ return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to cancel subscription immediately"))
+ }
+
+ _, err = c.Conn.Exec(c, `
+ UPDATE hmn_user
+ SET
+ is_subscribed = false,
+ stripe_subscription_id = NULL,
+ subscription_status = 'canceled',
+ current_period_end = NULL,
+ cancel_at_period_end = false,
+ thank_you_email_sent = false,
+ grace_period_started_at = NULL,
+ grace_period_ends_at = NULL
+ WHERE id = $1
+ `, c.CurrentUser.ID)
+ if err != nil {
+ logging.Error().Err(err).Msg("failed to apply immediate local cancel state for grace-period user")
+ }
+ SyncSupporterDiscordRole(c, c.Conn, c.CurrentUser.ID)
+
+ return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther)
+ }
+
params := &stripe.SubscriptionUpdateParams{
CancelAtPeriodEnd: stripe.Bool(true),
}
From acd5f55436924899b42673bfa5af255a4874fe59 Mon Sep 17 00:00:00 2001
From: reece365
Date: Tue, 2 Jun 2026 05:48:05 -0500
Subject: [PATCH 13/15] Fix for when Stripe doens't provide paymentIntent
---
src/website/subscriptions.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go
index e807dff5..424f3956 100644
--- a/src/website/subscriptions.go
+++ b/src/website/subscriptions.go
@@ -490,6 +490,16 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio
grantGrace := shouldGrantGraceForPaymentIntent(pi, pmType)
shouldSendVerificationEmail := shouldSendACHVerificationEmailForPaymentIntent(pi, pmType)
+ if !grantGrace {
+ // Some ACH checkout.session.completed payloads do not include a usable payment_intent yet.
+ // Fall back to subscription/invoice inspection to decide grace eligibility.
+ if sub, subErr := sc.V1Subscriptions.Retrieve(c, session.Subscription.ID, nil); subErr == nil {
+ grantGrace = shouldGrantGraceForSubscription(c, sc, sub)
+ shouldSendVerificationEmail = shouldSendACHVerificationEmailForSubscription(c, sc, sub)
+ } else {
+ logging.Warn().Err(subErr).Int("userID", userID).Str("subscriptionID", session.Subscription.ID).Msg("failed to inspect subscription for pending checkout grace fallback")
+ }
+ }
now := SubscriptionNow()
if grantGrace && canStartGrace(user, now) {
From 966c57db692f1d43b14427bc29cd4cd7ac735504 Mon Sep 17 00:00:00 2001
From: reece365
Date: Wed, 3 Jun 2026 02:58:16 -0500
Subject: [PATCH 14/15] Header tweaks for readability and pragmaticism
---
src/templates/src/include/header-2024.html | 97 ++++++++++------------
src/templates/types.go | 1 -
src/website/base_data.go | 7 --
src/website/discord_membership.go | 8 +-
4 files changed, 46 insertions(+), 67 deletions(-)
diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html
index 3a0e8134..72826e2e 100644
--- a/src/templates/src/include/header-2024.html
+++ b/src/templates/src/include/header-2024.html
@@ -83,75 +83,50 @@
href="{{ .Header.MembershipVerificationUrl }}"
target="_blank"
rel="noopener noreferrer"
- data-verification-state-key="{{ .Header.MembershipVerificationStateKey }}"
>
Bank account verification pending{{ if gt .Header.MembershipGraceDaysRemaining 0 }} — membership benefits remain active for {{ .Header.MembershipGraceDaysRemaining }} more {{ if eq .Header.MembershipGraceDaysRemaining 1 }}day{{ else }}days{{ end }}{{ end }}.
Verify bank account{{ svg "arrow-right" }}
{{ end }}
+{{ if not .User }}
+
+{{ end }}
+
{{ if and .Header.ShowMembershipDiscordLinkBanner (not .Header.SuppressBanners) }}
-
+
+
{{ end }}
{{ if and (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }}
diff --git a/src/templates/types.go b/src/templates/types.go
index c16a3103..9e6fd43d 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -84,7 +84,6 @@ type Header struct {
ShowMembershipVerificationBanner bool
MembershipVerificationUrl string
MembershipGraceDaysRemaining int
- MembershipVerificationStateKey string
ShowMembershipDiscordLinkBanner bool
MembershipDiscordLinkUrl string
diff --git a/src/website/base_data.go b/src/website/base_data.go
index f2df68a2..484d5600 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -158,13 +158,6 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
baseData.Header.ShowMembershipVerificationBanner = true
baseData.Header.MembershipVerificationUrl = bannerURL
baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow())
- baseData.Header.MembershipVerificationStateKey = fmt.Sprintf(
- "user:%d|status:%s|grace_end:%s|days:%d",
- c.CurrentUser.ID,
- status,
- timeOrEmpty(c.CurrentUser.GracePeriodEndsAt),
- baseData.Header.MembershipGraceDaysRemaining,
- )
}
}
if !showMembershipVerificationBanner && userNeedsDiscordLinkReminder(c.CurrentUser) {
diff --git a/src/website/discord_membership.go b/src/website/discord_membership.go
index c8d65b48..80770c7e 100644
--- a/src/website/discord_membership.go
+++ b/src/website/discord_membership.go
@@ -105,12 +105,8 @@ func DismissMembershipDiscordLinkBanner(c *RequestContext) ResponseData {
c.CurrentUser.ID,
)
if err != nil {
- return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to dismiss membership Discord link banner"))
+ return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to dismiss membership Discord link banner"))
}
- dest := c.FullUrl()
- if referer := c.Req.Referer(); referer != "" {
- dest = referer
- }
- return c.Redirect(dest, http.StatusSeeOther)
+ return c.JSONResponse(http.StatusOK, map[string]any{"success": true})
}
From 760e3dfc85d9037e942f7f5399afecc48cb1249e Mon Sep 17 00:00:00 2001
From: reece365
Date: Wed, 3 Jun 2026 03:38:27 -0500
Subject: [PATCH 15/15] Minor Stripe webhook handler changes (per @bvisness
suggestions)
---
src/website/base_data.go | 1 -
src/website/discord_membership.go | 2 +-
src/website/hsf.go | 17 -----------
src/website/stripe.go | 50 ++++++++++++++++++++-----------
4 files changed, 33 insertions(+), 37 deletions(-)
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 484d5600..c666db80 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -1,7 +1,6 @@
package website
import (
- "fmt"
"time"
"git.handmade.network/hmn/hmn/src/buildcss"
diff --git a/src/website/discord_membership.go b/src/website/discord_membership.go
index 80770c7e..958f3b77 100644
--- a/src/website/discord_membership.go
+++ b/src/website/discord_membership.go
@@ -54,7 +54,7 @@ func SyncSupporterDiscordRole(ctx context.Context, conn db.ConnOrTx, userID int)
}
func SyncSupporterDiscordRoleForCustomer(ctx context.Context, conn db.ConnOrTx, stripeCustomerID string) {
- if config.Config.Discord.SupporterRoleID == "" || stripeCustomerID == "" {
+ if config.Config.Discord.SupporterRoleID == "" {
return
}
diff --git a/src/website/hsf.go b/src/website/hsf.go
index 3148ec70..52715c91 100644
--- a/src/website/hsf.go
+++ b/src/website/hsf.go
@@ -27,23 +27,6 @@ func HSFDetails(c *RequestContext) ResponseData {
}
func HSFMembership(c *RequestContext) ResponseData {
- // If the user just completed checkout, Stripe redirects with a session_id.
- // Verify it before building base/header data so both header and page body
- // are rendered from the same up-to-date subscription state.
- if c.CurrentUser != nil && !c.CurrentUser.IsSubscribed {
- if sessionID := c.Req.URL.Query().Get("session_id"); sessionID != "" {
- sc := stripe.NewClient(config.Config.Stripe.SecretKey)
- session, err := sc.V1CheckoutSessions.Retrieve(c, sessionID, nil)
- if err == nil && session.PaymentStatus == stripe.CheckoutSessionPaymentStatusPaid {
- c.CurrentUser.IsSubscribed = true
- activeStatus := "active"
- c.CurrentUser.SubscriptionStatus = &activeStatus
- c.CurrentUser.GracePeriodStartedAt = nil
- c.CurrentUser.GracePeriodEndsAt = nil
- }
- }
- }
-
if c.Req.URL.Query().Get("payment_method_updated") == "1" && c.CurrentUser != nil {
sc := stripe.NewClient(config.Config.Stripe.SecretKey)
if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, c.CurrentUser); err != nil {
diff --git a/src/website/stripe.go b/src/website/stripe.go
index 1a69c442..57d08c6b 100644
--- a/src/website/stripe.go
+++ b/src/website/stripe.go
@@ -17,12 +17,22 @@ func init() {
stripe.Key = config.Config.Stripe.SecretKey
}
+const (
+ stripeWebhookStatusProcessing = "processing"
+ stripeWebhookStatusProcessed = "processed"
+ stripeWebhookStatusFailed = "failed"
+
+ stripeEventPaymentIntentProcessing = "payment_intent.processing"
+ stripeEventPaymentIntentRequiresAction = "payment_intent.requires_action"
+ stripeEventPaymentIntentPaymentFailed = "payment_intent.payment_failed"
+)
+
func beginStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) {
tag, err := conn.Exec(ctx, `
INSERT INTO stripe_webhook_event (event_id, event_type, status, last_error, updated_at, processed_at)
- VALUES ($1, $2, 'processing', NULL, NOW(), NULL)
+ VALUES ($1, $2, $3, NULL, NOW(), NULL)
ON CONFLICT (event_id) DO NOTHING
- `, event.ID, string(event.Type))
+ `, event.ID, string(event.Type), stripeWebhookStatusProcessing)
if err != nil {
return false, oops.New(err, "failed to insert stripe webhook event id")
}
@@ -40,20 +50,20 @@ func beginStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *strip
return false, oops.New(err, "failed to read Stripe webhook event state")
}
switch status {
- case "processed", "processing":
+ case stripeWebhookStatusProcessed, stripeWebhookStatusProcessing:
return false, nil
- case "failed":
+ case stripeWebhookStatusFailed:
tag, err = conn.Exec(ctx, `
UPDATE stripe_webhook_event
SET
event_type = $2,
- status = 'processing',
+ status = $3,
last_error = NULL,
updated_at = NOW(),
processed_at = NULL
WHERE event_id = $1
- AND status = 'failed'
- `, event.ID, string(event.Type))
+ AND status = $4
+ `, event.ID, string(event.Type), stripeWebhookStatusProcessing, stripeWebhookStatusFailed)
if err != nil {
return false, oops.New(err, "failed to mark stripe webhook event as processing")
}
@@ -94,9 +104,9 @@ func membershipEventCustomerID(event *stripe.Event) string {
return ""
}
switch event.Type {
- case stripe.EventTypePaymentIntentProcessing,
- stripe.EventTypePaymentIntentRequiresAction,
- stripe.EventTypePaymentIntentPaymentFailed:
+ case stripeEventPaymentIntentProcessing,
+ stripeEventPaymentIntentRequiresAction,
+ stripeEventPaymentIntentPaymentFailed:
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
return ""
@@ -159,9 +169,9 @@ func isMembershipPaymentIntentEvent(event *stripe.Event) bool {
return false
}
switch event.Type {
- case stripe.EventTypePaymentIntentProcessing,
- stripe.EventTypePaymentIntentRequiresAction,
- stripe.EventTypePaymentIntentPaymentFailed:
+ case stripeEventPaymentIntentProcessing,
+ stripeEventPaymentIntentRequiresAction,
+ stripeEventPaymentIntentPaymentFailed:
return true
default:
return false
@@ -230,6 +240,10 @@ func StripeWebhook(c *RequestContext) ResponseData {
if checkMembershipEventOrder(c, &event) {
handleMembershipGracePaymentRetryWebhook(c, sc, &event)
}
+ if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil {
+ c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed")
+ }
+ return ResponseData{StatusCode: http.StatusOK}
}
if isMembershipPaymentIntentEvent(&event) && !checkMembershipEventOrder(c, &event) {
@@ -295,12 +309,12 @@ func finishStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stri
_, err := conn.Exec(ctx, `
UPDATE stripe_webhook_event
SET
- status = 'processed',
+ status = $2,
last_error = NULL,
updated_at = NOW(),
processed_at = NOW()
WHERE event_id = $1
- `, event.ID)
+ `, event.ID, stripeWebhookStatusProcessed)
if err != nil {
return oops.New(err, "failed to mark stripe webhook event as processed")
}
@@ -310,11 +324,11 @@ func finishStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stri
_, err := conn.Exec(ctx, `
UPDATE stripe_webhook_event
SET
- status = 'failed',
- last_error = $2,
+ status = $2,
+ last_error = $3,
updated_at = NOW()
WHERE event_id = $1
- `, event.ID, processErr.Error())
+ `, event.ID, stripeWebhookStatusFailed, processErr.Error())
if err != nil {
return oops.New(err, "failed to mark stripe webhook event as failed")
}