Skip to content

Commit d957215

Browse files
committed
Add ability to collect payments
1 parent c18f36a commit d957215

23 files changed

Lines changed: 1076 additions & 36 deletions

api/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ RUN go mod download
1212

1313
COPY . .
1414

15-
RUN go get github.com/swaggo/swag/gen@v1.8.2
16-
RUN go get github.com/swaggo/swag/cmd/swag@v1.8.2
15+
RUN go get github.com/swaggo/swag/gen@latest
16+
RUN go get github.com/swaggo/swag/cmd/swag@vlatest
1717
RUN go install github.com/swaggo/swag/cmd/swag
1818
RUN swag init
1919
RUN go install github.com/gobuffalo/packr/v2/packr2@latest

api/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
cloud.google.com/go/cloudtasks v1.4.0
77
firebase.google.com/go v3.13.0+incompatible
88
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.8.3
9-
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
9+
github.com/NdoleStudio/lemonsqueezy-go v0.0.5
1010
github.com/carlmjohnson/requests v0.22.3
1111
github.com/cheggaaa/pb/v3 v3.0.8
1212
github.com/cloudevents/sdk-go/v2 v2.10.0

api/go.sum

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I
8484
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
8585
github.com/Masterminds/sprig v2.16.0+incompatible h1:QZbMUPxRQ50EKAq3LFMnxddMu88/EUUG3qmxwtDmPsY=
8686
github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
87+
github.com/NdoleStudio/lemonsqueezy-go v0.0.5 h1:T4QlX1y3EDfypyoXcTYnjPy/JNyBldRyj1VzshfWp78=
88+
github.com/NdoleStudio/lemonsqueezy-go v0.0.5/go.mod h1:6w70wYe8SCLNGRspN4cgCiEXRIH5hsWixVd8jJWstqc=
8789
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
8890
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
8991
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
@@ -92,8 +94,6 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
9294
github.com/VividCortex/ewma v1.1.1 h1:MnEK4VOv6n0RSY4vtRe3h11qjxL3+t0B8yOL8iMXdcM=
9395
github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
9496
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
95-
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
96-
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
9797
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
9898
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
9999
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
@@ -490,7 +490,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
490490
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
491491
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
492492
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
493-
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
493+
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
494494
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 h1:+iNTcqQJy0OZ5jk6a5NLib47eqXK8uYcPX+O4+cBpEM=
495495
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
496496
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=

api/pkg/di/container.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"strconv"
88
"time"
99

10+
lemonsqueezy "github.com/NdoleStudio/lemonsqueezy-go"
11+
1012
"github.com/jinzhu/now"
1113

1214
"github.com/uptrace/uptrace-go/uptrace"
@@ -94,6 +96,8 @@ func NewContainer(projectID string) (container *Container) {
9496
container.RegisterBillingRoutes()
9597
container.RegisterBillingListeners()
9698

99+
container.RegisterLemonsqueezyRoutes()
100+
97101
// this has to be last since it registers the /* route
98102
container.RegisterSwaggerRoutes()
99103

@@ -546,6 +550,7 @@ func (container *Container) UserService() (service *services.UserService) {
546550
container.Mailer(),
547551
container.UserEmailFactory(),
548552
container.MarketingService(),
553+
container.LemonsqueezyClient(),
549554
)
550555
}
551556

@@ -645,6 +650,54 @@ func (container *Container) RegisterMessageListeners() {
645650
}
646651
}
647652

653+
// LemonsqueezyService creates a new instance of services.LemonsqueezyService
654+
func (container *Container) LemonsqueezyService() (service *services.LemonsqueezyService) {
655+
container.logger.Debug(fmt.Sprintf("creating %T", service))
656+
return services.NewLemonsqueezyService(
657+
container.Logger(),
658+
container.Tracer(),
659+
container.UserRepository(),
660+
container.EventDispatcher(),
661+
)
662+
}
663+
664+
// LemonsqueezyHandler creates a new instance of handlers.LemonsqueezyHandler
665+
func (container *Container) LemonsqueezyHandler() (handler *handlers.LemonsqueezyHandler) {
666+
container.logger.Debug(fmt.Sprintf("creating %T", handler))
667+
668+
return handlers.NewLemonsqueezyHandlerHandler(
669+
container.Logger(),
670+
container.Tracer(),
671+
container.LemonsqueezyService(),
672+
container.LemonsqueezyHandlerValidator(),
673+
)
674+
}
675+
676+
// LemonsqueezyHandlerValidator creates a new instance of validators.LemonsqueezyHandlerValidator
677+
func (container *Container) LemonsqueezyHandlerValidator() (validator *validators.LemonsqueezyHandlerValidator) {
678+
container.logger.Debug(fmt.Sprintf("creating %T", validator))
679+
return validators.NewLemonsqueezyHandlerValidator(
680+
container.Logger(),
681+
container.Tracer(),
682+
container.LemonsqueezyClient(),
683+
)
684+
}
685+
686+
// LemonsqueezyClient creates a new instance of lemonsqueezy.Client
687+
func (container *Container) LemonsqueezyClient() (client *lemonsqueezy.Client) {
688+
container.logger.Debug(fmt.Sprintf("creating %T", client))
689+
return lemonsqueezy.New(
690+
lemonsqueezy.WithAPIKey(os.Getenv("LEMONSQUEEZY_API_KEY")),
691+
lemonsqueezy.WithSigningSecret(os.Getenv("LEMONSQUEEZY_SIGNING_SECRET")),
692+
)
693+
}
694+
695+
// RegisterLemonsqueezyRoutes registers routes for the /project-settings prefix
696+
func (container *Container) RegisterLemonsqueezyRoutes() {
697+
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.LemonsqueezyHandler{}))
698+
container.LemonsqueezyHandler().RegisterRoutes(container.App())
699+
}
700+
648701
// RegisterMessageThreadListeners registers event listeners for listeners.MessageThreadListener
649702
func (container *Container) RegisterMessageThreadListeners() {
650703
container.logger.Debug(fmt.Sprintf("registering listners for %T", listeners.MessageThreadListener{}))

api/pkg/entities/user.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,29 @@ import (
99
// UserID is the ID of a user
1010
type UserID string
1111

12+
// SubscriptionName is the name of the subscription
13+
type SubscriptionName string
14+
15+
// SubscriptionNameFree represents a free subscription
16+
const SubscriptionNameFree = SubscriptionName("free")
17+
18+
// SubscriptionNameProMonthly represents a monthly pro subscription
19+
const SubscriptionNameProMonthly = SubscriptionName("pro-monthly")
20+
21+
// SubscriptionNameProYearly represents a yearly pro subscription
22+
const SubscriptionNameProYearly = SubscriptionName("pro-yearly")
23+
1224
// User stores information about a user
1325
type User struct {
14-
ID UserID `json:"id" gorm:"primaryKey;type:string;" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
15-
Email string `json:"email" example:"name@email.com"` // gorm:"uniqueIndex"
16-
APIKey string `json:"api_key" example:"xyz"` // gorm:"uniqueIndex"
17-
ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
18-
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
19-
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
26+
ID UserID `json:"id" gorm:"primaryKey;type:string;" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
27+
Email string `json:"email" example:"name@email.com"` // gorm:"uniqueIndex"
28+
APIKey string `json:"api_key" example:"xyz"` // gorm:"uniqueIndex"
29+
ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
30+
SubscriptionName SubscriptionName `json:"subscription_name" example:"free"`
31+
SubscriptionID *string `json:"subscription_id" example:"8f9c71b8-b84e-4417-8408-a62274f65a08"`
32+
SubscriptionStatus *string `json:"subscription_status" example:"on_trial"`
33+
SubscriptionRenewsAt *time.Time `json:"subscription_renews_at" example:"2022-06-05T14:26:02.302718+03:00"`
34+
SubscriptionEndsAt *time.Time `json:"subscription_ends_at" example:"2022-06-05T14:26:02.302718+03:00"`
35+
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
36+
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
2037
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package events
2+
3+
import (
4+
"time"
5+
6+
"github.com/NdoleStudio/httpsms/pkg/entities"
7+
)
8+
9+
// UserSubscriptionCancelled is raised when a user subscription is cancelled
10+
const UserSubscriptionCancelled = "user.subscription.cancelled"
11+
12+
// UserSubscriptionCancelledPayload stores the data for the UserSubscriptionCancelled event
13+
type UserSubscriptionCancelledPayload struct {
14+
UserID entities.UserID `json:"user_id"`
15+
SubscriptionCancelledAt time.Time `json:"subscription_cancelled_at"`
16+
SubscriptionEndsAt time.Time `json:"subscription_ends_at"`
17+
SubscriptionID string `json:"subscription_id"`
18+
SubscriptionName entities.SubscriptionName `json:"subscription_name"`
19+
SubscriptionStatus string `json:"subscription_status"`
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package events
2+
3+
import (
4+
"time"
5+
6+
"github.com/NdoleStudio/httpsms/pkg/entities"
7+
)
8+
9+
// UserSubscriptionCreated is raised when a user subscription is created
10+
const UserSubscriptionCreated = "user.subscription.created"
11+
12+
// UserSubscriptionCreatedPayload stores the data for the user created event
13+
type UserSubscriptionCreatedPayload struct {
14+
UserID entities.UserID `json:"user_id"`
15+
SubscriptionCreatedAt time.Time `json:"subscription_created_at"`
16+
SubscriptionID string `json:"subscription_id"`
17+
SubscriptionName entities.SubscriptionName `json:"subscription_name"`
18+
SubscriptionRenewsAt time.Time `json:"subscription_renews_at"`
19+
SubscriptionStatus string `json:"subscription_status"`
20+
}

api/pkg/handlers/handler.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,7 @@ func (h *handler) userFromContext(c *fiber.Ctx) entities.AuthUser {
9797
func (h *handler) userIDFomContext(c *fiber.Ctx) entities.UserID {
9898
return h.userFromContext(c).ID
9999
}
100+
101+
func (h *handler) computeRoute(middlewares []fiber.Handler, route fiber.Handler) []fiber.Handler {
102+
return append(append([]fiber.Handler{}, middlewares...), route)
103+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/NdoleStudio/httpsms/pkg/services"
9+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
10+
"github.com/NdoleStudio/httpsms/pkg/validators"
11+
lemonsqueezy "github.com/NdoleStudio/lemonsqueezy-go"
12+
"github.com/davecgh/go-spew/spew"
13+
"github.com/gofiber/fiber/v2"
14+
"github.com/palantir/stacktrace"
15+
)
16+
17+
// LemonsqueezyHandler handles lemonsqueezy events
18+
type LemonsqueezyHandler struct {
19+
handler
20+
logger telemetry.Logger
21+
tracer telemetry.Tracer
22+
service *services.LemonsqueezyService
23+
validator *validators.LemonsqueezyHandlerValidator
24+
}
25+
26+
// NewLemonsqueezyHandlerHandler creates a new LemonsqueezyHandler
27+
func NewLemonsqueezyHandlerHandler(
28+
logger telemetry.Logger,
29+
tracer telemetry.Tracer,
30+
service *services.LemonsqueezyService,
31+
validator *validators.LemonsqueezyHandlerValidator,
32+
) (h *LemonsqueezyHandler) {
33+
return &LemonsqueezyHandler{
34+
logger: logger.WithService(fmt.Sprintf("%T", h)),
35+
tracer: tracer,
36+
service: service,
37+
validator: validator,
38+
}
39+
}
40+
41+
// RegisterRoutes registers the routes for the MessageHandler
42+
func (h *LemonsqueezyHandler) RegisterRoutes(app *fiber.App, middlewares ...fiber.Handler) {
43+
router := app.Group("lemonsqueezy")
44+
router.Post("/event", h.computeRoute(middlewares, h.Event)...)
45+
}
46+
47+
// Event consumes a lemonsqueezy event
48+
// @Summary Consume a lemonsqueezy event
49+
// @Description Publish a lemonsqueezy event to the registered listeners
50+
// @Tags Lemonsqueezy
51+
// @Accept json
52+
// @Produce json
53+
// @Success 204 {object} responses.NoContent
54+
// @Failure 400 {object} responses.BadRequest
55+
// @Failure 401 {object} responses.Unauthorized
56+
// @Failure 422 {object} responses.UnprocessableEntity
57+
// @Failure 500 {object} responses.InternalServerError
58+
// @Router /lemonsqueezy/event [post]
59+
func (h *LemonsqueezyHandler) Event(c *fiber.Ctx) error {
60+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
61+
defer span.End()
62+
63+
signature := c.Get("X-Signature")
64+
if errors := h.validator.ValidateEvent(ctx, signature, c.Body()); len(errors) != 0 {
65+
msg := fmt.Sprintf("validation errors [%s], while storing request [%s] and signature [%s]", spew.Sdump(errors), c.Body(), signature)
66+
ctxLogger.Warn(stacktrace.NewError(msg))
67+
return h.responseUnprocessableEntity(c, errors, "validation errors while storing lemonsqueezy event")
68+
}
69+
70+
if err := h.handleRequest(ctx, c); err != nil {
71+
msg := fmt.Sprintf("cannot handle lemonsqueezy event [%s]", c.Body())
72+
ctxLogger.Error(stacktrace.Propagate(err, msg))
73+
return h.responseInternalServerError(c)
74+
}
75+
76+
return h.responseNoContent(c, "event consumed successfully")
77+
}
78+
79+
func (h *LemonsqueezyHandler) handleRequest(ctx context.Context, c *fiber.Ctx) error {
80+
eventName := c.Get("X-Event-Name")
81+
switch eventName {
82+
case "subscription_created":
83+
var request lemonsqueezy.WebHookRequestSubscription
84+
err := json.Unmarshal(c.Body(), &request)
85+
if err != nil {
86+
return stacktrace.Propagate(err, fmt.Sprintf("cannot marshall [%s] to [%T]", c.Body(), request))
87+
}
88+
return h.service.HandleSubscriptionCreatedEvent(ctx, c.OriginalURL(), &request)
89+
case "subscription_cancelled":
90+
var request lemonsqueezy.WebHookRequestSubscription
91+
err := json.Unmarshal(c.Body(), &request)
92+
if err != nil {
93+
return stacktrace.Propagate(err, fmt.Sprintf("cannot marshall [%s] to [%T]", c.Body(), request))
94+
}
95+
return h.service.HandleSubscriptionCanceledEvent(ctx, c.OriginalURL(), &request)
96+
default:
97+
return stacktrace.NewError(fmt.Sprintf("invalid event [%s] received with request [%s]", eventName, c.Body()))
98+
}
99+
}

api/pkg/handlers/user_handler.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ func NewUserHandler(
4141
func (h *UserHandler) RegisterRoutes(router fiber.Router) {
4242
router.Get("/users/me", h.Show)
4343
router.Put("/users/me", h.Update)
44+
router.Get("/users/subscription-update-url", h.subscriptionUpdateURL)
45+
router.Delete("/users/subscription", h.cancelSubscription)
4446
}
4547

4648
// Show returns an entities.User
@@ -116,3 +118,61 @@ func (h *UserHandler) Update(c *fiber.Ctx) error {
116118

117119
return h.responseOK(c, "user updated successfully", user)
118120
}
121+
122+
// subscriptionUpdateURL returns the subscription update URL for the authenticated entities.User
123+
// @Summary Currently authenticated user subscription update URL
124+
// @Description Fetches the subscription URL of the authenticated user.
125+
// @Security ApiKeyAuth
126+
// @Tags Users
127+
// @Produce json
128+
// @Success 200 {object} responses.OkString
129+
// @Failure 400 {object} responses.BadRequest
130+
// @Failure 401 {object} responses.Unauthorized
131+
// @Failure 422 {object} responses.UnprocessableEntity
132+
// @Failure 500 {object} responses.InternalServerError
133+
// @Router /users/subscription-update-url [get]
134+
func (h *UserHandler) subscriptionUpdateURL(c *fiber.Ctx) error {
135+
ctx, span := h.tracer.StartFromFiberCtx(c)
136+
defer span.End()
137+
138+
ctxLogger := h.tracer.CtxLogger(h.logger, span)
139+
authUser := h.userFromContext(c)
140+
141+
url, err := h.service.GetSubscriptionUpdateURL(ctx, authUser.ID)
142+
if err != nil {
143+
msg := fmt.Sprintf("cannot get user with ID [%s]", authUser.ID)
144+
ctxLogger.Error(stacktrace.Propagate(err, msg))
145+
return h.responseInternalServerError(c)
146+
}
147+
148+
return h.responseOK(c, "Subscription update URL fetched successfully", url)
149+
}
150+
151+
// cancelSubscription cancels the subscription for the authenticated entities.User
152+
// @Summary Cancel the user's subscription
153+
// @Description Cancel the subscription of the authenticated user.
154+
// @Security ApiKeyAuth
155+
// @Tags Users
156+
// @Produce json
157+
// @Success 200 {object} responses.NoContent
158+
// @Failure 400 {object} responses.BadRequest
159+
// @Failure 401 {object} responses.Unauthorized
160+
// @Failure 422 {object} responses.UnprocessableEntity
161+
// @Failure 500 {object} responses.InternalServerError
162+
// @Router /users/subscription [delete]
163+
func (h *UserHandler) cancelSubscription(c *fiber.Ctx) error {
164+
ctx, span := h.tracer.StartFromFiberCtx(c)
165+
defer span.End()
166+
167+
ctxLogger := h.tracer.CtxLogger(h.logger, span)
168+
authUser := h.userFromContext(c)
169+
170+
err := h.service.InitiateSubscriptionCancel(ctx, authUser.ID)
171+
if err != nil {
172+
msg := fmt.Sprintf("cannot get user with ID [%s]", authUser.ID)
173+
ctxLogger.Error(stacktrace.Propagate(err, msg))
174+
return h.responseInternalServerError(c)
175+
}
176+
177+
return h.responseNoContent(c, "Subscription cancelled successfully")
178+
}

0 commit comments

Comments
 (0)