Skip to content

Commit ad27216

Browse files
committed
Rebase implementing HMF membership system; allows users to subscribe, cancel, and recieve benefits. Billing through Stripe
1 parent 8beb67d commit ad27216

26 files changed

Lines changed: 1485 additions & 41 deletions

src/admintools/admintools.go

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ func init() {
307307
OwnerName: "Test User",
308308
OwnerEmail: "test@example.org",
309309
})
310+
case "thankyou":
311+
err = email.SendThankYouEmail(toAddress, toName, nil, "", p)
312+
case "cancelled":
313+
err = email.SendSubscriptionCancelledEmail(toAddress, toName, nil, p)
310314
default:
311315
fmt.Printf("You must provide a valid email type\n\n")
312316
cmd.Usage()
@@ -576,8 +580,8 @@ func init() {
576580
fmt.Printf("%v", err)
577581
}
578582

579-
if len(errorOut.Bytes()) > 0 {
580-
fmt.Printf("FFMpeg error:\n%s\n", string(errorOut.Bytes()))
583+
if errorOut.Len() > 0 {
584+
fmt.Printf("FFMpeg error:\n%s\n", errorOut.String())
581585
}
582586

583587
out, err := os.Create(outFile)
@@ -642,6 +646,63 @@ func init() {
642646
},
643647
})
644648

649+
adminCommand.AddCommand(&cobra.Command{
650+
Use: "userstripeinfo [username]",
651+
Short: "Output all Stripe and payment related data for a user",
652+
Run: func(cmd *cobra.Command, args []string) {
653+
if len(args) < 1 {
654+
fmt.Printf("You must provide a username.\n\n")
655+
cmd.Usage()
656+
os.Exit(1)
657+
}
658+
username := args[0]
659+
660+
ctx := context.Background()
661+
conn := db.NewConn()
662+
defer conn.Close(ctx)
663+
664+
user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE LOWER(username) = LOWER($1)", username)
665+
if err != nil {
666+
fmt.Printf("Error: User '%s' not found or error occurred.\n", username)
667+
return
668+
}
669+
670+
fmt.Printf("=== Subscription Info for %s ===\n", user.Username)
671+
fmt.Printf("Is Subscribed: %v\n", user.IsSubscribed)
672+
fmt.Printf("Subscription Status: %s\n", utils.OrDefaultPtr(user.SubscriptionStatus, "N/A"))
673+
fmt.Printf("Stripe Customer ID: %s\n", utils.OrDefaultPtr(user.StripeCustomerID, "N/A"))
674+
fmt.Printf("Stripe Subscription ID: %s\n", utils.OrDefaultPtr(user.StripeSubscriptionID, "N/A"))
675+
if user.CurrentPeriodEnd != nil {
676+
fmt.Printf("Current Period End: %s\n", user.CurrentPeriodEnd.Format(time.RFC3339))
677+
} else {
678+
fmt.Printf("Current Period End: N/A\n")
679+
}
680+
fmt.Printf("Cancel At Period End: %v\n", user.CancelAtPeriodEnd)
681+
682+
payments, err := db.Query[models.UserPayment](ctx, conn, "SELECT $columns FROM user_payment WHERE user_id = $1 ORDER BY paid_at DESC", user.ID)
683+
if err == nil && len(payments) > 0 {
684+
fmt.Printf("\n=== Payment History ===\n")
685+
fmt.Printf("%-30s | %-8s | %-8s | %-8s | %-8s | %-8s | %-12s | %-5s | %-20s\n", "Invoice ID", "Amount", "Fee", "Net", "Currency", "Type", "Brand/Bank", "Last4", "Paid At")
686+
fmt.Println(strings.Repeat("-", 145))
687+
for _, p := range payments {
688+
fmt.Printf("%-30s | %-8.2f | %-8.2f | %-8.2f | %-8s | %-8s | %-12s | %-5s | %-20s\n",
689+
utils.OrDefaultPtr(p.StripeInvoiceID, "N/A"),
690+
float64(p.AmountCents)/100.0,
691+
float64(utils.OrDefaultPtr(p.StripeFeeCents, 0))/100.0,
692+
float64(utils.OrDefaultPtr(p.NetAmountCents, 0))/100.0,
693+
p.Currency,
694+
utils.OrDefaultPtr(p.PaymentMethodType, "N/A"),
695+
utils.OrDefaultPtr(p.CardBrand, "N/A"),
696+
utils.OrDefaultPtr(p.CardLast4, "N/A"),
697+
p.PaidAt.Format("2006-01-02 15:04"),
698+
)
699+
}
700+
} else {
701+
fmt.Printf("\nNo payment history found.\n")
702+
}
703+
},
704+
})
705+
645706
addProjectCommands(adminCommand)
646707
addPostCommands(adminCommand)
647708
}

src/config/config.go.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ var Config = HMNConfig{
102102
},
103103
Stripe: StripeConfig{
104104
SecretKey: "",
105+
PublishableKey: "",
105106
WebhookSecret: "",
107+
PriceID: "", // price_...
106108
},
107109
}

src/config/types.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ type Environment string
1111

1212
const (
1313
Live Environment = "live"
14-
Beta = "beta"
15-
Dev = "dev"
14+
Beta Environment = "beta"
15+
Dev Environment = "dev"
1616
)
1717

1818
type HMNConfig struct {
@@ -37,6 +37,13 @@ type HMNConfig struct {
3737
Stripe StripeConfig
3838
}
3939

40+
type StripeConfig struct {
41+
SecretKey string
42+
PublishableKey string
43+
WebhookSecret string
44+
PriceID string
45+
}
46+
4047
type PostgresConfig struct {
4148
User string
4249
Password string
@@ -132,10 +139,6 @@ type PostmarkConfig struct {
132139
TransactionalStreamToken string
133140
}
134141

135-
type StripeConfig struct {
136-
SecretKey string
137-
WebhookSecret string
138-
}
139142

140143
func init() {
141144
if Config.EpisodeGuide.Projects == nil {

src/email/email.go

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,142 @@ func SendRegistrationEmail(
6161
return nil
6262
}
6363

64+
type ThankYouEmailData struct {
65+
Name string
66+
HomepageUrl string
67+
ManageSubscriptionUrl string
68+
RenewalDate string
69+
Amount string
70+
}
71+
72+
func SendThankYouEmail(
73+
toAddress string,
74+
toName string,
75+
renewalDate *time.Time,
76+
amount string,
77+
perf *perf.RequestPerf,
78+
) error {
79+
defer perf.StartBlock("EMAIL", "Thank you email").End()
80+
81+
renewalDateStr := ""
82+
if renewalDate != nil {
83+
renewalDateStr = renewalDate.Format("January 2, 2006")
84+
}
85+
86+
b1 := perf.StartBlock("EMAIL", "Rendering template")
87+
defer b1.End()
88+
contents, err := renderTemplate("email_thank_you.html", ThankYouEmailData{
89+
Name: toName,
90+
HomepageUrl: hmnurl.BuildHomepage(),
91+
ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(),
92+
RenewalDate: renewalDateStr,
93+
Amount: amount,
94+
})
95+
if err != nil {
96+
return err
97+
}
98+
b1.End()
99+
100+
b2 := perf.StartBlock("EMAIL", "Sending email")
101+
defer b2.End()
102+
err = sendMail(toAddress, toName, "[Handmade Software Foundation] Thank you!", contents)
103+
if err != nil {
104+
return oops.New(err, "Failed to send email")
105+
}
106+
b2.End()
107+
108+
return nil
109+
}
110+
111+
type SubscriptionCancelledEmailData struct {
112+
Name string
113+
HomepageUrl string
114+
ExpirationDate string
115+
}
116+
117+
func SendSubscriptionCancelledEmail(
118+
toAddress string,
119+
toName string,
120+
expirationDate *time.Time,
121+
perf *perf.RequestPerf,
122+
) error {
123+
defer perf.StartBlock("EMAIL", "Subscription cancelled email").End()
124+
125+
expirationDateStr := ""
126+
if expirationDate != nil && !expirationDate.IsZero() {
127+
expirationDateStr = expirationDate.Format("January 2, 2006")
128+
}
129+
130+
b1 := perf.StartBlock("EMAIL", "Rendering template")
131+
defer b1.End()
132+
contents, err := renderTemplate("email_subscription_cancelled.html", SubscriptionCancelledEmailData{
133+
Name: toName,
134+
HomepageUrl: hmnurl.BuildHomepage(),
135+
ExpirationDate: expirationDateStr,
136+
})
137+
if err != nil {
138+
return err
139+
}
140+
b1.End()
141+
142+
b2 := perf.StartBlock("EMAIL", "Sending email")
143+
defer b2.End()
144+
err = sendMail(toAddress, toName, "[Handmade Software Foundation] Membership cancelled", contents)
145+
if err != nil {
146+
return oops.New(err, "Failed to send email")
147+
}
148+
b2.End()
149+
150+
return nil
151+
}
152+
153+
type PaymentFailedEmailData struct {
154+
Name string
155+
HomepageUrl string
156+
ManageSubscriptionUrl string
157+
Amount string
158+
NextAttemptDate string
159+
}
160+
161+
func SendPaymentFailedEmail(
162+
toAddress string,
163+
toName string,
164+
amount string,
165+
nextAttemptDate *time.Time,
166+
perf *perf.RequestPerf,
167+
) error {
168+
defer perf.StartBlock("EMAIL", "Payment failed email").End()
169+
170+
nextAttemptDateStr := ""
171+
if nextAttemptDate != nil && !nextAttemptDate.IsZero() {
172+
nextAttemptDateStr = nextAttemptDate.Format("January 2, 2006")
173+
}
174+
175+
b1 := perf.StartBlock("EMAIL", "Rendering template")
176+
defer b1.End()
177+
contents, err := renderTemplate("email_payment_failed.html", PaymentFailedEmailData{
178+
Name: toName,
179+
HomepageUrl: hmnurl.BuildHomepage(),
180+
ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(),
181+
Amount: amount,
182+
NextAttemptDate: nextAttemptDateStr,
183+
})
184+
if err != nil {
185+
return err
186+
}
187+
b1.End()
188+
189+
b2 := perf.StartBlock("EMAIL", "Sending email")
190+
defer b2.End()
191+
err = sendMail(toAddress, toName, "[Handmade Software Foundation] Payment failed", contents)
192+
if err != nil {
193+
return oops.New(err, "Failed to send email")
194+
}
195+
b2.End()
196+
197+
return nil
198+
}
199+
64200
type ExistingAccountEmailData struct {
65201
Name string
66202
Username string
@@ -219,6 +355,7 @@ func renderTemplate(name string, data any) ([]byte, error) {
219355
}
220356
contentString := buffer.Bytes()
221357
contentString = bytes.ReplaceAll(contentString, []byte("\n"), []byte("\r\n"))
358+
222359
return contentString, nil
223360
}
224361

@@ -236,9 +373,16 @@ func sendMail(toAddress, toName, subject string, contentHTML []byte) error {
236373
subject,
237374
processedHTML,
238375
)
376+
377+
// Support for passwordless authentication for Mailpit testing
378+
var auth smtp.Auth
379+
if config.Config.Email.MailerPassword != "" {
380+
auth = smtp.PlainAuth("", config.Config.Email.MailerUsername, config.Config.Email.MailerPassword, config.Config.Email.ServerAddress)
381+
}
382+
239383
return smtp.SendMail(
240384
fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort),
241-
smtp.PlainAuth("", config.Config.Email.MailerUsername, config.Config.Email.MailerPassword, config.Config.Email.ServerAddress),
385+
auth,
242386
config.Config.Email.FromAddress,
243387
[]string{toAddress},
244388
contents,

src/hmnurl/urls.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -399,11 +399,36 @@ func BuildUserProfile(username string) string {
399399
}
400400

401401
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
402+
var RegexSubscriptionManage = regexp.MustCompile(`^/foundation/membership$`)
403+
var RegexSubscriptionSubscribe = regexp.MustCompile(`^/foundation/membership/subscribe$`)
404+
var RegexSubscriptionCancel = regexp.MustCompile(`^/foundation/membership/cancel$`)
405+
var RegexSubscriptionResume = regexp.MustCompile(`^/foundation/membership/resume$`)
406+
var RegexFoundationWebhook = regexp.MustCompile(`^/foundation/webhook$`)
402407

403408
func BuildUserSettings(section string) string {
404409
return UrlWithFragment("/settings", nil, section)
405410
}
406411

412+
func BuildSubscriptionManage() string {
413+
return Url("/foundation/membership", nil)
414+
}
415+
416+
func BuildSubscriptionSubscribe() string {
417+
return Url("/foundation/membership/subscribe", nil)
418+
}
419+
420+
func BuildSubscriptionCancel() string {
421+
return Url("/foundation/membership/cancel", nil)
422+
}
423+
424+
func BuildSubscriptionResume() string {
425+
return Url("/foundation/membership/resume", nil)
426+
}
427+
428+
func BuildFoundationWebhook() string {
429+
return Url("/foundation/webhook", nil)
430+
}
431+
407432
/*
408433
* Admin
409434
*/
@@ -1065,10 +1090,10 @@ func BuildHSFAbout() string {
10651090
return Url("/foundation/about", nil)
10661091
}
10671092

1068-
var RegexHSFMembership = regexp.MustCompile(`^/foundation/membership$`)
1093+
var RegexHSFMembershipInfo = regexp.MustCompile(`^/foundation/membership-info$`)
10691094

1070-
func BuildHSFMembership() string {
1071-
return Url("/foundation/membership", nil)
1095+
func BuildHSFMembershipInfo() string {
1096+
return Url("/foundation/membership-info", nil)
10721097
}
10731098

10741099
/*
@@ -1165,7 +1190,7 @@ func BuildUserFile(filepath string) string {
11651190
return BuildPublic(fmt.Sprintf("media/%s", filepath), false)
11661191
}
11671192

1168-
var RegexStripeWebhook = regexp.MustCompile("^/stripe/webhook$")
1193+
11691194

11701195
/*
11711196
* Redirects

0 commit comments

Comments
 (0)