1package billing
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/google/uuid"
9 "github.com/stripe/stripe-go/v76"
10 "github.com/stripe/stripe-go/v76/subscription"
11)
12
13type Store interface {
14 GetPlan(ctx context.Context, id string) (*Plan, error)
15 GetPlanBySlug(ctx context.Context, slug string) (*Plan, error)
16 GetSubscription(ctx context.Context, id string) (*Subscription, error)
17 GetSubscriptionByCustomer(ctx context.Context, customerID string) (*Subscription, error)
18 GetSubscriptionByStripeID(ctx context.Context, stripeSubID string) (*Subscription, error)
19 CreateSubscription(ctx context.Context, sub *Subscription) error
20 UpdateSubscription(ctx context.Context, sub *Subscription) error
21 SaveWebhookEvent(ctx context.Context, eventID string) error
22 IsWebhookProcessed(ctx context.Context, eventID string) (bool, error)
23 RecordUsage(ctx context.Context, event *UsageEvent) error
24 GetUsageSummary(ctx context.Context, subID, metricID string, start, end time.Time) (int64, error)
25}
26
27type Service struct {
28 store Store
29}
30
31func NewService(store Store) *Service {
32 return &Service{store: store}
33}
34
35func (s *Service) CreateSubscription(
36 ctx context.Context,
37 customerID string,
38 planSlug string,
39 quantity int,
40 stripeCustomerID string,
41) (*Subscription, error) {
42 plan, err := s.store.GetPlanBySlug(ctx, planSlug)
43 if err != nil {
44 return nil, fmt.Errorf("get plan: %w", err)
45 }
46 if !plan.Active {
47 return nil, fmt.Errorf("plan %s is not active", planSlug)
48 }
49
50
51 params := &stripe.SubscriptionParams{
52 Customer: stripe.String(stripeCustomerID),
53 Items: []*stripe.SubscriptionItemsParams{
54 {
55 Price: stripe.String(plan.StripePriceID),
56 Quantity: stripe.Int64(int64(quantity)),
57 },
58 },
59 }
60
61 stripeSub, err := subscription.New(params)
62 if err != nil {
63 return nil, fmt.Errorf("create stripe subscription: %w", err)
64 }
65
66 sub := &Subscription{
67 ID: uuid.New().String(),
68 CustomerID: customerID,
69 PlanID: plan.ID,
70 StripeSubscriptionID: stripeSub.ID,
71 StripeCustomerID: stripeCustomerID,
72 Status: StatusActive,
73 Quantity: quantity,
74 CurrentPeriodStart: time.Unix(stripeSub.CurrentPeriodStart, 0),
75 CurrentPeriodEnd: time.Unix(stripeSub.CurrentPeriodEnd, 0),
76 CreatedAt: time.Now(),
77 UpdatedAt: time.Now(),
78 }
79
80 if err := s.store.CreateSubscription(ctx, sub); err != nil {
81 return nil, fmt.Errorf("save subscription: %w", err)
82 }
83
84 return sub, nil
85}
86
87func (s *Service) ChangePlan(
88 ctx context.Context,
89 subscriptionID string,
90 newPlanSlug string,
91) (*Subscription, *ProrationResult, error) {
92 sub, err := s.store.GetSubscription(ctx, subscriptionID)
93 if err != nil {
94 return nil, nil, fmt.Errorf("get subscription: %w", err)
95 }
96
97 currentPlan, err := s.store.GetPlan(ctx, sub.PlanID)
98 if err != nil {
99 return nil, nil, fmt.Errorf("get current plan: %w", err)
100 }
101
102 newPlan, err := s.store.GetPlanBySlug(ctx, newPlanSlug)
103 if err != nil {
104 return nil, nil, fmt.Errorf("get new plan: %w", err)
105 }
106
107 proration := CalculateProration(
108 currentPlan, newPlan, sub.Quantity, sub.Quantity,
109 sub.CurrentPeriodStart, sub.CurrentPeriodEnd, time.Now(),
110 )
111
112
113 params := &stripe.SubscriptionParams{
114 Items: []*stripe.SubscriptionItemsParams{
115 {
116 ID: stripe.String(sub.StripeSubscriptionID),
117 Price: stripe.String(newPlan.StripePriceID),
118 },
119 },
120 ProrationBehavior: stripe.String("create_prorations"),
121 }
122
123 _, err = subscription.Update(sub.StripeSubscriptionID, params)
124 if err != nil {
125 return nil, nil, fmt.Errorf("update stripe subscription: %w", err)
126 }
127
128 sub.PlanID = newPlan.ID
129 sub.UpdatedAt = time.Now()
130
131 if err := s.store.UpdateSubscription(ctx, sub); err != nil {
132 return nil, nil, fmt.Errorf("update subscription: %w", err)
133 }
134
135 return sub, proration, nil
136}
137
138func (s *Service) UpdateQuantity(
139 ctx context.Context,
140 subscriptionID string,
141 newQuantity int,
142) (*Subscription, error) {
143 sub, err := s.store.GetSubscription(ctx, subscriptionID)
144 if err != nil {
145 return nil, fmt.Errorf("get subscription: %w", err)
146 }
147
148 params := &stripe.SubscriptionParams{
149 Items: []*stripe.SubscriptionItemsParams{
150 {
151 ID: stripe.String(sub.StripeSubscriptionID),
152 Quantity: stripe.Int64(int64(newQuantity)),
153 },
154 },
155 ProrationBehavior: stripe.String("create_prorations"),
156 }
157
158 _, err = subscription.Update(sub.StripeSubscriptionID, params)
159 if err != nil {
160 return nil, fmt.Errorf("update stripe quantity: %w", err)
161 }
162
163 sub.Quantity = newQuantity
164 sub.UpdatedAt = time.Now()
165
166 if err := s.store.UpdateSubscription(ctx, sub); err != nil {
167 return nil, fmt.Errorf("update subscription: %w", err)
168 }
169
170 return sub, nil
171}
172
173func (s *Service) CancelSubscription(
174 ctx context.Context,
175 subscriptionID string,
176 immediately bool,
177) (*Subscription, error) {
178 sub, err := s.store.GetSubscription(ctx, subscriptionID)
179 if err != nil {
180 return nil, fmt.Errorf("get subscription: %w", err)
181 }
182
183 if immediately {
184 _, err = subscription.Cancel(sub.StripeSubscriptionID, nil)
185 } else {
186 params := &stripe.SubscriptionParams{
187 CancelAtPeriodEnd: stripe.Bool(true),
188 }
189 _, err = subscription.Update(sub.StripeSubscriptionID, params)
190 }
191
192 if err != nil {
193 return nil, fmt.Errorf("cancel stripe subscription: %w", err)
194 }
195
196 if immediately {
197 now := time.Now()
198 sub.Status = StatusCancelled
199 sub.CancelledAt = &now
200 } else {
201 sub.CancelAtPeriodEnd = true
202 }
203 sub.UpdatedAt = time.Now()
204
205 if err := s.store.UpdateSubscription(ctx, sub); err != nil {
206 return nil, fmt.Errorf("update subscription: %w", err)
207 }
208
209 return sub, nil
210}
211