Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ tmp/

web/web-app.html
web/web-app-debug.html

env-files/.env.internal
27 changes: 27 additions & 0 deletions backend/STRIPE_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Some dev notes

Test webhooks locally with event forwarding instead of exposing a URL

```bash
stripe login


# This created a webhook secret, then add this to the .env.local file
stripe listen --forward-to https://localhost:1926/api/billing/webhook

# Manual trigger an event if needed
stripe trigger payment_intent.succeeded

# Trigger again an event, grab the event id from Stripe's Dashboard
stripe events resend evt_XXXXXXXXXXX
```

# Testing subscription flow

For testing a subscription flow, visit the web-app and add details that can
[be found here](https://docs.stripe.com/testing?testing-method=card-numbers#cards).

## Bypass trial

There are internal flags we use in team model, called `is_manual_upgrade` which
can be set to true to bypass the trial period.
2 changes: 1 addition & 1 deletion backend/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@ tasks:
vars:
EMAIL: '{{.EMAIL| default "michael@dundermifflin.com"}}'
cmds:
- curl --request GET --url https://localhost:1926/api/jwt-debug\?email\={{.EMAIL}}
- curl --request GET --url https://localhost:1926/api/jwt-debug\?email\={{.EMAIL}}
184 changes: 184 additions & 0 deletions backend/api-files/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,64 @@ components:
type: object
additionalProperties: true
nullable: true
is_pro:
type: boolean
description: Whether the user has an active paid subscription
is_trial:
type: boolean
description: Whether the user is currently in trial period
trial_ends_at:
type: string
format: date-time
nullable: true
description: When the trial period ends (null if not in trial or has active subscription)

Error:
type: object
properties:
message:
type: string

SubscriptionResponse:
type: object
required:
- status
- manual_upgrade
- is_admin
properties:
status:
type: string
enum: [active, canceled, past_due, trialing, incomplete]
description: Current subscription status
manual_upgrade:
type: boolean
description: Whether the team has been manually upgraded
current_period_end:
type: string
format: date-time
nullable: true
description: End date of current billing period (null for manual upgrades or trialing)
cancel_at_period_end:
type: boolean
nullable: true
description: Whether subscription will cancel at period end (null for manual upgrades or trialing)
is_admin:
type: boolean
description: Whether the current user is a team admin

CreateCheckoutSessionRequest:
type: object
required:
- tier
properties:
tier:
type: string
enum: [paid]
description: Subscription tier to create checkout session for
price_id:
type: string
description: Optional Stripe price ID (uses environment default if not provided)

securitySchemes:
BearerAuth:
type: http
Expand Down Expand Up @@ -575,3 +626,136 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"

/api/auth/billing/subscription:
get:
summary: Get current subscription status
description: Returns the subscription status for the current user's team
security:
- BearerAuth: []
responses:
"200":
description: Subscription status retrieved successfully
content:
application/json:
schema:
type: object
properties:
subscription:
$ref: "#/components/schemas/SubscriptionResponse"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
description: Internal server error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/api/auth/billing/create-checkout-session:
post:
summary: Create Stripe checkout session
description: Creates a Stripe checkout session for team subscription
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateCheckoutSessionRequest"
responses:
"200":
description: Checkout session created successfully
content:
application/json:
schema:
type: object
required:
- checkout_url
- session_id
properties:
checkout_url:
type: string
description: URL to redirect user to Stripe checkout
session_id:
type: string
description: Stripe checkout session ID
"400":
description: Bad request - invalid input or team already has active subscription
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Forbidden - only team admins can manage subscriptions
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
description: Internal server error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/api/auth/billing/create-portal-session:
post:
summary: Create Stripe billing portal session
description: Creates a Stripe billing portal session for subscription management
security:
- BearerAuth: []
responses:
"200":
description: Portal session created successfully
content:
application/json:
schema:
type: object
required:
- portal_url
properties:
portal_url:
type: string
description: URL to redirect user to Stripe billing portal
"400":
description: Bad request - user must be part of a team
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Forbidden - only team admins can access billing portal
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: No subscription found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
description: Internal server error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"strings"

"github.com/joho/godotenv"
)
Expand Down Expand Up @@ -49,6 +50,14 @@ type Config struct {
Sentry struct {
DSN string
}
Stripe struct {
SecretKey string
PublishableKey string
WebhookSecret string
PaidPriceID string // Single price ID for paid tier
SuccessURL string
CancelURL string
}
}

func Load() (*Config, error) {
Expand All @@ -62,6 +71,13 @@ func Load() (*Config, error) {
if err != nil {
fmt.Printf("Error loading .env file: %s\n", err)
}

// Load internal one, from maintainer's team to avoid pushing to git
internalFilePath := "./env-files/.env.internal"
err = godotenv.Load(internalFilePath)
if err != nil {
fmt.Printf("Error loading .env.internal file: %s\n", err)
}
}

// Load configuration from environment variables or config file
Expand Down Expand Up @@ -122,5 +138,20 @@ func Load() (*Config, error) {

c.Sentry.DSN = os.Getenv("SENTRY_DSN")

// Stripe configuration
c.Stripe.SecretKey = os.Getenv("STRIPE_SECRET_KEY")
c.Stripe.PublishableKey = os.Getenv("STRIPE_PUBLISHABLE_KEY")
c.Stripe.WebhookSecret = os.Getenv("STRIPE_WEBHOOK_SECRET")
c.Stripe.PaidPriceID = os.Getenv("STRIPE_PAID_PRICE_ID")

if c.Stripe.PaidPriceID != "" && !strings.HasPrefix(c.Stripe.PaidPriceID, "price_") {
fmt.Printf("WARNING: STRIPE_PAID_PRICE_ID should start with 'price_', but got: %s\n", c.Stripe.PaidPriceID)
fmt.Println("Please check your Stripe dashboard for the correct Price ID (not Product ID)")
return c, fmt.Errorf("STRIPE_PAID_PRICE_ID should start with 'price_', but got: %s", c.Stripe.PaidPriceID)
}

c.Stripe.SuccessURL = fmt.Sprintf("https://%s/subscription/success", c.Server.DeployDomain)
c.Stripe.CancelURL = fmt.Sprintf("https://%s/subscription/cancel", c.Server.DeployDomain)

return c, nil
}
44 changes: 44 additions & 0 deletions backend/internal/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type EmailClient interface {
SendAsync(toEmail, subject, htmlBody string)
SendWelcomeEmail(user *models.User)
SendTeamInvitationEmail(inviterName, teamName, inviteLink, toEmail string)
SendSubscriptionConfirmationEmail(user *models.User)
SendSubscriptionCancellationEmail(user *models.User)
}

// ResendEmailClient implements EmailClient using the Resend service
Expand Down Expand Up @@ -107,3 +109,45 @@ func (c *ResendEmailClient) SendTeamInvitationEmail(inviterName, teamName, invit

c.SendAsync(toEmail, subject, htmlBody)
}

// SendSubscriptionConfirmationEmail sends a subscription confirmation email
func (c *ResendEmailClient) SendSubscriptionConfirmationEmail(user *models.User) {
if user == nil {
c.logger.Error("Cannot send subscription confirmation email to nil user")
return
}

// Read the template file
templateBytes, err := os.ReadFile("web/emails/hopp-subscription.html")
if err != nil {
c.logger.Errorf("Failed to read subscription confirmation email template: %v", err)
return
}

htmlBody := strings.Replace(string(templateBytes), "{first_name}", user.FirstName, -1)

subject := "Welcome to Hopp Pro! 🎉"

c.SendAsync(user.Email, subject, htmlBody)
}

// SendSubscriptionCancellationEmail sends a subscription cancellation email
func (c *ResendEmailClient) SendSubscriptionCancellationEmail(user *models.User) {
if user == nil {
c.logger.Error("Cannot send subscription cancellation email to nil user")
return
}

// Read the template file
templateBytes, err := os.ReadFile("web/emails/hopp-unsubscribe.html")
if err != nil {
c.logger.Errorf("Failed to read subscription cancellation email template: %v", err)
return
}

htmlBody := strings.Replace(string(templateBytes), "{first_name}", user.FirstName, -1)

subject := "We're sorry to see you go 😢"

c.SendAsync(user.Email, subject, htmlBody)
}
Loading