Skip to content

Commit 0f7d743

Browse files
committed
Add billing service
1 parent d428451 commit 0f7d743

File tree

14 files changed

+418
-28
lines changed

14 files changed

+418
-28
lines changed

api/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
github.com/nyaruka/phonenumbers v1.1.4
2828
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177
2929
github.com/pkg/errors v0.9.1
30+
github.com/redis/go-redis/v9 v9.0.2
3031
github.com/rs/zerolog v1.28.0
3132
github.com/sendgrid/sendgrid-go v3.12.0+incompatible
3233
github.com/swaggo/swag v1.8.9
@@ -63,6 +64,8 @@ require (
6364
github.com/andybalholm/brotli v1.0.4 // indirect
6465
github.com/andybalholm/cascadia v1.3.1 // indirect
6566
github.com/cenkalti/backoff/v4 v4.2.0 // indirect
67+
github.com/cespare/xxhash/v2 v2.2.0 // indirect
68+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
6669
github.com/fatih/color v1.10.0 // indirect
6770
github.com/go-logr/logr v1.2.3 // indirect
6871
github.com/go-logr/stdr v1.2.2 // indirect

api/go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,17 @@ github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEq
9494
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
9595
github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
9696
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
97+
github.com/bsm/ginkgo/v2 v2.5.0 h1:aOAnND1T40wEdAtkGSkvSICWeQ8L3UASX7YVCqQx+eQ=
98+
github.com/bsm/gomega v1.20.0 h1:JhAwLmtRzXFTx2AkALSLa8ijZafntmhSoU63Ok18Uq8=
9799
github.com/carlmjohnson/requests v0.22.3 h1:ip16AKXNYuArdw9L5/1mL+mNorlZO5XhkLg617yOumc=
98100
github.com/carlmjohnson/requests v0.22.3/go.mod h1:iTsaX9TdFg2+L4WtZO/HFyDMPEfBnogV3i4A4gjDnvs=
99101
github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
100102
github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
101103
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
102104
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
103105
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
106+
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
107+
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
104108
github.com/cheggaaa/pb/v3 v3.0.8 h1:bC8oemdChbke2FHIIGy9mn4DPJ2caZYQnfbRqwmdCoA=
105109
github.com/cheggaaa/pb/v3 v3.0.8/go.mod h1:UICbiLec/XO6Hw6k+BHEtHeQFzzBH4i2/qk/ow1EJTA=
106110
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -128,6 +132,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
128132
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
129133
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
130134
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
135+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
136+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
131137
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
132138
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
133139
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -412,6 +418,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
412418
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
413419
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
414420
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
421+
github.com/redis/go-redis/v9 v9.0.2 h1:BA426Zqe/7r56kCcvxYLWe1mkaz71LKF77GwgFzSxfE=
422+
github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps=
415423
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
416424
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
417425
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=

api/pkg/cache/cache.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package cache
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// Cache stores items temporarily
9+
type Cache interface {
10+
Set(ctx context.Context, key string, value string, ttl time.Duration) error
11+
Get(ctx context.Context, key string) (value string, err error)
12+
}

api/pkg/cache/redis_cache.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cache
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
9+
"github.com/palantir/stacktrace"
10+
"github.com/redis/go-redis/v9"
11+
)
12+
13+
// RedisCache is the Cache implementation in redis
14+
type RedisCache struct {
15+
tracer telemetry.Tracer
16+
client *redis.Client
17+
}
18+
19+
// NewRedisCache creates a new instance of RedisCache
20+
func NewRedisCache(tracer telemetry.Tracer, client *redis.Client) Cache {
21+
return &RedisCache{
22+
tracer: tracer,
23+
client: client,
24+
}
25+
}
26+
27+
// Get an item from the redis cache
28+
func (cache *RedisCache) Get(ctx context.Context, key string) (value string, err error) {
29+
ctx, span := cache.tracer.Start(ctx)
30+
defer span.End()
31+
32+
response, err := cache.client.Get(ctx, key).Result()
33+
if err == redis.Nil {
34+
return "", stacktrace.Propagate(err, fmt.Sprintf("no item found in redis with key [%s]", key))
35+
}
36+
if err != nil {
37+
return "", stacktrace.Propagate(err, fmt.Sprintf("cannot get item in redis with key [%s]", key))
38+
}
39+
return response, nil
40+
}
41+
42+
// Set an item in the redis cache
43+
func (cache *RedisCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
44+
ctx, span := cache.tracer.Start(ctx)
45+
defer span.End()
46+
47+
err := cache.client.Set(ctx, key, value, ttl).Err()
48+
if err != nil {
49+
return cache.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot set item in redis"))
50+
}
51+
return nil
52+
}

api/pkg/di/container.go

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

11+
"github.com/NdoleStudio/httpsms/pkg/cache"
12+
"github.com/redis/go-redis/v9"
13+
1114
"github.com/NdoleStudio/go-otelroundtripper"
1215
"go.opentelemetry.io/otel/metric/global"
1316

@@ -248,6 +251,16 @@ func (container *Container) FirebaseApp() (app *firebase.App) {
248251
return app
249252
}
250253

254+
// Cache creates a new instance of cache.Cache
255+
func (container *Container) Cache() cache.Cache {
256+
container.logger.Debug("creating cache.Cache")
257+
opt, err := redis.ParseURL(os.Getenv("REDIS_URL"))
258+
if err != nil {
259+
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot parse redis url [%s]", os.Getenv("REDIS_URL"))))
260+
}
261+
return cache.NewRedisCache(container.Tracer(), redis.NewClient(opt))
262+
}
263+
251264
// FirebaseAuthClient creates a new instance of auth.Client
252265
func (container *Container) FirebaseAuthClient() (client *auth.Client) {
253266
container.logger.Debug(fmt.Sprintf("creating %T", client))
@@ -556,7 +569,11 @@ func (container *Container) BillingService() (service *services.BillingService)
556569
return services.NewBillingService(
557570
container.Logger(),
558571
container.Tracer(),
572+
container.Cache(),
573+
container.Mailer(),
574+
container.UserEmailFactory(),
559575
container.BillingUsageRepository(),
576+
container.UserRepository(),
560577
)
561578
}
562579

@@ -690,6 +707,7 @@ func (container *Container) MessageHandler() (handler *handlers.MessageHandler)
690707
container.Logger(),
691708
container.Tracer(),
692709
container.MessageHandlerValidator(),
710+
container.BillingService(),
693711
container.MessageService(),
694712
)
695713
}

api/pkg/emails/hermes_user_email_factory.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,97 @@ type hermesUserEmailFactory struct {
1414
generator hermes.Hermes
1515
}
1616

17+
// UsageLimitExceeded is the email sent when the plan limit is reached
18+
func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User) (*Email, error) {
19+
email := hermes.Email{
20+
Body: hermes.Body{
21+
Intros: []string{
22+
fmt.Sprintf("You have exceeded your limit of [%d] messages on your [%s] plan.", user.SubscriptionName.Limit(), user.SubscriptionName),
23+
fmt.Sprintf("Upgrade your plan to send more messages on https://httpsms.com/billing"),
24+
},
25+
Actions: []hermes.Action{
26+
{
27+
Instructions: "Click the button below to upgrade your plan",
28+
Button: hermes.Button{
29+
Color: "#329ef4",
30+
TextColor: "#FFFFFF",
31+
Text: "UPGRADE PLAN",
32+
Link: "https://httpsms.com/billing",
33+
},
34+
},
35+
},
36+
Title: "Hey,",
37+
Signature: "Cheers",
38+
Outros: []string{
39+
fmt.Sprintf("Don't hesitate to contact us by replying to this email."),
40+
},
41+
},
42+
}
43+
44+
html, err := factory.generator.GenerateHTML(email)
45+
if err != nil {
46+
return nil, stacktrace.Propagate(err, "cannot generate html email")
47+
}
48+
49+
text, err := factory.generator.GeneratePlainText(email)
50+
if err != nil {
51+
return nil, stacktrace.Propagate(err, "cannot generate text email")
52+
}
53+
54+
return &Email{
55+
ToEmail: user.Email,
56+
Subject: "⚠ You have exceeded your plan limit",
57+
HTML: html,
58+
Text: text,
59+
}, nil
60+
}
61+
62+
// UsageLimitAlert is the email sent when the plan limit is reached
63+
func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usage *entities.BillingUsage) (*Email, error) {
64+
percent := (usage.TotalMessages() * 100) / user.SubscriptionName.Limit()
65+
email := hermes.Email{
66+
Body: hermes.Body{
67+
Intros: []string{
68+
fmt.Sprintf("This is a friendly notification that you have exceeded %d of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName),
69+
fmt.Sprintf("You have sent %d messages and received %d messages. Upgrade your plan to send more messages on https://httpsms.com/billing", usage.SentMessages, usage.ReceivedMessages),
70+
},
71+
Actions: []hermes.Action{
72+
{
73+
Instructions: "Click the button below to upgrade your plan",
74+
Button: hermes.Button{
75+
Color: "#329ef4",
76+
TextColor: "#FFFFFF",
77+
Text: "UPGRADE PLAN",
78+
Link: "https://httpsms.com/billing",
79+
},
80+
},
81+
},
82+
Title: "Hey,",
83+
Signature: "Cheers",
84+
Outros: []string{
85+
fmt.Sprintf("Don't hesitate to contact us by replying to this email."),
86+
},
87+
},
88+
}
89+
90+
html, err := factory.generator.GenerateHTML(email)
91+
if err != nil {
92+
return nil, stacktrace.Propagate(err, "cannot generate html email")
93+
}
94+
95+
text, err := factory.generator.GeneratePlainText(email)
96+
if err != nil {
97+
return nil, stacktrace.Propagate(err, "cannot generate text email")
98+
}
99+
100+
return &Email{
101+
ToEmail: user.Email,
102+
Subject: fmt.Sprintf("⚠ %d%% Usage Limit Alert", percent),
103+
HTML: html,
104+
Text: text,
105+
}, nil
106+
}
107+
17108
// NewHermesUserEmailFactory creates a new instance of the UserEmailFactory
18109
func NewHermesUserEmailFactory(config *HermesGeneratorConfig) UserEmailFactory {
19110
return &hermesUserEmailFactory{

api/pkg/emails/user_email_factory.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ import (
1010
type UserEmailFactory interface {
1111
// PhoneDead sends an emails when the user's phone is not sending heartbeats
1212
PhoneDead(user *entities.User, lastHeartbeatTimestamp time.Time, owner string) (*Email, error)
13+
14+
// UsageLimitExceeded sends an email when the user's limit is exceeded
15+
UsageLimitExceeded(user *entities.User) (*Email, error)
16+
17+
// UsageLimitAlert sends an email when a user is approaching the limit
18+
UsageLimitAlert(user *entities.User, usage *entities.BillingUsage) (*Email, error)
1319
}

api/pkg/entities/billing_usage.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,8 @@ type BillingUsage struct {
1818
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
1919
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
2020
}
21+
22+
// TotalMessages returns the sum of sent and received messages
23+
func (usage *BillingUsage) TotalMessages() uint {
24+
return usage.SentMessages + usage.ReceivedMessages
25+
}

api/pkg/entities/user.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ type UserID string
1212
// SubscriptionName is the name of the subscription
1313
type SubscriptionName string
1414

15+
// Limit returns the limit of a subscription
16+
func (subscription SubscriptionName) Limit() uint {
17+
if subscription == SubscriptionNameFree {
18+
return 200
19+
}
20+
return 5000
21+
}
22+
1523
// SubscriptionNameFree represents a free subscription
1624
const SubscriptionNameFree = SubscriptionName("free")
1725

@@ -21,6 +29,9 @@ const SubscriptionNameProMonthly = SubscriptionName("pro-monthly")
2129
// SubscriptionNameProYearly represents a yearly pro subscription
2230
const SubscriptionNameProYearly = SubscriptionName("pro-yearly")
2331

32+
// SubscriptionNameProLifetime represents a pro lifetime subscription
33+
const SubscriptionNameProLifetime = SubscriptionName("pro-lifetime")
34+
2435
// User stores information about a user
2536
type User struct {
2637
ID UserID `json:"id" gorm:"primaryKey;type:string;" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
@@ -35,3 +46,8 @@ type User struct {
3546
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
3647
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
3748
}
49+
50+
// IsOnProPlan checks if a user is on the pro plan
51+
func (user User) IsOnProPlan() bool {
52+
return user.SubscriptionName == SubscriptionNameProLifetime || user.SubscriptionName == SubscriptionNameProMonthly || user.SubscriptionName == SubscriptionNameProYearly
53+
}

api/pkg/handlers/handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ func (h *handler) responseNotFound(c *fiber.Ctx, message string) error {
5757
})
5858
}
5959

60+
func (h *handler) responsePaymentRequired(c *fiber.Ctx, message string) error {
61+
return c.Status(fiber.StatusPaymentRequired).JSON(fiber.Map{
62+
"status": "error",
63+
"message": message,
64+
})
65+
}
66+
6067
func (h *handler) responseNoContent(c *fiber.Ctx, message string) error {
6168
return c.Status(fiber.StatusNoContent).JSON(fiber.Map{
6269
"status": "success",

0 commit comments

Comments
 (0)