From 44ec2ce6697d1cbed55def5a03172043b8821ce7 Mon Sep 17 00:00:00 2001 From: reece365 Date: Sun, 24 May 2026 16:31:54 -0500 Subject: [PATCH 01/15] Merged together ticket and membership Stripe webhook handlers; TODO write tests to ensure no conflicts Co-authored-by: Cursor --- src/config/config.go.example | 7 + src/config/types.go | 13 +- src/email/email.go | 136 ++++ src/hmnurl/hmnurl_test.go | 14 + src/hmnurl/urls.go | 24 + ...026-03-25T155929Z_AddIsSubscribedToUser.go | 47 ++ .../2026-03-25T160033Z_AddStripeToUser.go | 45 ++ ...-25T160121Z_AddDetailedSubscriptionInfo.go | 63 ++ ...6-03-25T160441Z_AddFeeInfoToUserPayment.go | 45 ++ ...2026-03-25T162427Z_AddThankYouEmailSent.go | 43 ++ src/models/payment.go | 17 + src/models/user.go | 10 + src/templates/img/heart.svg | 3 + src/templates/mapping.go | 3 + src/templates/src/email_payment_failed.html | 28 + .../src/email_subscription_cancelled.html | 19 + src/templates/src/email_thank_you.html | 27 + src/templates/src/hsf_membership.html | 6 +- src/templates/src/manage_subscription.html | 177 +++++ src/templates/types.go | 3 + src/website/routes.go | 4 + src/website/stripe.go | 238 ++++-- src/website/stripe_tickets.go | 61 ++ src/website/stripe_webhook_test.go | 21 + src/website/subscriptions.go | 681 ++++++++++++++++++ 25 files changed, 1684 insertions(+), 51 deletions(-) create mode 100644 src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go create mode 100644 src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.go create mode 100644 src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go create mode 100644 src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.go create mode 100644 src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.go create mode 100644 src/models/payment.go create mode 100644 src/templates/img/heart.svg create mode 100644 src/templates/src/email_payment_failed.html create mode 100644 src/templates/src/email_subscription_cancelled.html create mode 100644 src/templates/src/email_thank_you.html create mode 100644 src/templates/src/manage_subscription.html create mode 100644 src/website/stripe_tickets.go create mode 100644 src/website/stripe_webhook_test.go create mode 100644 src/website/subscriptions.go diff --git a/src/config/config.go.example b/src/config/config.go.example index 47566729..45c3ceb6 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -103,6 +103,13 @@ var Config = HMNConfig{ }, Stripe: StripeConfig{ SecretKey: "", + PublishableKey: "", WebhookSecret: "", + // Default USD membership price (price_... from Stripe Dashboard / API). + PriceID: "price_...", + // Optional other membership prices, e.g. EUR. First entry powers the EUR option on /foundation/membership/manage. + MembershipAlternatePriceIDs: []string{ + // "price_...", + }, }, } diff --git a/src/config/types.go b/src/config/types.go index 7f0cd0c1..051b9ec7 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -135,8 +135,17 @@ type PostmarkConfig struct { } type StripeConfig struct { - SecretKey string - WebhookSecret string + SecretKey string + PublishableKey string + WebhookSecret string + + // PriceID is the default subscription price used when creating a membership Checkout Session. + PriceID string + + // MembershipAlternatePriceIDs lists any other Stripe price IDs used for membership (e.g. a + // second currency). The webhook dispatcher treats PriceID ∪ MembershipAlternatePriceIDs as + // belonging to the subscription flow. + MembershipAlternatePriceIDs []string } func init() { diff --git a/src/email/email.go b/src/email/email.go index bee14c48..f330b799 100644 --- a/src/email/email.go +++ b/src/email/email.go @@ -167,6 +167,142 @@ func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername strin return nil } +type ThankYouEmailData struct { + Name string + HomepageUrl string + ManageSubscriptionUrl string + RenewalDate string + Amount string +} + +func SendThankYouEmail( + toAddress string, + toName string, + renewalDate *time.Time, + amount string, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Thank you email").End() + + renewalDateStr := "" + if renewalDate != nil { + renewalDateStr = renewalDate.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_thank_you.html", ThankYouEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), + RenewalDate: renewalDateStr, + Amount: amount, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Thank you!", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + +type SubscriptionCancelledEmailData struct { + Name string + HomepageUrl string + ExpirationDate string +} + +func SendSubscriptionCancelledEmail( + toAddress string, + toName string, + expirationDate *time.Time, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Subscription cancelled email").End() + + expirationDateStr := "" + if expirationDate != nil && !expirationDate.IsZero() { + expirationDateStr = expirationDate.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_subscription_cancelled.html", SubscriptionCancelledEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ExpirationDate: expirationDateStr, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Membership cancelled", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + +type PaymentFailedEmailData struct { + Name string + HomepageUrl string + ManageSubscriptionUrl string + Amount string + NextAttemptDate string +} + +func SendPaymentFailedEmail( + toAddress string, + toName string, + amount string, + nextAttemptDate *time.Time, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Payment failed email").End() + + nextAttemptDateStr := "" + if nextAttemptDate != nil && !nextAttemptDate.IsZero() { + nextAttemptDateStr = nextAttemptDate.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_payment_failed.html", PaymentFailedEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), + Amount: amount, + NextAttemptDate: nextAttemptDateStr, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Payment failed", 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/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index da5519dc..5341b8ae 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -538,6 +538,20 @@ func AssertRegexNoMatch(t *testing.T, fullUrl string, regex *regexp.Regexp) { assert.Nilf(t, match, "Url matched regex: [%s] vs [%s]", requestPath, regex.String()) } +func TestFoundationSubscriptionBuildUrls(t *testing.T) { + defer func() { + SetGlobalBaseUrl(config.Config.BaseUrl) + }() + SetGlobalBaseUrl("http://handmade.test") + isTest = true + + AssertRegexMatch(t, BuildHSFMembership(), RegexHSFMembership, nil) + AssertRegexMatch(t, BuildSubscriptionManage(), RegexSubscriptionManage, nil) + AssertRegexMatch(t, BuildSubscriptionSubscribe(), RegexSubscriptionSubscribe, nil) + AssertRegexMatch(t, BuildSubscriptionCancel(), RegexSubscriptionCancel, nil) + AssertRegexMatch(t, BuildSubscriptionResume(), RegexSubscriptionResume, nil) +} + func TestThingsThatDontNeedCoverage(t *testing.T) { // look the other way ಠ_ಠ BuildPodcastEpisodeFile("foo") diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 49301b53..a6ef2885 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -1083,6 +1083,30 @@ func BuildHSFMembership() string { return Url("/foundation/membership", nil) } +var RegexSubscriptionManage = regexp.MustCompile(`^/foundation/membership/manage$`) + +func BuildSubscriptionManage() string { + return Url("/foundation/membership/manage", nil) +} + +var RegexSubscriptionSubscribe = regexp.MustCompile(`^/foundation/membership/subscribe$`) + +func BuildSubscriptionSubscribe() string { + return Url("/foundation/membership/subscribe", nil) +} + +var RegexSubscriptionCancel = regexp.MustCompile(`^/foundation/membership/cancel$`) + +func BuildSubscriptionCancel() string { + return Url("/foundation/membership/cancel", nil) +} + +var RegexSubscriptionResume = regexp.MustCompile(`^/foundation/membership/resume$`) + +func BuildSubscriptionResume() string { + return Url("/foundation/membership/resume", nil) +} + /* * Perf */ diff --git a/src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go b/src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go new file mode 100644 index 00000000..7dc9fdde --- /dev/null +++ b/src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddIsSubscribedToUser{}) +} + +type AddIsSubscribedToUser struct{} + +func (m AddIsSubscribedToUser) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 15, 59, 29, 0, time.UTC)) +} + +func (m AddIsSubscribedToUser) Name() string { + return "AddIsSubscribedToUser" +} + +func (m AddIsSubscribedToUser) Description() string { + return "Add is_subscribed column to hmn_user table" +} + +func (m AddIsSubscribedToUser) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE hmn_user + ADD COLUMN is_subscribed BOOLEAN NOT NULL DEFAULT false; + `, + ) + return err +} + +func (m AddIsSubscribedToUser) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE hmn_user + DROP COLUMN is_subscribed; + `, + ) + return err +} diff --git a/src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.go b/src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.go new file mode 100644 index 00000000..b8060105 --- /dev/null +++ b/src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.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(AddStripeToUser{}) +} + +type AddStripeToUser struct{} + +func (m AddStripeToUser) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 0, 33, 0, time.UTC)) +} + +func (m AddStripeToUser) Name() string { + return "AddStripeToUser" +} + +func (m AddStripeToUser) Description() string { + return "Add Stripe customer and subscription IDs to user table" +} + +func (m AddStripeToUser) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN stripe_customer_id TEXT, + ADD COLUMN stripe_subscription_id TEXT + `) + return err +} + +func (m AddStripeToUser) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN stripe_customer_id, + DROP COLUMN stripe_subscription_id + `) + return err +} diff --git a/src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go b/src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go new file mode 100644 index 00000000..108143bd --- /dev/null +++ b/src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go @@ -0,0 +1,63 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddDetailedSubscriptionInfo{}) +} + +type AddDetailedSubscriptionInfo struct{} + +func (m AddDetailedSubscriptionInfo) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 1, 21, 0, time.UTC)) +} + +func (m AddDetailedSubscriptionInfo) Name() string { + return "AddDetailedSubscripitionInfo" +} + +func (m AddDetailedSubscriptionInfo) Description() string { + return "Add detailed subscription fields to user table and create user_payment table" +} + +func (m AddDetailedSubscriptionInfo) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN subscription_status TEXT, + ADD COLUMN current_period_end TIMESTAMP WITH TIME ZONE, + ADD COLUMN cancel_at_period_end BOOLEAN NOT NULL DEFAULT false; + + CREATE TABLE user_payment ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES hmn_user(id) ON DELETE CASCADE, + stripe_invoice_id TEXT UNIQUE, + amount_cents INTEGER NOT NULL, + currency TEXT NOT NULL, + payment_method_type TEXT, + card_last4 TEXT, + card_brand TEXT, + paid_at TIMESTAMP WITH TIME ZONE NOT NULL + ); + + CREATE INDEX idx_user_payment_user_id ON user_payment(user_id); + `) + return err +} + +func (m AddDetailedSubscriptionInfo) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + DROP TABLE user_payment; + + ALTER TABLE hmn_user + DROP COLUMN subscription_status, + DROP COLUMN current_period_end, + DROP COLUMN cancel_at_period_end; + `) + return err +} diff --git a/src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.go b/src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.go new file mode 100644 index 00000000..646ef474 --- /dev/null +++ b/src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.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(AddFeeInfoToUserPayment{}) +} + +type AddFeeInfoToUserPayment struct{} + +func (m AddFeeInfoToUserPayment) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 4, 41, 0, time.UTC)) +} + +func (m AddFeeInfoToUserPayment) Name() string { + return "AddFeeInfoToUserPayment" +} + +func (m AddFeeInfoToUserPayment) Description() string { + return "Add stripe_fee_cents and net_amount_cents to user_payment table" +} + +func (m AddFeeInfoToUserPayment) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE user_payment + ADD COLUMN stripe_fee_cents INTEGER, + ADD COLUMN net_amount_cents INTEGER; + `) + return err +} + +func (m AddFeeInfoToUserPayment) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE user_payment + DROP COLUMN stripe_fee_cents, + DROP COLUMN net_amount_cents; + `) + return err +} diff --git a/src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.go b/src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.go new file mode 100644 index 00000000..e89e22bc --- /dev/null +++ b/src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.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(AddThankYouEmailSent{}) +} + +type AddThankYouEmailSent struct{} + +func (m AddThankYouEmailSent) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 24, 27, 0, time.UTC)) +} + +func (m AddThankYouEmailSent) Name() string { + return "AddThankYouEmailSent" +} + +func (m AddThankYouEmailSent) Description() string { + return "Add thank_you_email_sent column to hmn_user table" +} + +func (m AddThankYouEmailSent) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN thank_you_email_sent BOOLEAN NOT NULL DEFAULT false; + `) + return err +} + +func (m AddThankYouEmailSent) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN thank_you_email_sent; + `) + return err +} diff --git a/src/models/payment.go b/src/models/payment.go new file mode 100644 index 00000000..b43cbdb1 --- /dev/null +++ b/src/models/payment.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type UserPayment struct { + ID int `db:"id"` + UserID int `db:"user_id"` + StripeInvoiceID *string `db:"stripe_invoice_id"` + AmountCents int `db:"amount_cents"` + Currency string `db:"currency"` + PaymentMethodType *string `db:"payment_method_type"` + CardLast4 *string `db:"card_last4"` + CardBrand *string `db:"card_brand"` + PaidAt time.Time `db:"paid_at"` + StripeFeeCents *int `db:"stripe_fee_cents"` + NetAmountCents *int `db:"net_amount_cents"` +} diff --git a/src/models/user.go b/src/models/user.go index 4b55915c..60f0a2f7 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -46,6 +46,16 @@ type User struct { DiscordSaveShowcase bool `db:"discord_save_showcase"` DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"` + IsSubscribed bool `db:"is_subscribed"` + + StripeCustomerID *string `db:"stripe_customer_id"` + StripeSubscriptionID *string `db:"stripe_subscription_id"` + + SubscriptionStatus *string `db:"subscription_status"` + CurrentPeriodEnd *time.Time `db:"current_period_end"` + CancelAtPeriodEnd bool `db:"cancel_at_period_end"` + ThankYouEmailSent bool `db:"thank_you_email_sent"` + MarkedAllReadAt time.Time `db:"marked_all_read_at"` // Non-db fields, to be filled in by fetch helpers diff --git a/src/templates/img/heart.svg b/src/templates/img/heart.svg new file mode 100644 index 00000000..af022f4e --- /dev/null +++ b/src/templates/img/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/templates/mapping.go b/src/templates/mapping.go index c5e06741..6b45f70a 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -262,6 +262,9 @@ func UserToTemplate(u *models.User) User { DiscordSaveShowcase: u.DiscordSaveShowcase, DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete, + IsSubscribed: u.IsSubscribed, + CancelAtPeriodEnd: u.CancelAtPeriodEnd, + IsEduTester: u.CanSeeUnpublishedEducationContent(), IsEduAuthor: u.CanAuthorEducation(), diff --git a/src/templates/src/email_payment_failed.html b/src/templates/src/email_payment_failed.html new file mode 100644 index 00000000..e9c86ce2 --- /dev/null +++ b/src/templates/src/email_payment_failed.html @@ -0,0 +1,28 @@ +

+ Hello {{ .Name }}, +

+

+ We were unable to process your recurring donation payment to Handmade + Network. +

+{{ if .Amount }} +

+ The charge of {{ .Amount }} to your payment method on file was declined. +

+{{ end }} +

+ Please update your payment details to keep your membership active. You can manage your membership at {{ .ManageSubscriptionUrl }}. +

+{{ if .NextAttemptDate }} +

+ Stripe will automatically retry the charge on {{ .NextAttemptDate }}. If the payment continues to fail, your + membership may be cancelled. +

+{{ else }} +

+ If the payment issue is not resolved, your membership may be cancelled. +

+{{ end }} +

Thanks,
+ The Handmade Network staff.

\ No newline at end of file diff --git a/src/templates/src/email_subscription_cancelled.html b/src/templates/src/email_subscription_cancelled.html new file mode 100644 index 00000000..3ebf6a23 --- /dev/null +++ b/src/templates/src/email_subscription_cancelled.html @@ -0,0 +1,19 @@ +

+ Hello {{ .Name }}, +

+

+ Your recurring donation to Handmade Network has been cancelled. +

+{{ if .ExpirationDate }} +

+ You will continue to have access to your membership benefits until {{ .ExpirationDate }}. +

+{{ end }} +

+ We're sorry to see you go, but thank you for your support! +

+

+ You can always find us at {{ .HomepageUrl }}. +

+

Thanks,
+ The Handmade Network staff.

\ No newline at end of file diff --git a/src/templates/src/email_thank_you.html b/src/templates/src/email_thank_you.html new file mode 100644 index 00000000..2a140ed8 --- /dev/null +++ b/src/templates/src/email_thank_you.html @@ -0,0 +1,27 @@ +

+ Hello {{ .Name }}, +

+

+ Thank you for supporting Handmade Network with your recurring donation! +

+ +{{ if and .RenewalDate .Amount }} +

+ Your payment method on file will automatically be charged again for {{ .Amount }} on {{ .RenewalDate }}. +

+{{ else if .RenewalDate }} +

+ Your payment method on file will automatically be charged again on {{ .RenewalDate }}. +

+{{ else if .Amount }} +

+ Your donation amount is {{ .Amount }}. +

+{{ end }} + +

+ You can cancel or manage your membership any time at {{ + .ManageSubscriptionUrl }}. +

+

Thanks,
+ The Handmade Network staff.

\ No newline at end of file diff --git a/src/templates/src/hsf_membership.html b/src/templates/src/hsf_membership.html index 0069fbb2..3d30a36f 100644 --- a/src/templates/src/hsf_membership.html +++ b/src/templates/src/hsf_membership.html @@ -13,9 +13,9 @@

Ongoing membership

  • A regular members-only newsletter
  • More to come!
  • -
    - Memberships are not yet available. We look forward to launching them in the near future. -
    +

    + Manage your membership +

    {{ end }} diff --git a/src/templates/src/manage_subscription.html b/src/templates/src/manage_subscription.html new file mode 100644 index 00000000..46c48606 --- /dev/null +++ b/src/templates/src/manage_subscription.html @@ -0,0 +1,177 @@ +{{ 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 .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 }} +

    +
    + + {{ 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

    +
      +
    • +
      {{ svg "discord" }}
      +
      +
      Discord Badge & Role
      +
      Show off your support with a special role and badge on our Discord.
      +
      +
    • +
    • +
      {{ svg "heart" }}
      +
      +
      Support Handmade
      +
      Help fund our mission to put the software industry back on the right track.
      +
      +
    • +
    • +
      {{ svg "arrow-right-up" }}
      +
      +
      And More
      +
      Get access to private channels and future supporter-only features.
      +
      +
    • +
    +
    + +
    + + {{ 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 }} +
    + {{ csrftoken .Session }} + +
    + {{ else }} +
    + {{ csrftoken .Session }} + + +
    + {{ end }} +
    +
    + + +{{ end }} \ No newline at end of file diff --git a/src/templates/types.go b/src/templates/types.go index 976d15e5..1bf423cf 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -270,6 +270,9 @@ type User struct { DiscordSaveShowcase bool DiscordDeleteSnippetOnMessageDelete bool + IsSubscribed bool + CancelAtPeriodEnd bool + IsEduTester bool IsEduAuthor bool diff --git a/src/website/routes.go b/src/website/routes.go index 75afde16..584156a5 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -151,6 +151,10 @@ 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.POST(hmnurl.RegexSubscriptionSubscribe, needsAuth(csrfMiddleware(SubscriptionSubscribe))) + hmnOnly.POST(hmnurl.RegexSubscriptionCancel, needsAuth(csrfMiddleware(SubscriptionCancel))) + hmnOnly.POST(hmnurl.RegexSubscriptionResume, needsAuth(csrfMiddleware(SubscriptionResume))) hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine) hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions) diff --git a/src/website/stripe.go b/src/website/stripe.go index 65d0728c..7e395305 100644 --- a/src/website/stripe.go +++ b/src/website/stripe.go @@ -1,23 +1,23 @@ package website import ( + "context" "encoding/json" "io" "net/http" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" - "git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/oops" "github.com/stripe/stripe-go/v84" "github.com/stripe/stripe-go/v84/webhook" ) func init() { - // Use the global client stripe.Key = config.Config.Stripe.SecretKey } +// StripeWebhook verifies and routes all Stripe webhook events. func StripeWebhook(c *RequestContext) ResponseData { const MaxBodyBytes = 65536 payload, err := io.ReadAll(io.LimitReader(c.Req.Body, MaxBodyBytes)) @@ -38,62 +38,208 @@ func StripeWebhook(c *RequestContext) ResponseData { c.Logger.Info().Str("type", string(event.Type)).Msg("received Stripe webhook") + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + 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") + return ResponseData{StatusCode: http.StatusOK} + } + + kind, err := classifyStripePriceIDs(c, c.Conn, priceIDs) + if err != nil { + 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) + case stripeWebhookKindMembership: + handleMembershipStripeEvent(c, sc, &event) + 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") + return ResponseData{StatusCode: http.StatusOK} + } +} + +type stripeWebhookKind int + +const ( + stripeWebhookKindUnknown stripeWebhookKind = iota + stripeWebhookKindTicket + stripeWebhookKindMembership +) + +func membershipPriceIDAllowed(priceID string) bool { + if priceID == "" { + return false + } + for _, id := range membershipWebhookPriceIDs() { + if priceID == id { + return true + } + } + return false +} + +func membershipWebhookPriceIDs() []string { + var out []string + seen := map[string]struct{}{} + add := func(id string) { + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + add(config.Config.Stripe.PriceID) + for _, id := range config.Config.Stripe.MembershipAlternatePriceIDs { + add(id) + } + return out +} + +func classifyStripePriceIDs(ctx context.Context, conn db.ConnOrTx, priceIDs []string) (stripeWebhookKind, error) { + if len(priceIDs) == 0 { + return stripeWebhookKindUnknown, nil + } + + membership := map[string]struct{}{} + for _, id := range membershipWebhookPriceIDs() { + membership[id] = struct{}{} + } + for _, id := range priceIDs { + if _, ok := membership[id]; ok { + return stripeWebhookKindMembership, nil + } + } + + ticketPriceIDs, err := db.QueryScalar[string](ctx, conn, ` + SELECT stripe_price_id + FROM ticket_metadata + WHERE stripe_price_id <> '' + `) + if err != nil { + return stripeWebhookKindUnknown, oops.New(err, "failed to load ticket price ids") + } + + known := make(map[string]struct{}, len(ticketPriceIDs)) + for _, id := range ticketPriceIDs { + known[id] = struct{}{} + } + for _, id := range priceIDs { + if _, ok := known[id]; ok { + return stripeWebhookKindTicket, nil + } + } + + return stripeWebhookKindUnknown, nil +} + +func stripePriceIDsForEvent(ctx context.Context, sc *stripe.Client, event *stripe.Event) ([]string, error) { switch event.Type { - case "checkout.session.completed": + case "checkout.session.completed", "checkout.session.expired", "checkout.session.async_payment_failed", "checkout.session.async_payment_succeeded": var session stripe.CheckoutSession - err := json.Unmarshal(event.Data.Raw, &session) - if err != nil { - return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return nil, oops.New(err, "bad checkout session JSON in stripe webhook") } - return stripeCheckoutSessionCompleted(c, &session) - case "checkout.session.expired": - var session stripe.CheckoutSession - err := json.Unmarshal(event.Data.Raw, &session) - if err != nil { - return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + return checkoutSessionPriceIDs(ctx, sc, &session) + + case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "customer.subscription.paused", "customer.subscription.resumed", "customer.subscription.trial_will_end": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, oops.New(err, "bad subscription JSON in stripe webhook") } - return stripeCheckoutSessionExpired(c, &session) - default: - return ResponseData{StatusCode: http.StatusOK} + return subscriptionPriceIDs(&sub), nil + + case "invoice.paid", "invoice.payment_failed", "invoice.payment_succeeded", "invoice.finalized", "invoice.upcoming": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + return nil, oops.New(err, "bad invoice JSON in stripe webhook") + } + return invoicePriceIDs(&inv), nil } + + return nil, nil } -func stripeCheckoutSessionCompleted(c *RequestContext, session *stripe.CheckoutSession) ResponseData { - // Different Stripe checkout flows may dispatch to different things. +func checkoutSessionPriceIDs(ctx context.Context, sc *stripe.Client, session *stripe.CheckoutSession) ([]string, error) { + var ids []string + seen := map[string]struct{}{} - ticket, err := hmndata.FetchTicket(c, c.Conn, hmndata.TicketQuery{ - StripeCheckoutSessionID: session.ID, + iter := sc.V1CheckoutSessions.ListLineItems(ctx, &stripe.CheckoutSessionListLineItemsParams{ + Session: stripe.String(session.ID), }) - if err == nil { - err := confirmStripeTicketPurchase(c, c.Conn, session, ticket) + var iterErr error + iter(func(item *stripe.LineItem, err error) bool { if err != nil { - return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to process ticket purchase")) - } - return c.JSONResponse(http.StatusOK, map[string]any{ - "confirmedTicket": ticket.ID, - }) - } else if err == db.NotFound { - // all good, move on to other checkout things - } else { - return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up ticket for checkout session")) - } - - c.Logger.Warn(). - Str("session ID", session.ID). - Str("payment intent ID", session.PaymentIntent.ID). - Msg("Unknown checkout session! What could it mean???") - return ResponseData{StatusCode: http.StatusOK} + iterErr = err + return false + } + if item == nil || item.Price == nil || item.Price.ID == "" { + return true + } + pid := item.Price.ID + if _, ok := seen[pid]; ok { + return true + } + seen[pid] = struct{}{} + ids = append(ids, pid) + return true + }) + if iterErr != nil { + return nil, oops.New(iterErr, "failed to list checkout session line items") + } + return ids, nil } -func stripeCheckoutSessionExpired(c *RequestContext, session *stripe.CheckoutSession) ResponseData { - // Different Stripe checkout flows may dispatch to different things. - - numDeleted, err := cancelPendingTicketsForCheckoutSession(c, c.Conn, session) - if err != nil { - return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to clear tickets for expired checkout session")) +func subscriptionPriceIDs(sub *stripe.Subscription) []string { + if sub == nil || sub.Items == nil { + return nil } + var ids []string + seen := map[string]struct{}{} + for _, item := range sub.Items.Data { + if item == nil || item.Price == nil || item.Price.ID == "" { + continue + } + pid := item.Price.ID + if _, ok := seen[pid]; ok { + continue + } + seen[pid] = struct{}{} + ids = append(ids, pid) + } + return ids +} - return c.JSONResponse(http.StatusOK, map[string]any{ - "ticketsDeleted": numDeleted, - }) +func invoicePriceIDs(inv *stripe.Invoice) []string { + if inv == nil || inv.Lines == nil { + return nil + } + var ids []string + seen := map[string]struct{}{} + for _, line := range inv.Lines.Data { + if line == nil || line.Pricing == nil || line.Pricing.PriceDetails == nil { + continue + } + pid := line.Pricing.PriceDetails.Price + if pid == "" { + continue + } + if _, ok := seen[pid]; ok { + continue + } + seen[pid] = struct{}{} + ids = append(ids, pid) + } + return ids } diff --git a/src/website/stripe_tickets.go b/src/website/stripe_tickets.go new file mode 100644 index 00000000..f5925695 --- /dev/null +++ b/src/website/stripe_tickets.go @@ -0,0 +1,61 @@ +package website + +import ( + "encoding/json" + "net/http" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmndata" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/stripe/stripe-go/v84" +) + +// handleTicketStripeEvent handles ticket Stripe webhook events. +func handleTicketStripeEvent(c *RequestContext, sc *stripe.Client, event *stripe.Event) ResponseData { + switch event.Type { + case "checkout.session.completed": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + } + return ticketCheckoutSessionCompleted(c, &session) + case "checkout.session.expired": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + } + return ticketCheckoutSessionExpired(c, &session) + } + return ResponseData{StatusCode: http.StatusOK} +} + +func ticketCheckoutSessionCompleted(c *RequestContext, session *stripe.CheckoutSession) ResponseData { + ticket, err := hmndata.FetchTicket(c, c.Conn, hmndata.TicketQuery{ + StripeCheckoutSessionID: session.ID, + }) + if err == db.NotFound { + c.Logger.Warn(). + Str("session ID", session.ID). + Msg("checkout session matched a ticket product but no ticket row exists") + return ResponseData{StatusCode: http.StatusOK} + } else if err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up ticket for checkout session")) + } + + if err := confirmStripeTicketPurchase(c, c.Conn, session, ticket); err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to process ticket purchase")) + } + return c.JSONResponse(http.StatusOK, map[string]any{ + "confirmedTicket": ticket.ID, + }) +} + +func ticketCheckoutSessionExpired(c *RequestContext, session *stripe.CheckoutSession) ResponseData { + numDeleted, err := cancelPendingTicketsForCheckoutSession(c, c.Conn, session) + if err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to clear tickets for expired checkout session")) + } + return c.JSONResponse(http.StatusOK, map[string]any{ + "ticketsDeleted": numDeleted, + }) +} diff --git a/src/website/stripe_webhook_test.go b/src/website/stripe_webhook_test.go new file mode 100644 index 00000000..16286698 --- /dev/null +++ b/src/website/stripe_webhook_test.go @@ -0,0 +1,21 @@ +package website + +import ( + "testing" + + "git.handmade.network/hmn/hmn/src/hmnurl" + "github.com/stretchr/testify/assert" +) + +// Routing regression: info page and subscription manage must not share the same path. +func TestFoundationMembershipVsManageRoutes(t *testing.T) { + assert.True(t, hmnurl.RegexHSFMembership.MatchString("/foundation/membership")) + assert.False(t, hmnurl.RegexHSFMembership.MatchString("/foundation/membership/manage")) + + assert.True(t, hmnurl.RegexSubscriptionManage.MatchString("/foundation/membership/manage")) + assert.False(t, hmnurl.RegexSubscriptionManage.MatchString("/foundation/membership")) +} + +func TestStripeWebhookIsTheOnlyStripeEndpoint(t *testing.T) { + assert.True(t, hmnurl.RegexStripeWebhook.MatchString("/stripe/webhook")) +} diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go new file mode 100644 index 00000000..6c474f80 --- /dev/null +++ b/src/website/subscriptions.go @@ -0,0 +1,681 @@ +package website + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "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/hmnurl" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/templates" + "github.com/stripe/stripe-go/v84" +) + +// handleMembershipStripeEvent handles membership Stripe webhook events. +func handleMembershipStripeEvent(c *RequestContext, sc *stripe.Client, event *stripe.Event) { + switch event.Type { + case "checkout.session.completed": + 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") + return + } + handleCheckoutSessionCompleted(c, sc, &session) + case "customer.subscription.created": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.subscription.created") + return + } + handleSubscriptionCreated(c, sc, &sub) + case "customer.subscription.updated": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.subscription.updated") + return + } + c.Logger.Trace().RawJSON("sub_json", event.Data.Raw).Msg("received subscription update JSON") + handleSubscriptionUpdated(c, sc, &sub) + case "customer.subscription.deleted": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.subscription.deleted") + return + } + handleSubscriptionDeleted(c, sc, &sub) + case "invoice.paid": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal invoice.paid") + return + } + handleInvoicePaid(c, sc, &inv) + case "invoice.payment_failed": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal invoice.payment_failed") + return + } + handleInvoicePaymentFailed(c, sc, &inv) + } +} + +type PaymentHistoryItem struct { + Date string + Amount string + CardInfo string +} + +type ManageSubscriptionTemplateData struct { + templates.BaseData + SubscribeUrl string + CancelSubscriptionUrl string + ResumeSubscriptionUrl string + CurrentCurrencySymbol string + CurrentAmount string + PaymentHistory []PaymentHistoryItem + CurrentPeriodEnd string + LastPaymentAmount string + LastPaymentMethod string + + DefaultMembershipPriceID string + EurMembershipPriceID string +} + +func SubscriptionManage(c *RequestContext) ResponseData { + + // 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" + + if c.CurrentUser != nil && c.CurrentUser.IsSubscribed { + payments, _ := db.Query[models.UserPayment](c, c.Conn, "SELECT $columns FROM user_payment WHERE user_id = $1 ORDER BY paid_at DESC", c.CurrentUser.ID) + if len(payments) > 0 { + if strings.EqualFold(payments[0].Currency, "eur") { + currentCurrencySymbol = "€" + } + currentAmount = fmt.Sprintf("%.2f", float64(payments[0].AmountCents)/100.0) + } + + for _, p := range payments { + sym := "$" + if strings.EqualFold(p.Currency, "eur") { + sym = "€" + } + card := "" + if p.CardBrand != nil { + card = strings.ToUpper(*p.CardBrand) + } + if p.CardLast4 != nil { + if card != "" { + card += " " + } + card += "•••• " + *p.CardLast4 + } + history = append(history, PaymentHistoryItem{ + Date: p.PaidAt.UTC().Format("Jan 2, 2006"), + Amount: fmt.Sprintf("%s%.2f", sym, float64(p.AmountCents)/100.0), + CardInfo: card, + }) + } + } + + currentPeriodEnd := "" + if c.CurrentUser != nil && c.CurrentUser.CurrentPeriodEnd != nil { + currentPeriodEnd = c.CurrentUser.CurrentPeriodEnd.UTC().Format("Jan 2, 2006") + } + + lastAmount := "" + lastMethod := "" + if len(history) > 0 { + lastAmount = history[0].Amount + lastMethod = history[0].CardInfo + } + + eurPriceID := "" + if alts := config.Config.Stripe.MembershipAlternatePriceIDs; len(alts) > 0 { + eurPriceID = alts[0] + } + + var res ResponseData + res.MustWriteTemplate("manage_subscription.html", ManageSubscriptionTemplateData{ + BaseData: getBaseData(c, "Manage Membership", nil), + SubscribeUrl: hmnurl.BuildSubscriptionSubscribe(), + CancelSubscriptionUrl: hmnurl.BuildSubscriptionCancel(), + ResumeSubscriptionUrl: hmnurl.BuildSubscriptionResume(), + CurrentCurrencySymbol: currentCurrencySymbol, + CurrentAmount: currentAmount, + PaymentHistory: history, + CurrentPeriodEnd: currentPeriodEnd, + LastPaymentAmount: lastAmount, + LastPaymentMethod: lastMethod, + 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) + } + + if err := c.Req.ParseForm(); err != nil { + return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse subscribe form")) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + priceID := strings.TrimSpace(c.Req.FormValue("price_id")) + if priceID == "" { + priceID = config.Config.Stripe.PriceID + } + if !membershipPriceIDAllowed(priceID) { + return c.RejectRequest("Membership billing is not configured. Set Stripe.PriceID (and optional MembershipAlternatePriceIDs) in config.") + } + + params := &stripe.CheckoutSessionCreateParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + SuccessURL: stripe.String(hmnurl.BuildSubscriptionManage() + "?session_id={CHECKOUT_SESSION_ID}"), + CancelURL: stripe.String(hmnurl.BuildSubscriptionManage()), + ClientReferenceID: stripe.String(strconv.Itoa(c.CurrentUser.ID)), + LineItems: []*stripe.CheckoutSessionCreateLineItemParams{ + { + Price: stripe.String(priceID), + Quantity: stripe.Int64(1), + }, + }, + CustomerEmail: stripe.String(c.CurrentUser.Email), + SubscriptionData: &stripe.CheckoutSessionCreateSubscriptionDataParams{ + Metadata: map[string]string{"user_id": strconv.Itoa(c.CurrentUser.ID)}, + }, + } + + p, err := sc.V1Prices.Retrieve(c, priceID, nil) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get requested price")) + } + targetCurrency := p.Currency + targetAmount := p.UnitAmount + + if c.CurrentUser.StripeCustomerID != nil { + params.Customer = stripe.String(*c.CurrentUser.StripeCustomerID) + params.CustomerEmail = nil + + listParams := &stripe.CheckoutSessionListParams{ + Customer: stripe.String(*c.CurrentUser.StripeCustomerID), + Status: stripe.String(string(stripe.CheckoutSessionStatusOpen)), + } + + iter := sc.V1CheckoutSessions.List(c, listParams) + var existingURL string + var outdatedSessionID string + var listErr error + iter(func(session *stripe.CheckoutSession, err error) bool { + listErr = err + if err == nil && session != nil { + if session.Currency == targetCurrency && session.AmountTotal == targetAmount { + existingURL = session.URL + } else { + outdatedSessionID = session.ID + } + } + return false // pull only the first item + }) + + if listErr != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(listErr, "failed to list checkout sessions")) + } + + if existingURL != "" { + return c.Redirect(existingURL, http.StatusSeeOther) + } else if outdatedSessionID != "" { + _, _ = sc.V1CheckoutSessions.Expire(c, outdatedSessionID, nil) + } + } + + s, err := sc.V1CheckoutSessions.Create(c, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create checkout session")) + } + + return c.Redirect(s.URL, http.StatusSeeOther) +} + +func SubscriptionCancel(c *RequestContext) ResponseData { + if c.CurrentUser.StripeSubscriptionID == nil { + return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + params := &stripe.SubscriptionUpdateParams{ + CancelAtPeriodEnd: stripe.Bool(true), + } + _, err := sc.V1Subscriptions.Update(c, *c.CurrentUser.StripeSubscriptionID, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to cancel subscription")) + } + + _, err = c.Conn.Exec(c, "UPDATE hmn_user SET cancel_at_period_end = true WHERE id = $1", c.CurrentUser.ID) + if err != nil { + logging.Error().Err(err).Msg("failed to update user cancel_at_period_end optimistically") + } + + return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) +} + +func SubscriptionResume(c *RequestContext) ResponseData { + if c.CurrentUser.StripeSubscriptionID == nil { + return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) + } + + if c.CurrentUser.CurrentPeriodEnd == nil || c.CurrentUser.CurrentPeriodEnd.Before(time.Now()) { + return c.Redirect(hmnurl.BuildSubscriptionSubscribe(), http.StatusSeeOther) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + params := &stripe.SubscriptionUpdateParams{ + CancelAtPeriodEnd: stripe.Bool(false), + } + _, err := sc.V1Subscriptions.Update(c, *c.CurrentUser.StripeSubscriptionID, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to resume subscription")) + } + + _, err = c.Conn.Exec(c, "UPDATE hmn_user SET cancel_at_period_end = false WHERE id = $1", c.CurrentUser.ID) + if err != nil { + logging.Error().Err(err).Msg("failed to update user cancel_at_period_end optimistically") + } + + return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) +} + + + +func handleSubscriptionCreated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { + if uidStr, ok := sub.Metadata["user_id"]; ok { + if uid, err := strconv.Atoi(uidStr); err == nil { + renewalDate := getSubscriptionPeriodEnd(sub) + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = $3, + current_period_end = $4 + WHERE id = $5 + `, sub.Customer.ID, sub.ID, sub.Status, renewalDate, uid) + if err != nil { + logging.Error().Err(err).Int("userID", uid).Msg("failed to handle subscription.created") + } + } + } +} + +func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, session *stripe.CheckoutSession) { + if session.ClientReferenceID == "" { + logging.Error().Msg("checkout.session.completed missing client_reference_id") + return + } + + userID, err := strconv.Atoi(session.ClientReferenceID) + if err != nil { + logging.Error().Err(err).Str("client_reference_id", session.ClientReferenceID).Msg("invalid client_reference_id") + return + } + + user, err := db.QueryOne[models.User](c, c.Conn, ` + UPDATE hmn_user + SET + is_subscribed = true, + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = 'active', + cancel_at_period_end = false + WHERE id = $3 + RETURNING $columns + `, session.Customer.ID, session.Subscription.ID, userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to update user subscription status") + } else { + logging.Info().Int("userID", userID).Msg("user subscription linked, attempting thank you email") + attemptThankYouEmail(c, user.ID, session.AmountTotal, session.Currency) + } +} + +func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { + renewalDate := getSubscriptionPeriodEnd(sub) + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", sub.Customer.ID) + if err == db.NotFound { + if uidStr, ok := sub.Metadata["user_id"]; ok { + if uid, subErr := strconv.Atoi(uidStr); subErr == nil { + user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", uid) + } + } + } + + if err != nil { + logging.Error().Err(err).Str("customerID", sub.Customer.ID).Msg("failed to fetch user for subscription update") + return + } + + isCancelling := sub.CancelAtPeriodEnd || (sub.CancelAt > 0 && sub.Status != "canceled") + + logging.Info(). + Int("userID", user.ID). + Str("status", string(sub.Status)). + Bool("cancelAtPeriodEnd", sub.CancelAtPeriodEnd). + Int64("cancelAt", sub.CancelAt). + Bool("isCancelling", isCancelling). + Msg("updating user subscription from webhook") + + _, 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 = ($3 = 'active' OR $3 = 'trialing'), + current_period_end = $5 + WHERE id = $6 + `, sub.Customer.ID, sub.ID, sub.Status, isCancelling, renewalDate, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook") + } + + if isCancelling && !user.CancelAtPeriodEnd { + var expirationDate *time.Time + if sub.CancelAt > 0 { + t := time.Unix(sub.CancelAt, 0) + expirationDate = &t + } else if renewalDate != nil { + expirationDate = renewalDate + } + logging.Info().Int("userID", user.ID).Msg("sending subscription cancellation initiation email") + err = email.SendSubscriptionCancelledEmail(user.Email, user.BestName(), expirationDate, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send cancellation initiation email") + } + } +} + +func handleSubscriptionDeleted(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", sub.Customer.ID) + if err == db.NotFound { + if uidStr, ok := sub.Metadata["user_id"]; ok { + if uid, subErr := strconv.Atoi(uidStr); subErr == nil { + user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", uid) + } + } + } + + if err != nil { + logging.Error().Err(err).Str("customerID", sub.Customer.ID).Msg("failed to fetch user for subscription deletion") + return + } + + _, 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 WHERE id = $1", user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to handle subscription deletion") + return + } + + logging.Info().Int("userID", user.ID).Msg("user subscription deactivated") + + if !sub.CancelAtPeriodEnd { + err = email.SendSubscriptionCancelledEmail(user.Email, user.BestName(), nil, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send cancellation email") + } + } +} + +func handleInvoicePaid(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice) { + if inv.Customer == nil { + return + } + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", inv.Customer.ID) + if err == db.NotFound { + } + if err != nil { + logging.Error().Err(err).Str("customerID", inv.Customer.ID).Msg("failed to fetch user for invoice.paid") + return + } + + details := getPaymentDetails(c, sc, inv) + + _, err = c.Conn.Exec(c, ` + INSERT INTO user_payment (user_id, stripe_invoice_id, amount_cents, currency, payment_method_type, card_last4, card_brand, paid_at, stripe_fee_cents, net_amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (stripe_invoice_id) DO UPDATE SET + payment_method_type = EXCLUDED.payment_method_type, + card_last4 = EXCLUDED.card_last4, + card_brand = EXCLUDED.card_brand, + stripe_fee_cents = EXCLUDED.stripe_fee_cents, + net_amount_cents = EXCLUDED.net_amount_cents + `, user.ID, inv.ID, inv.AmountPaid, string(inv.Currency), details.methodType, details.last4, details.brand, time.Unix(inv.StatusTransitions.PaidAt, 0), details.feeCents, details.netCents) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to record user payment") + } + + 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) + if err == nil { + renewalDate := getSubscriptionPeriodEnd(sub) + _, err = c.Conn.Exec(c, "UPDATE hmn_user SET current_period_end = $1, is_subscribed = true 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") + } + } + } + + attemptThankYouEmail(c, user.ID, inv.AmountPaid, inv.Currency) +} + +func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice) { + if inv.Customer == nil { + return + } + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", inv.Customer.ID) + if err != nil { + logging.Error().Err(err).Str("customerID", inv.Customer.ID).Msg("failed to fetch user for invoice.payment_failed") + return + } + + amountStr := "" + if inv.AmountDue > 0 { + curr := strings.ToUpper(string(inv.Currency)) + symbol := "$" + if curr != "USD" { + symbol = curr + " " + } + amountStr = fmt.Sprintf("%s%.2f", symbol, float64(inv.AmountDue)/100.0) + } + + var nextAttemptDate *time.Time + if inv.NextPaymentAttempt > 0 { + t := time.Unix(inv.NextPaymentAttempt, 0) + nextAttemptDate = &t + } + + logging.Info().Int("userID", user.ID).Str("invoiceID", inv.ID).Msg("sending payment failed email") + err = email.SendPaymentFailedEmail(user.Email, user.BestName(), amountStr, nextAttemptDate, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send payment failed email") + } +} + +func getSubscriptionPeriodEnd(sub *stripe.Subscription) *time.Time { + if sub != nil && sub.Items != nil && len(sub.Items.Data) > 0 { + t := time.Unix(sub.Items.Data[0].CurrentPeriodEnd, 0) + return &t + } + return nil +} + +type paymentDetails struct { + methodType *string + last4 *string + brand *string + feeCents *int + netCents *int +} + +func getPaymentDetails(c context.Context, sc *stripe.Client, inv *stripe.Invoice) paymentDetails { + var details paymentDetails + params := &stripe.InvoicePaymentListParams{ + Invoice: stripe.String(inv.ID), + } + params.AddExpand("data.payment.charge.balance_transaction") + params.AddExpand("data.payment.payment_intent.latest_charge") + + sc.V1InvoicePayments.List(c, params)(func(ip *stripe.InvoicePayment, err error) bool { + if err != nil || ip.Payment == nil { + return true + } + + var targetCharge *stripe.Charge + if ip.Payment.Charge != nil { + targetCharge = ip.Payment.Charge + } else if ip.Payment.PaymentIntent != nil && ip.Payment.PaymentIntent.LatestCharge != nil { + targetCharge = ip.Payment.PaymentIntent.LatestCharge + } + + if targetCharge == nil { + return true + } + + if targetCharge.PaymentMethodDetails != nil { + mt := string(targetCharge.PaymentMethodDetails.Type) + details.methodType = &mt + if targetCharge.PaymentMethodDetails.Card != nil { + l4 := targetCharge.PaymentMethodDetails.Card.Last4 + details.last4 = &l4 + b := string(targetCharge.PaymentMethodDetails.Card.Brand) + details.brand = &b + } else if targetCharge.PaymentMethodDetails.USBankAccount != nil { + l4 := targetCharge.PaymentMethodDetails.USBankAccount.Last4 + details.last4 = &l4 + b := targetCharge.PaymentMethodDetails.USBankAccount.BankName + details.brand = &b + } + } + + bt := targetCharge.BalanceTransaction + if bt == nil || (bt.Net == 0 && inv.AmountPaid != 0) { + retrieveParams := &stripe.ChargeRetrieveParams{} + retrieveParams.AddExpand("balance_transaction") + fullCharge, err := sc.V1Charges.Retrieve(c, targetCharge.ID, retrieveParams) + if err == nil && fullCharge.BalanceTransaction != nil && (fullCharge.BalanceTransaction.Net != 0 || inv.AmountPaid == 0) { + bt = fullCharge.BalanceTransaction + } else { + btListParams := &stripe.BalanceTransactionListParams{ + Source: stripe.String(targetCharge.ID), + } + sc.V1BalanceTransactions.List(c, btListParams)(func(item *stripe.BalanceTransaction, err error) bool { + if err == nil && (item.Net != 0 || inv.AmountPaid == 0) { + bt = item + return false + } + return true + }) + } + } + + if bt != nil { + fc := int(bt.Fee) + details.feeCents = &fc + nc := int(bt.Net) + details.netCents = &nc + } + + return false // Found a payment, stop iteration + }) + + return details +} + +func attemptThankYouEmail(c *RequestContext, userID int, amountCents int64, currency stripe.Currency) { + tx, err := c.Conn.Begin(c) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to begin transaction for thank you email") + return + } + defer tx.Rollback(c) + + user, err := db.QueryOne[models.User](c, tx, "SELECT $columns FROM hmn_user WHERE id = $1 FOR UPDATE", userID) + if err != nil { + if err != db.NotFound { + logging.Error().Err(err).Int("userID", userID).Msg("failed to query user for thank you email") + } + return + } + + // Only send if we have both pieces of info (active status and renewal date) and haven't sent it yet. + // We check that the renewal date is at least reasonably in the future to avoid race conditions + // where an old or "initiation" date is still in the DB. + shouldSend := false + if user.IsSubscribed && user.CurrentPeriodEnd != nil && user.CurrentPeriodEnd.After(time.Now().Add(24*time.Hour)) && !user.ThankYouEmailSent { + shouldSend = true + _, err = tx.Exec(c, "UPDATE hmn_user SET thank_you_email_sent = true WHERE id = $1", userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to update thank_you_email_sent flag") + return + } + } + + err = tx.Commit(c) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to commit transaction for thank you email") + return + } + + if shouldSend { + sendThankYouEmail(c, user, user.CurrentPeriodEnd, amountCents, currency) + } +} + +func sendThankYouEmail(c *RequestContext, user *models.User, renewalDate *time.Time, amountCents int64, currency stripe.Currency) { + amountStr := "" + if amountCents > 0 { + curr := strings.ToUpper(string(currency)) + symbol := "$" + if curr != "USD" { + symbol = curr + " " + } + amountStr = fmt.Sprintf("%s%.2f", symbol, float64(amountCents)/100.0) + } + + err := email.SendThankYouEmail(user.Email, user.BestName(), renewalDate, amountStr, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send thank you email") + } +} From c17ad86c2ab42b2e09b8c89c1db02cac40d89cc7 Mon Sep 17 00:00:00 2001 From: reece365 Date: Sun, 24 May 2026 16:56:23 -0500 Subject: [PATCH 02/15] Added basic tests (to be configured with more advanced fast-forward/grace period logic) --- src/admintools/adminsubscription.go | 336 ++++++++++++++++++++++++++++ src/admintools/admintools.go | 1 + 2 files changed, 337 insertions(+) create mode 100644 src/admintools/adminsubscription.go diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go new file mode 100644 index 00000000..679dd142 --- /dev/null +++ b/src/admintools/adminsubscription.go @@ -0,0 +1,336 @@ +package admintools + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + "git.handmade.network/hmn/hmn/src/auth" + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "github.com/google/uuid" + "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() + conn := db.NewConn() + defer conn.Close(ctx) + + 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: "ACH (US bank account)", + CreatePaymentMethod: func(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) { + return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ + Type: stripe.String("us_bank_account"), + USBankAccount: &stripe.PaymentMethodCreateUSBankAccountParams{ + AccountHolderType: stripe.String("individual"), + AccountType: stripe.String("checking"), + RoutingNumber: stripe.String("110000000"), + AccountNumber: stripe.String("000123456789"), + }, + BillingDetails: &stripe.PaymentMethodCreateBillingDetailsParams{ + Name: stripe.String("HMN ACH Test User"), + }, + }) + }, + }, + } + + 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) + 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) +} + +type subscriptionTestResult int + +const ( + subscriptionTestResultPass subscriptionTestResult = iota + subscriptionTestResultPending +) + +func runSubscriptionScenario(ctx context.Context, conn db.ConnOrTx, 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) + fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress) + + fmt.Printf("[2/6] Creating Stripe customer\n") + customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{ + Email: stripe.String(emailAddress), + Name: stripe.String(username), + 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("[3/6] Creating payment method (%s)\n", scenario.Name) + paymentMethod, err := scenario.CreatePaymentMethod(ctx, sc) + 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") + _, 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") + if updateErr := persistPendingVerificationState(ctx, conn, userID, customer.ID); updateErr != nil { + return subscriptionTestResultPass, updateErr + } + printSubscriptionData(ctx, conn, userID) + return subscriptionTestResultPending, nil + } + return subscriptionTestResultPass, err + } + + 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") + + subscription, err := sc.V1Subscriptions.Create(ctx, subscriptionParams) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" subscription_id=%s status=%s\n", subscription.ID, subscription.Status) + + 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, ` + UPDATE hmn_user + SET + is_subscribed = $1, + stripe_customer_id = $2, + stripe_subscription_id = $3, + subscription_status = $4, + current_period_end = $5, + cancel_at_period_end = $6 + WHERE id = $7 + `, isSubscribed, customer.ID, subscription.ID, subscription.Status, renewalDate, subscription.CancelAtPeriodEnd, userID) + if 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.StatusTransitions != nil && invoice.StatusTransitions.PaidAt > 0 { + paidAt := time.Unix(invoice.StatusTransitions.PaidAt, 0) + _, err = conn.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 + amount_cents = EXCLUDED.amount_cents, + currency = EXCLUDED.currency, + paid_at = EXCLUDED.paid_at + `, userID, invoice.ID, invoice.AmountPaid, string(invoice.Currency), paidAt) + if err != nil { + return subscriptionTestResultPass, err + } + } + + fmt.Printf("[6/6] Verifying and printing stored subscription data\n") + if err := validateStoredSubscriptionData(ctx, conn, userID, customer.ID, subscription.ID); err != nil { + return subscriptionTestResultPass, err + } + printSubscriptionData(ctx, conn, userID) + return subscriptionTestResultPass, nil +} + +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) + if err != nil { + return err + } + if user.StripeCustomerID == nil || *user.StripeCustomerID != customerID { + return fmt.Errorf("stored stripe_customer_id mismatch") + } + if user.StripeSubscriptionID == nil || *user.StripeSubscriptionID != subscriptionID { + return fmt.Errorf("stored stripe_subscription_id mismatch") + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus == "" { + return fmt.Errorf("stored subscription_status is empty") + } + return nil +} + +func createSubscriptionTestUser(ctx context.Context, conn db.ConnOrTx, username string) (int, string) { + emailAddress := uuid.New().String() + "@example.com" + hashedPassword := auth.HashPassword("password") + + var userID int + err := conn.QueryRow(ctx, ` + INSERT INTO hmn_user (username, email, password, date_joined, registration_ip, status) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, username, emailAddress, hashedPassword.String(), time.Now(), net.ParseIP("127.0.0.1"), models.UserStatusConfirmed).Scan(&userID) + if err != nil { + panic(err) + } + + return userID, emailAddress +} + +func getSubscriptionPeriodEndFromStripe(sub *stripe.Subscription) *time.Time { + if sub == nil || sub.Items == nil || len(sub.Items.Data) == 0 { + return nil + } + + t := time.Unix(sub.Items.Data[0].CurrentPeriodEnd, 0) + 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) + if err != nil { + panic(err) + } + + fmt.Printf("\nStored user subscription 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) + fmt.Printf(" stripe_customer_id: %s\n", stringOrEmpty(user.StripeCustomerID)) + fmt.Printf(" stripe_subscription_id: %s\n", stringOrEmpty(user.StripeSubscriptionID)) + fmt.Printf(" subscription_status: %s\n", stringOrEmpty(user.SubscriptionStatus)) + if user.CurrentPeriodEnd != nil { + fmt.Printf(" current_period_end: %s\n", user.CurrentPeriodEnd.UTC().Format(time.RFC3339)) + } else { + fmt.Printf(" current_period_end: \n") + } + fmt.Printf(" cancel_at_period_end: %v\n", user.CancelAtPeriodEnd) + + payments, err := db.Query[models.UserPayment](ctx, conn, ` + SELECT $columns + FROM user_payment + WHERE user_id = $1 + ORDER BY paid_at DESC + `, userID) + if err != nil { + panic(err) + } + + fmt.Printf("\nStored payment rows: %d\n", len(payments)) + for i, payment := range payments { + fmt.Printf(" [%d] invoice=%s amount_cents=%d currency=%s paid_at=%s\n", + i, + stringOrEmpty(payment.StripeInvoiceID), + payment.AmountCents, + payment.Currency, + payment.PaidAt.UTC().Format(time.RFC3339), + ) + } +} + +func stringOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} + +func isExpectedACHVerificationPending(err error) bool { + var stripeErr *stripe.Error + if errors.As(err, &stripeErr) { + return strings.Contains(stripeErr.Msg, "must be verified before they can be attached to a customer") + } + 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, ` + UPDATE hmn_user + SET + is_subscribed = false, + stripe_customer_id = $1, + subscription_status = 'pending_verification', + current_period_end = NULL, + cancel_at_period_end = false + WHERE id = $2 + `, customerID, userID) + return err +} diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index 7bb90234..4dacf884 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -645,4 +645,5 @@ func init() { addProjectCommands(adminCommand) addPostCommands(adminCommand) addEventCommands(adminCommand) + addSubscriptionCommands(adminCommand) } From d1e9d139a1eb86ec5dc45472731b587c961f52c6 Mon Sep 17 00:00:00 2001 From: reece365 Date: Sun, 24 May 2026 17:42:28 -0500 Subject: [PATCH 03/15] Added mimimum viable "grace period" support for ACH transactions --- src/admintools/adminsubscription.go | 350 +++++++++++++++++- src/config/config.go.example | 2 + src/config/types.go | 7 + src/email/email.go | 8 + ...5-24T170000Z_AddSubscriptionGracePeriod.go | 47 +++ src/models/user.go | 4 + src/templates/src/email_payment_failed.html | 6 + src/templates/src/manage_subscription.html | 9 + src/website/subscription_grace.go | 158 ++++++++ src/website/subscription_grace_job.go | 42 +++ src/website/subscription_grace_test.go | 82 ++++ src/website/subscriptions.go | 129 ++++++- src/website/website.go | 1 + 13 files changed, 814 insertions(+), 31 deletions(-) create mode 100644 src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go create mode 100644 src/website/subscription_grace.go create mode 100644 src/website/subscription_grace_job.go create mode 100644 src/website/subscription_grace_test.go diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go index 679dd142..9aec7145 100644 --- a/src/admintools/adminsubscription.go +++ b/src/admintools/adminsubscription.go @@ -14,6 +14,7 @@ import ( "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/website" "github.com/google/uuid" "github.com/spf13/cobra" "github.com/stripe/stripe-go/v84" @@ -43,6 +44,13 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) { conn := db.NewConn() defer conn.Close(ctx) + 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{ { @@ -58,20 +66,15 @@ func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) { }, { Name: "ACH (US bank account)", - CreatePaymentMethod: func(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) { - return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ - Type: stripe.String("us_bank_account"), - USBankAccount: &stripe.PaymentMethodCreateUSBankAccountParams{ - AccountHolderType: stripe.String("individual"), - AccountType: stripe.String("checking"), - RoutingNumber: stripe.String("110000000"), - AccountNumber: stripe.String("000123456789"), - }, - BillingDetails: &stripe.PaymentMethodCreateBillingDetailsParams{ - Name: stripe.String("HMN ACH Test User"), - }, - }) - }, + CreatePaymentMethod: createACHPaymentMethod, + }, + { + Name: "ACH grace expires after 2 week clock advance", + Run: runACHGraceExpiryScenario, + }, + { + Name: "ACH verification after 2 day clock advance", + Run: runACHVerificationAfterAdvanceScenario, }, } @@ -102,6 +105,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) } type subscriptionTestResult int @@ -111,20 +115,38 @@ const ( subscriptionTestResultPending ) +type achTestSetup struct { + userID int + customerID string + paymentMethodID string + testClockID string +} + func runSubscriptionScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) { + if scenario.Run != nil { + return scenario.Run(ctx, conn, sc) + } + return runCardOrACHScenario(ctx, conn, sc, scenario) +} + +func runCardOrACHScenario(ctx context.Context, conn db.ConnOrTx, 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) fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress) fmt.Printf("[2/6] Creating Stripe customer\n") - customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{ + 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 } @@ -153,9 +175,232 @@ func runSubscriptionScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.C return subscriptionTestResultPass, err } + return completeSubscription(ctx, conn, sc, userID, customer.ID, paymentMethod.ID) +} + +func runACHGraceExpiryScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client) (subscriptionTestResult, error) { + defer website.ClearSubscriptionNowForTests() + + fmt.Printf("[1/7] Creating Stripe test clock\n") + testClock, err := createTestClock(ctx, sc, "ach-grace-expiry") + 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) + + setup, err := setupACHPendingOnClock(ctx, conn, 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 { + return subscriptionTestResultPass, 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) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" clock frozen_time=%s\n", clockTime.UTC().Format(time.RFC3339)) + website.SetSubscriptionNowForTests(clockTime) + + fmt.Printf("[7/7] Expiring due grace periods and verifying final state\n") + expiredCount, err := website.ExpireSubscriptionGracePeriods(ctx, conn) + 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) + if err != nil { + return subscriptionTestResultPass, err + } + if user.IsSubscribed { + 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)) + } + if user.GraceAvailable { + return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace expiry") + } + + printSubscriptionData(ctx, conn, setup.userID) + return subscriptionTestResultPass, nil +} + +func runACHVerificationAfterAdvanceScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client) (subscriptionTestResult, error) { + defer website.ClearSubscriptionNowForTests() + + fmt.Printf("[1/8] Creating Stripe test clock\n") + testClock, err := createTestClock(ctx, sc, "ach-verify-after-advance") + 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) + + setup, err := setupACHPendingOnClock(ctx, conn, sc, testClock.ID) + if err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[5/8] Advancing test clock by 2 days (simulating microdeposit wait)\n") + clockTime, err := advanceTestClockBy(ctx, sc, testClock.ID, 2*24*time.Hour) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" clock frozen_time=%s\n", clockTime.UTC().Format(time.RFC3339)) + website.SetSubscriptionNowForTests(clockTime) + + fmt.Printf("[6/8] Triggering ACH verification via SetupIntent\n") + if err := verifyACHPaymentMethod(ctx, sc, setup.customerID, setup.paymentMethodID); err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[7/8] Attaching verified payment method and creating subscription\n") + _, err = sc.V1PaymentMethods.Attach(ctx, setup.paymentMethodID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(setup.customerID), + }) + if err != nil { + return subscriptionTestResultPass, fmt.Errorf("attach verified ACH payment method: %w", err) + } + + result, err := completeSubscription(ctx, conn, 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) + if err != nil { + return subscriptionTestResultPass, err + } + if !user.IsSubscribed { + 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 result, nil +} + +func setupACHPendingOnClock(ctx context.Context, conn db.ConnOrTx, 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) + fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress) + + fmt.Printf("[3/7] 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(testClockID), + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + }) + if err != nil { + return nil, err + } + fmt.Printf(" customer_id=%s\n", customer.ID) + + fmt.Printf("[4/7] Creating ACH payment method and reaching pending verification\n") + paymentMethod, err := createACHPaymentMethod(ctx, sc) + if err != nil { + return nil, err + } + fmt.Printf(" payment_method_id=%s\n", paymentMethod.ID) + + _, err = sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if err == nil { + return nil, fmt.Errorf("expected ACH attach to require verification") + } + 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") + if err := persistPendingVerificationState(ctx, conn, userID, customer.ID); err != nil { + return nil, err + } + printSubscriptionData(ctx, conn, userID) + + return &achTestSetup{ + userID: userID, + customerID: customer.ID, + paymentMethodID: paymentMethod.ID, + testClockID: testClockID, + }, nil +} + +func createACHPaymentMethod(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) { + return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ + Type: stripe.String("us_bank_account"), + USBankAccount: &stripe.PaymentMethodCreateUSBankAccountParams{ + AccountHolderType: stripe.String("individual"), + AccountType: stripe.String("checking"), + RoutingNumber: stripe.String("110000000"), + AccountNumber: stripe.String("000123456789"), + }, + BillingDetails: &stripe.PaymentMethodCreateBillingDetailsParams{ + Name: stripe.String("HMN ACH Test User"), + }, + }) +} + +func verifyACHPaymentMethod(ctx context.Context, sc *stripe.Client, customerID, paymentMethodID string) error { + setupIntent, err := sc.V1SetupIntents.Create(ctx, &stripe.SetupIntentCreateParams{ + Customer: stripe.String(customerID), + PaymentMethod: stripe.String(paymentMethodID), + PaymentMethodTypes: []*string{stripe.String("us_bank_account")}, + Confirm: stripe.Bool(true), + MandateData: &stripe.SetupIntentCreateMandateDataParams{ + CustomerAcceptance: &stripe.SetupIntentCreateMandateDataCustomerAcceptanceParams{ + Type: stripe.String("online"), + Online: &stripe.SetupIntentCreateMandateDataCustomerAcceptanceOnlineParams{ + IPAddress: stripe.String("127.0.0.1"), + UserAgent: stripe.String("HMN Admin Subscription Test"), + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("confirm setup intent for ACH verification: %w", err) + } + fmt.Printf(" setup_intent_id=%s status=%s\n", setupIntent.ID, setupIntent.Status) + + if setupIntent.Status == stripe.SetupIntentStatusRequiresAction { + setupIntent, err = sc.V1SetupIntents.VerifyMicrodeposits(ctx, setupIntent.ID, &stripe.SetupIntentVerifyMicrodepositsParams{ + Amounts: []*int64{stripe.Int64(32), stripe.Int64(45)}, + }) + if err != nil { + setupIntent, err = sc.V1SetupIntents.VerifyMicrodeposits(ctx, setupIntent.ID, &stripe.SetupIntentVerifyMicrodepositsParams{ + DescriptorCode: stripe.String("SM11AA"), + }) + if err != nil { + return fmt.Errorf("verify ACH microdeposits: %w", err) + } + } + fmt.Printf(" setup_intent_id=%s status=%s after verification\n", setupIntent.ID, setupIntent.Status) + } + + if setupIntent.Status != stripe.SetupIntentStatusSucceeded { + return fmt.Errorf("setup intent did not succeed: status=%s", setupIntent.Status) + } + return nil +} + +func completeSubscription(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, userID int, customerID, paymentMethodID string) (subscriptionTestResult, error) { subscriptionParams := &stripe.SubscriptionCreateParams{ - Customer: stripe.String(customer.ID), - DefaultPaymentMethod: stripe.String(paymentMethod.ID), + Customer: stripe.String(customerID), + DefaultPaymentMethod: stripe.String(paymentMethodID), CollectionMethod: stripe.String("charge_automatically"), PaymentBehavior: stripe.String("allow_incomplete"), Items: []*stripe.SubscriptionCreateItemParams{ @@ -186,7 +431,7 @@ func runSubscriptionScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.C current_period_end = $5, cancel_at_period_end = $6 WHERE id = $7 - `, isSubscribed, customer.ID, subscription.ID, subscription.Status, renewalDate, subscription.CancelAtPeriodEnd, userID) + `, isSubscribed, customerID, subscription.ID, subscription.Status, renewalDate, subscription.CancelAtPeriodEnd, userID) if err != nil { return subscriptionTestResultPass, err } @@ -214,13 +459,71 @@ func runSubscriptionScenario(ctx context.Context, conn db.ConnOrTx, sc *stripe.C } fmt.Printf("[6/6] Verifying and printing stored subscription data\n") - if err := validateStoredSubscriptionData(ctx, conn, userID, customer.ID, subscription.ID); err != nil { + if err := validateStoredSubscriptionData(ctx, conn, userID, customerID, subscription.ID); err != nil { return subscriptionTestResultPass, err } printSubscriptionData(ctx, conn, userID) return subscriptionTestResultPass, nil } +func createTestClock(ctx context.Context, sc *stripe.Client, name string) (*stripe.TestHelpersTestClock, error) { + return sc.V1TestHelpersTestClocks.Create(ctx, &stripe.TestHelpersTestClockCreateParams{ + FrozenTime: stripe.Int64(time.Now().Unix()), + Name: stripe.String(name), + }) +} + +func advanceTestClockBy(ctx context.Context, sc *stripe.Client, testClockID string, duration time.Duration) (time.Time, error) { + clock, err := sc.V1TestHelpersTestClocks.Retrieve(ctx, testClockID, nil) + if err != nil { + return time.Time{}, err + } + + target := time.Unix(clock.FrozenTime, 0).Add(duration) + _, 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 waitForTestClockReady(ctx context.Context, sc *stripe.Client, testClockID string) (*stripe.TestHelpersTestClock, error) { + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + clock, err := sc.V1TestHelpersTestClocks.Retrieve(ctx, testClockID, nil) + if err != nil { + return nil, err + } + if clock.Status == stripe.TestHelpersTestClockStatusReady { + return clock, nil + } + time.Sleep(1 * time.Second) + } + return nil, fmt.Errorf("test clock %s did not become ready", testClockID) +} + +func syncSubscriptionNowToTestClock(ctx context.Context, sc *stripe.Client, testClockID string) { + clock, err := sc.V1TestHelpersTestClocks.Retrieve(ctx, testClockID, nil) + if err != nil { + panic(err) + } + website.SetSubscriptionNowForTests(time.Unix(clock.FrozenTime, 0)) +} + +func deleteTestClock(ctx context.Context, sc *stripe.Client, testClockID string) { + _, err := sc.V1TestHelpersTestClocks.Delete(ctx, testClockID, nil) + if err != nil { + fmt.Printf(" warning: failed to delete test clock %s: %v\n", testClockID, err) + } +} + 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) if err != nil { @@ -283,6 +586,13 @@ func printSubscriptionData(ctx context.Context, conn db.ConnOrTx, userID int) { fmt.Printf(" current_period_end: \n") } fmt.Printf(" cancel_at_period_end: %v\n", user.CancelAtPeriodEnd) + if user.GracePeriodStartedAt != nil { + fmt.Printf(" grace_period_started_at: %s\n", user.GracePeriodStartedAt.UTC().Format(time.RFC3339)) + } + if user.GracePeriodEndsAt != nil { + fmt.Printf(" grace_period_ends_at: %s\n", user.GracePeriodEndsAt.UTC().Format(time.RFC3339)) + } + fmt.Printf(" grace_available: %v\n", user.GraceAvailable) payments, err := db.Query[models.UserPayment](ctx, conn, ` SELECT $columns diff --git a/src/config/config.go.example b/src/config/config.go.example index 45c3ceb6..517ffc13 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -111,5 +111,7 @@ var Config = HMNConfig{ MembershipAlternatePriceIDs: []string{ // "price_...", }, + // TestClockID: "clock_...", + // SubscriptionNowOverride: "2026-05-24T12:00:00Z", }, } diff --git a/src/config/types.go b/src/config/types.go index 051b9ec7..228c3bb4 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -146,6 +146,13 @@ type StripeConfig struct { // second currency). The webhook dispatcher treats PriceID ∪ MembershipAlternatePriceIDs as // belonging to the subscription flow. MembershipAlternatePriceIDs []string + + // TestClockID attaches new Stripe customers created by subscription test tooling to this clock. + TestClockID string + + // SubscriptionNowOverride is an RFC3339 timestamp used instead of time.Now() for subscription + // grace-period logic in dev/test. Leave empty in production. + SubscriptionNowOverride string } func init() { diff --git a/src/email/email.go b/src/email/email.go index f330b799..731ab99c 100644 --- a/src/email/email.go +++ b/src/email/email.go @@ -262,6 +262,7 @@ type PaymentFailedEmailData struct { ManageSubscriptionUrl string Amount string NextAttemptDate string + GracePeriodEnd string } func SendPaymentFailedEmail( @@ -269,6 +270,7 @@ func SendPaymentFailedEmail( toName string, amount string, nextAttemptDate *time.Time, + gracePeriodEnd *time.Time, perf *perf.RequestPerf, ) error { defer perf.StartBlock("EMAIL", "Payment failed email").End() @@ -278,6 +280,11 @@ func SendPaymentFailedEmail( nextAttemptDateStr = nextAttemptDate.Format("January 2, 2006") } + 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_payment_failed.html", PaymentFailedEmailData{ @@ -286,6 +293,7 @@ func SendPaymentFailedEmail( ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), Amount: amount, NextAttemptDate: nextAttemptDateStr, + GracePeriodEnd: gracePeriodEndStr, }) if err != nil { return err diff --git a/src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go b/src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go new file mode 100644 index 00000000..aa601d35 --- /dev/null +++ b/src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddSubscriptionGracePeriod{}) +} + +type AddSubscriptionGracePeriod struct{} + +func (m AddSubscriptionGracePeriod) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 5, 24, 17, 0, 0, 0, time.UTC)) +} + +func (m AddSubscriptionGracePeriod) Name() string { + return "AddSubscriptionGracePeriod" +} + +func (m AddSubscriptionGracePeriod) Description() string { + return "Add grace period tracking columns to hmn_user" +} + +func (m AddSubscriptionGracePeriod) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN grace_period_started_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN grace_period_ends_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN grace_available BOOLEAN NOT NULL DEFAULT true; + `) + return err +} + +func (m AddSubscriptionGracePeriod) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN grace_period_started_at, + DROP COLUMN grace_period_ends_at, + DROP COLUMN grace_available; + `) + return err +} diff --git a/src/models/user.go b/src/models/user.go index 60f0a2f7..c7681a83 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -56,6 +56,10 @@ type User struct { CancelAtPeriodEnd bool `db:"cancel_at_period_end"` ThankYouEmailSent bool `db:"thank_you_email_sent"` + GracePeriodStartedAt *time.Time `db:"grace_period_started_at"` + GracePeriodEndsAt *time.Time `db:"grace_period_ends_at"` + GraceAvailable bool `db:"grace_available"` + MarkedAllReadAt time.Time `db:"marked_all_read_at"` // Non-db fields, to be filled in by fetch helpers diff --git a/src/templates/src/email_payment_failed.html b/src/templates/src/email_payment_failed.html index e9c86ce2..da3c050b 100644 --- a/src/templates/src/email_payment_failed.html +++ b/src/templates/src/email_payment_failed.html @@ -14,6 +14,12 @@ Please update your payment details to keep your membership active. You can manage your membership at {{ .ManageSubscriptionUrl }}.

    +{{ if .GracePeriodEnd }} +

    + Your membership benefits will remain active until {{ .GracePeriodEnd }} while we wait for payment. After that + date, access will end if payment is still unresolved. +

    +{{ end }} {{ if .NextAttemptDate }}

    Stripe will automatically retry the charge on {{ .NextAttemptDate }}. If the payment continues to fail, your diff --git a/src/templates/src/manage_subscription.html b/src/templates/src/manage_subscription.html index 46c48606..3b302a7a 100644 --- a/src/templates/src/manage_subscription.html +++ b/src/templates/src/manage_subscription.html @@ -11,6 +11,14 @@

    Manage your membership

    {{ 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 }} @@ -28,6 +36,7 @@

    Manage your membership

    {{ end }}

    + {{ end }} {{ if .PaymentHistory }}
    diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go new file mode 100644 index 00000000..f758af4f --- /dev/null +++ b/src/website/subscription_grace.go @@ -0,0 +1,158 @@ +package website + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" +) + +const ( + SubscriptionStatusGracePeriod = "grace_period" + SubscriptionStatusGraceFailed = "grace_failed" + SubscriptionStatusPendingVerification = "pending_verification" + subscriptionGracePeriodDuration = 7 * 24 * time.Hour +) + +var subscriptionNowOverride *time.Time + +func SubscriptionNow() time.Time { + if subscriptionNowOverride != nil { + return *subscriptionNowOverride + } + if override := config.Config.Stripe.SubscriptionNowOverride; override != "" { + if parsed, err := time.Parse(time.RFC3339, override); err == nil { + return parsed + } + } + return time.Now() +} + +func SetSubscriptionNowForTests(t time.Time) { + subscriptionNowOverride = &t +} + +func ClearSubscriptionNowForTests() { + subscriptionNowOverride = nil +} + +func isGraceActive(user *models.User, now time.Time) bool { + if user == nil || user.GracePeriodEndsAt == nil { + return false + } + if user.SubscriptionStatus != nil && *user.SubscriptionStatus == SubscriptionStatusGracePeriod { + return now.Before(*user.GracePeriodEndsAt) + } + return user.GracePeriodStartedAt != nil && now.Before(*user.GracePeriodEndsAt) +} + +func canStartGrace(user *models.User, now time.Time) bool { + if user == nil || !user.GraceAvailable { + return false + } + if isGraceActive(user, now) { + return false + } + return true +} + +func stripeSubscriptionGrantsAccess(status string) bool { + return status == "active" || status == "trialing" +} + +func isFailedPaymentStripeStatus(status string) bool { + switch status { + case "past_due", "unpaid", "incomplete", "incomplete_expired": + return true + default: + return false + } +} + +func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now time.Time) error { + endsAt := now.Add(subscriptionGracePeriodDuration) + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = true, + subscription_status = $1, + grace_period_started_at = $2, + grace_period_ends_at = $3, + grace_available = false + WHERE id = $4 + `, SubscriptionStatusGracePeriod, now, endsAt, userID) + if err != nil { + return err + } + logging.Info().Int("userID", userID).Time("graceEndsAt", endsAt).Msg("started subscription grace period") + return nil +} + +func clearGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error { + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = true + WHERE id = $1 + `, userID) + if err != nil { + return err + } + logging.Info().Int("userID", userID).Msg("cleared subscription grace period after successful payment") + return nil +} + +func expireGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) 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 = false + WHERE id = $2 + AND subscription_status = $3 + `, SubscriptionStatusGraceFailed, userID, SubscriptionStatusGracePeriod) + if err != nil { + return err + } + logging.Info().Int("userID", userID).Msg("expired subscription grace period without payment") + return nil +} + +func expireDueGracePeriods(ctx context.Context, conn db.ConnOrTx, now time.Time) (int64, error) { + tag, 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 = false + WHERE subscription_status = $2 + AND grace_period_ends_at IS NOT NULL + AND grace_period_ends_at < $3 + `, SubscriptionStatusGraceFailed, SubscriptionStatusGracePeriod, now) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +func userInGracePeriod(user *models.User) bool { + return user != nil && user.SubscriptionStatus != nil && *user.SubscriptionStatus == SubscriptionStatusGracePeriod +} + +func StartSubscriptionGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error { + return startGracePeriod(ctx, conn, userID, SubscriptionNow()) +} + +func ExpireSubscriptionGracePeriods(ctx context.Context, conn db.ConnOrTx) (int64, error) { + return expireDueGracePeriods(ctx, conn, SubscriptionNow()) +} diff --git a/src/website/subscription_grace_job.go b/src/website/subscription_grace_job.go new file mode 100644 index 00000000..a36753e0 --- /dev/null +++ b/src/website/subscription_grace_job.go @@ -0,0 +1,42 @@ +package website + +import ( + "time" + + "git.handmade.network/hmn/hmn/src/jobs" + "git.handmade.network/hmn/hmn/src/utils" + "github.com/jackc/pgx/v5/pgxpool" +) + +func ExpireSubscriptionGracePeriodsJob(dbConn *pgxpool.Pool) *jobs.Job { + job := jobs.New("subscription grace expiry") + go func() { + defer job.Finish() + + t := time.NewTicker(1 * time.Minute) + for { + select { + case <-t.C: + err := func() (err error) { + defer utils.RecoverPanicAsError(&err) + + n, err := expireDueGracePeriods(job.Ctx, dbConn, SubscriptionNow()) + if err != nil { + job.Logger.Error().Err(err).Msg("failed to expire subscription grace periods") + return err + } + if n > 0 { + job.Logger.Info().Int64("num expired", n).Msg("Expired subscription grace periods") + } + return nil + }() + if err != nil { + job.Logger.Error().Err(err).Msg("Panicked in subscription grace expiry job") + } + case <-job.Canceled(): + return + } + } + }() + return job +} diff --git a/src/website/subscription_grace_test.go b/src/website/subscription_grace_test.go new file mode 100644 index 00000000..e4d25342 --- /dev/null +++ b/src/website/subscription_grace_test.go @@ -0,0 +1,82 @@ +package website + +import ( + "testing" + "time" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/models" + "github.com/stretchr/testify/assert" +) + +func statusPtr(s string) *string { + return &s +} + +func timePtr(t time.Time) *time.Time { + return &t +} + +func TestSubscriptionNowUsesOverride(t *testing.T) { + original := config.Config.Stripe.SubscriptionNowOverride + defer func() { + config.Config.Stripe.SubscriptionNowOverride = original + ClearSubscriptionNowForTests() + }() + + fixed := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + config.Config.Stripe.SubscriptionNowOverride = fixed.Format(time.RFC3339) + ClearSubscriptionNowForTests() + + assert.Equal(t, fixed, SubscriptionNow()) + + SetSubscriptionNowForTests(fixed.Add(2 * time.Hour)) + assert.Equal(t, fixed.Add(2*time.Hour), SubscriptionNow()) +} + +func TestIsGraceActive(t *testing.T) { + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + endsAt := now.Add(48 * time.Hour) + + user := &models.User{ + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + GracePeriodStartedAt: timePtr(now.Add(-24 * time.Hour)), + GracePeriodEndsAt: timePtr(endsAt), + } + assert.True(t, isGraceActive(user, now)) + assert.False(t, isGraceActive(user, endsAt)) + assert.False(t, isGraceActive(&models.User{}, now)) +} + +func TestCanStartGrace(t *testing.T) { + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + + assert.True(t, canStartGrace(&models.User{GraceAvailable: true}, now)) + assert.False(t, canStartGrace(&models.User{GraceAvailable: false}, now)) + + activeGraceUser := &models.User{ + GraceAvailable: true, + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + GracePeriodStartedAt: timePtr(now.Add(-24 * time.Hour)), + GracePeriodEndsAt: timePtr(now.Add(24 * time.Hour)), + } + assert.False(t, canStartGrace(activeGraceUser, now)) +} + +func TestIsFailedPaymentStripeStatus(t *testing.T) { + assert.True(t, isFailedPaymentStripeStatus("past_due")) + assert.True(t, isFailedPaymentStripeStatus("unpaid")) + assert.False(t, isFailedPaymentStripeStatus("active")) + assert.False(t, isFailedPaymentStripeStatus("trialing")) +} + +func TestStripeSubscriptionGrantsAccess(t *testing.T) { + assert.True(t, stripeSubscriptionGrantsAccess("active")) + assert.True(t, stripeSubscriptionGrantsAccess("trialing")) + assert.False(t, stripeSubscriptionGrantsAccess("past_due")) +} + +func TestUserInGracePeriod(t *testing.T) { + assert.True(t, userInGracePeriod(&models.User{SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod)})) + assert.False(t, userInGracePeriod(&models.User{SubscriptionStatus: statusPtr("active")})) +} diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go index 6c474f80..bd8b53f5 100644 --- a/src/website/subscriptions.go +++ b/src/website/subscriptions.go @@ -86,6 +86,8 @@ type ManageSubscriptionTemplateData struct { CurrentPeriodEnd string LastPaymentAmount string LastPaymentMethod string + GracePeriodEnd string + IsInGracePeriod bool DefaultMembershipPriceID string EurMembershipPriceID string @@ -159,6 +161,15 @@ func SubscriptionManage(c *RequestContext) ResponseData { eurPriceID = alts[0] } + gracePeriodEnd := "" + isInGracePeriod := false + if c.CurrentUser != nil && userInGracePeriod(c.CurrentUser) { + isInGracePeriod = true + if c.CurrentUser.GracePeriodEndsAt != nil { + gracePeriodEnd = c.CurrentUser.GracePeriodEndsAt.UTC().Format("Jan 2, 2006") + } + } + var res ResponseData res.MustWriteTemplate("manage_subscription.html", ManageSubscriptionTemplateData{ BaseData: getBaseData(c, "Manage Membership", nil), @@ -171,6 +182,8 @@ func SubscriptionManage(c *RequestContext) ResponseData { CurrentPeriodEnd: currentPeriodEnd, LastPaymentAmount: lastAmount, LastPaymentMethod: lastMethod, + GracePeriodEnd: gracePeriodEnd, + IsInGracePeriod: isInGracePeriod, DefaultMembershipPriceID: config.Config.Stripe.PriceID, EurMembershipPriceID: eurPriceID, }, c.Perf) @@ -181,6 +194,9 @@ func SubscriptionSubscribe(c *RequestContext) ResponseData { if c.CurrentUser.IsSubscribed { return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) } + if userInGracePeriod(c.CurrentUser) { + return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) + } if err := c.Req.ParseForm(); err != nil { return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse subscribe form")) @@ -292,7 +308,7 @@ func SubscriptionResume(c *RequestContext) ResponseData { return c.Redirect(hmnurl.BuildSubscriptionManage(), http.StatusSeeOther) } - if c.CurrentUser.CurrentPeriodEnd == nil || c.CurrentUser.CurrentPeriodEnd.Before(time.Now()) { + if c.CurrentUser.CurrentPeriodEnd == nil || c.CurrentUser.CurrentPeriodEnd.Before(SubscriptionNow()) { return c.Redirect(hmnurl.BuildSubscriptionSubscribe(), http.StatusSeeOther) } @@ -355,7 +371,10 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio stripe_customer_id = $1, stripe_subscription_id = $2, subscription_status = 'active', - cancel_at_period_end = false + cancel_at_period_end = false, + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = true WHERE id = $3 RETURNING $columns `, session.Customer.ID, session.Subscription.ID, userID) @@ -369,6 +388,8 @@ func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, sessio func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { renewalDate := getSubscriptionPeriodEnd(sub) + now := SubscriptionNow() + stripeStatus := string(sub.Status) user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", sub.Customer.ID) if err == db.NotFound { @@ -388,12 +409,60 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe logging.Info(). Int("userID", user.ID). - Str("status", string(sub.Status)). + Str("status", stripeStatus). Bool("cancelAtPeriodEnd", sub.CancelAtPeriodEnd). Int64("cancelAt", sub.CancelAt). Bool("isCancelling", isCancelling). Msg("updating user subscription from webhook") + if isFailedPaymentStripeStatus(stripeStatus) { + if 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") + return + } + } else if !isGraceActive(user, now) { + _, 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 = false, + current_period_end = $5 + WHERE id = $6 + `, sub.Customer.ID, sub.ID, stripeStatus, isCancelling, renewalDate, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook") + } + return + } + + _, 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") + } + return + } + + isSubscribed := stripeSubscriptionGrantsAccess(stripeStatus) + if isSubscribed { + 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") + } + } + _, err = c.Conn.Exec(c, ` UPDATE hmn_user SET @@ -401,10 +470,10 @@ func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe stripe_subscription_id = $2, subscription_status = $3, cancel_at_period_end = $4, - is_subscribed = ($3 = 'active' OR $3 = 'trialing'), - current_period_end = $5 - WHERE id = $6 - `, sub.Customer.ID, sub.ID, sub.Status, isCancelling, renewalDate, user.ID) + is_subscribed = $5, + current_period_end = $6 + WHERE id = $7 + `, sub.Customer.ID, sub.ID, stripeStatus, isCancelling, isSubscribed, renewalDate, user.ID) if err != nil { logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook") } @@ -440,7 +509,19 @@ func handleSubscriptionDeleted(c *RequestContext, sc *stripe.Client, sub *stripe return } - _, 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 WHERE id = $1", user.ID) + _, 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 + `, user.ID) if err != nil { logging.Error().Err(err).Int("userID", user.ID).Msg("failed to handle subscription deletion") return @@ -485,11 +566,15 @@ 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") + } + 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) if err == nil { renewalDate := getSubscriptionPeriodEnd(sub) - _, err = c.Conn.Exec(c, "UPDATE hmn_user SET current_period_end = $1, is_subscribed = true WHERE id = $2", renewalDate, user.ID) + _, 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") } @@ -510,6 +595,28 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip return } + now := SubscriptionNow() + if 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 !isGraceActive(user, now) && !user.GraceAvailable { + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET is_subscribed = false, subscription_status = $1 + WHERE id = $2 + `, SubscriptionStatusGraceFailed, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to mark subscription grace_failed") + } + } + + user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to reload user after payment failure") + return + } + amountStr := "" if inv.AmountDue > 0 { curr := strings.ToUpper(string(inv.Currency)) @@ -527,7 +634,7 @@ func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *strip } logging.Info().Int("userID", user.ID).Str("invoiceID", inv.ID).Msg("sending payment failed email") - err = email.SendPaymentFailedEmail(user.Email, user.BestName(), amountStr, nextAttemptDate, c.Perf) + err = email.SendPaymentFailedEmail(user.Email, user.BestName(), amountStr, nextAttemptDate, user.GracePeriodEndsAt, c.Perf) if err != nil { logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send payment failed email") } @@ -643,7 +750,7 @@ func attemptThankYouEmail(c *RequestContext, userID int, amountCents int64, curr // We check that the renewal date is at least reasonably in the future to avoid race conditions // where an old or "initiation" date is still in the DB. shouldSend := false - if user.IsSubscribed && user.CurrentPeriodEnd != nil && user.CurrentPeriodEnd.After(time.Now().Add(24*time.Hour)) && !user.ThankYouEmailSent { + if user.IsSubscribed && user.CurrentPeriodEnd != nil && user.CurrentPeriodEnd.After(SubscriptionNow().Add(24*time.Hour)) && !user.ThankYouEmailSent { shouldSend = true _, err = tx.Exec(c, "UPDATE hmn_user SET thank_you_email_sent = true WHERE id = $1", userID) if err != nil { diff --git a/src/website/website.go b/src/website/website.go index af740a56..d2c4ba2b 100644 --- a/src/website/website.go +++ b/src/website/website.go @@ -56,6 +56,7 @@ var WebsiteCommand = &cobra.Command{ buildcss.RunServer(), email.MonitorBounces(conn), NagUsersToCreateJamProjectsJob(conn), + ExpireSubscriptionGracePeriodsJob(conn), } // Create HTTP server From 658dd0536570196112c2e3f1bdf27edf620f5252 Mon Sep 17 00:00:00 2001 From: reece365 Date: Wed, 27 May 2026 22:50:47 -0500 Subject: [PATCH 04/15] Moved membership management UI into unified page, added bank account verification banner --- src/config/config.go.example | 2 +- src/hmnurl/hmnurl_test.go | 2 +- src/hmnurl/urls.go | 2 +- src/templates/src/hsf_membership.html | 200 +++++++++++++++++++-- src/templates/src/include/header-2024.html | 11 ++ src/templates/src/manage_subscription.html | 186 ------------------- src/templates/types.go | 4 + src/website/base_data.go | 13 ++ src/website/hsf.go | 2 +- src/website/routes.go | 2 +- src/website/subscription_grace.go | 30 ++++ src/website/subscription_grace_test.go | 40 +++++ src/website/subscriptions.go | 126 +++++++++++-- 13 files changed, 400 insertions(+), 220 deletions(-) delete mode 100644 src/templates/src/manage_subscription.html diff --git a/src/config/config.go.example b/src/config/config.go.example index 517ffc13..451b5e34 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -107,7 +107,7 @@ var Config = HMNConfig{ WebhookSecret: "", // Default USD membership price (price_... from Stripe Dashboard / API). PriceID: "price_...", - // Optional other membership prices, e.g. EUR. First entry powers the EUR option on /foundation/membership/manage. + // Optional other membership prices, e.g. EUR. First entry powers the EUR option on /foundation/membership. MembershipAlternatePriceIDs: []string{ // "price_...", }, diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 5341b8ae..8b4ce43f 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -546,7 +546,7 @@ func TestFoundationSubscriptionBuildUrls(t *testing.T) { isTest = true AssertRegexMatch(t, BuildHSFMembership(), RegexHSFMembership, nil) - AssertRegexMatch(t, BuildSubscriptionManage(), RegexSubscriptionManage, nil) + AssertRegexMatch(t, BuildSubscriptionManage(), RegexHSFMembership, nil) AssertRegexMatch(t, BuildSubscriptionSubscribe(), RegexSubscriptionSubscribe, nil) AssertRegexMatch(t, BuildSubscriptionCancel(), RegexSubscriptionCancel, nil) AssertRegexMatch(t, BuildSubscriptionResume(), RegexSubscriptionResume, nil) diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index a6ef2885..0c5c1981 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -1086,7 +1086,7 @@ func BuildHSFMembership() string { var RegexSubscriptionManage = regexp.MustCompile(`^/foundation/membership/manage$`) func BuildSubscriptionManage() string { - return Url("/foundation/membership/manage", nil) + return BuildHSFMembership() } var RegexSubscriptionSubscribe = regexp.MustCompile(`^/foundation/membership/subscribe$`) diff --git a/src/templates/src/hsf_membership.html b/src/templates/src/hsf_membership.html index 3d30a36f..c0598969 100644 --- a/src/templates/src/hsf_membership.html +++ b/src/templates/src/hsf_membership.html @@ -1,21 +1,67 @@ {{ template "base-2024.html" . }} {{ define "content" }} -
    -

    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.

    -

    Member benefits include:

    -
      -
    • Members-only technical and business resources
    • -
    • A Foundation Member role and access to a private channel on the Handmade Network Discord
    • -
    • A regular members-only newsletter
    • -
    • More to come!
    • -
    -

    - Manage your membership -

    +
    +

    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.

    + +
    + {{ 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 }} + + {{ template "supporter_card" . }} + + {{ if and .User .User.IsSubscribed }} + {{ 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 }} + {{ end }} +
    {{ end }} @@ -23,3 +69,127 @@

    Ongoing membership

    {{ end }} +{{ define "supporter_card" }} +
    +

    Supporter Benefits

    +
      +
    • +
      {{ svg "discord" }}
      +
      +
      Discord Badge & Role
      +
      Show off your support with a special role and badge on our Discord.
      +
      +
    • +
    • +
      {{ svg "heart" }}
      +
      +
      Support Handmade
      +
      Help fund our mission to put the software industry back on the right track.
      +
      +
    • +
    • +
      {{ svg "arrow-right-up" }}
      +
      +
      And More
      +
      Get access to private channels and future supporter-only features.
      +
      +
    • +
    +
    + + {{ if .User }} +
    + + {{ 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 }} +
    + {{ csrftoken .Session }} + +
    + {{ else }} +
    + {{ csrftoken .Session }} + + +
    + {{ end }} +
    + {{ else }} +

    + Log in to support Handmade +

    + {{ end }} +
    + + +{{ end }} diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html index 4f302009..3a899de4 100644 --- a/src/templates/src/include/header-2024.html +++ b/src/templates/src/include/header-2024.html @@ -75,6 +75,17 @@
    +{{ 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 (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }}