• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vocdoni / saas-backend / 18531836246

15 Oct 2025 02:12PM UTC coverage: 57.067% (-1.0%) from 58.083%
18531836246

Pull #84

github

altergui
api: new handler listProcessDraftsHandler
Pull Request #84: api: support creating a draft process

26 of 114 new or added lines in 2 files covered. (22.81%)

265 existing lines in 8 files now uncovered.

5931 of 10393 relevant lines covered (57.07%)

33.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

3.3
/api/stripe_handlers.go
1
package api
2

3
import (
4
        "encoding/json"
5
        "fmt"
6
        "io"
7
        "net/http"
8
        "time"
9

10
        "github.com/ethereum/go-ethereum/common"
11
        "github.com/go-chi/chi/v5"
12
        "github.com/vocdoni/saas-backend/api/apicommon"
13
        "github.com/vocdoni/saas-backend/db"
14
        "github.com/vocdoni/saas-backend/errors"
15
        "github.com/vocdoni/saas-backend/stripe"
16
        "go.vocdoni.io/dvote/log"
17
)
18

19
// Constants for webhook handling
20
const (
21
        MaxBodyBytes = int64(65536) //revive:disable:unexported-naming
22
)
23

24
// StripeHandlers contains the Stripe service and handles HTTP requests
25
type StripeHandlers struct {
26
        service *stripe.Service
27
}
28

29
// NewStripeHandlers creates new Stripe HTTP handlers
30
func NewStripeHandlers(service *stripe.Service) *StripeHandlers {
×
31
        return &StripeHandlers{
×
32
                service: service,
×
33
        }
×
34
}
×
35

36
// handleWebhook godoc
37
//
38
//        @Summary                Handle Stripe webhook events
39
//        @Description        Process incoming webhook events from Stripe for subscription management. Handles subscription creation,
40
//        @Description        updates, deletions, and payment events with idempotency and proper error handling.
41
//        @Tags                        plans
42
//        @Accept                        json
43
//        @Produce                json
44
//        @Param                        body        body                string        true        "Stripe webhook payload"
45
//        @Success                200                {string}        string        "OK"
46
//        @Failure                400                {string}        string        "Bad Request"
47
//        @Failure                500                {string}        string        "Internal Server Error"
48
//        @Router                        /subscriptions/webhook [post]
49
func (h *StripeHandlers) HandleWebhook(w http.ResponseWriter, r *http.Request) {
×
50
        if h == nil || h.service == nil {
×
51
                log.Errorf("stripe webhook: Stripe service not available")
×
52
                w.WriteHeader(http.StatusServiceUnavailable)
×
53
                return
×
54
        }
×
55

56
        // Read and validate the request body
57
        r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
×
58
        payload, err := io.ReadAll(r.Body)
×
59
        if err != nil {
×
60
                log.Errorf("stripe webhook: error reading request body: %s", err.Error())
×
61
                w.WriteHeader(http.StatusBadRequest)
×
62
                return
×
63
        }
×
64

65
        // Get signature header
66
        signatureHeader := r.Header.Get("Stripe-Signature")
×
67
        if signatureHeader == "" {
×
68
                log.Errorf("stripe webhook: missing Stripe-Signature header")
×
69
                w.WriteHeader(http.StatusBadRequest)
×
70
                return
×
71
        }
×
72

73
        // Process the webhook event
74
        if err := h.service.ProcessWebhookEvent(payload, signatureHeader); err != nil {
×
75
                log.Errorf("stripe webhook: failed to process event: %v", err)
×
76

×
77
                // Check if it's a validation error (client error) or server error
×
78
                if stripeErr, ok := err.(*stripe.StripeError); ok {
×
79
                        switch stripeErr.Code {
×
80
                        case "webhook_validation", "invalid_event":
×
81
                                w.WriteHeader(http.StatusBadRequest)
×
82
                        case "organization_not_found", "plan_not_found":
×
83
                                // These are business logic errors that shouldn't cause retries
×
84
                                w.WriteHeader(http.StatusOK)
×
85
                        default:
×
86
                                w.WriteHeader(http.StatusInternalServerError)
×
87
                        }
88
                } else {
×
89
                        w.WriteHeader(http.StatusInternalServerError)
×
90
                }
×
91
                return
×
92
        }
93

94
        // Success
95
        w.WriteHeader(http.StatusOK)
×
96
}
97

98
// createSubscriptionCheckoutHandler godoc
99
//
100
//        @Summary                Create a subscription checkout session
101
//        @Description        Create a new Stripe checkout session for subscription purchases
102
//        @Tags                        plans
103
//        @Accept                        json
104
//        @Produce                json
105
//        @Security                BearerAuth
106
//        @Param                        request        body                apicommon.SubscriptionCheckout        true        "Checkout information"
107
//        @Success                200                {object}        map[string]string                                "Contains clientSecret and sessionID"
108
//        @Failure                400                {object}        errors.Error                                        "Invalid input data"
109
//        @Failure                401                {object}        errors.Error                                        "Unauthorized"
110
//        @Failure                404                {object}        errors.Error                                        "Organization or plan not found"
111
//        @Failure                500                {object}        errors.Error                                        "Internal server error"
112
//        @Router                        /subscriptions/checkout [post]
113
func (h *StripeHandlers) CreateSubscriptionCheckout(w http.ResponseWriter, r *http.Request) {
×
114
        if h == nil || h.service == nil {
×
115
                errors.ErrStripeError.Withf("Stripe service not available").Write(w)
×
116
                return
×
117
        }
×
118

119
        user, ok := apicommon.UserFromContext(r.Context())
×
120
        if !ok {
×
121
                errors.ErrUnauthorized.Write(w)
×
122
                return
×
123
        }
×
124

125
        checkout := &apicommon.SubscriptionCheckout{}
×
126
        if err := json.NewDecoder(r.Body).Decode(checkout); err != nil {
×
127
                errors.ErrMalformedBody.Write(w)
×
128
                return
×
129
        }
×
130

131
        if checkout.Address.Cmp(common.Address{}) == 0 {
×
132
                errors.ErrMalformedBody.Withf("Missing required fields").Write(w)
×
133
                return
×
134
        }
×
135

136
        if !user.HasRoleFor(checkout.Address, db.AdminRole) {
×
137
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
138
                return
×
139
        }
×
140

141
        // Create checkout session by resolving the lookup key to get the plan
142
        session, err := h.service.CreateCheckoutSessionWithLookupKey(
×
143
                checkout.LookupKey,
×
144
                checkout.ReturnURL,
×
145
                checkout.Address,
×
146
                checkout.Locale,
×
147
        )
×
148
        if err != nil {
×
149
                log.Errorf("failed to create checkout session: %v", err)
×
150
                errors.ErrStripeError.Withf("Cannot create session: %v", err).Write(w)
×
151
                return
×
152
        }
×
UNCOV
153

×
154
        data := &struct {
155
                ClientSecret string `json:"clientSecret"`
×
156
                SessionID    string `json:"sessionId"`
×
157
        }{
×
158
                ClientSecret: session.ClientSecret,
×
159
                SessionID:    session.ID,
×
160
        }
×
161

×
162
        apicommon.HTTPWriteJSON(w, data)
×
UNCOV
163
}
×
164

165
// checkoutSessionHandler godoc
166
//
167
//        @Summary                Get checkout session status
168
//        @Description        Retrieve the status of a Stripe checkout session
169
//        @Tags                        plans
170
//        @Accept                        json
171
//        @Produce                json
172
//        @Param                        sessionID        path                string        true        "Checkout session ID"
173
//        @Success                200                        {object}        stripe.CheckoutSessionStatus
174
//        @Failure                400                        {object}        errors.Error        "Invalid session ID"
175
//        @Failure                500                        {object}        errors.Error        "Internal server error"
176
//        @Router                        /subscriptions/checkout/{sessionID} [get]
177
func (h *StripeHandlers) GetCheckoutSession(w http.ResponseWriter, r *http.Request) {
178
        sessionID := chi.URLParam(r, "sessionID")
×
179
        if sessionID == "" {
×
180
                errors.ErrMalformedURLParam.Withf("sessionID is required").Write(w)
×
181
                return
×
182
        }
×
UNCOV
183

×
184
        status, err := h.service.GetCheckoutSession(sessionID)
185
        if err != nil {
×
186
                log.Errorf("failed to get checkout session: %v", err)
×
187
                errors.ErrStripeError.Withf("Cannot get session: %v", err).Write(w)
×
188
                return
×
189
        }
×
UNCOV
190

×
191
        apicommon.HTTPWriteJSON(w, status)
UNCOV
192
}
×
193

194
// createSubscriptionPortalSessionHandler godoc
195
//
196
//        @Summary                Create a subscription portal session
197
//        @Description        Create a Stripe customer portal session for managing subscriptions
198
//        @Tags                        plans
199
//        @Accept                        json
200
//        @Produce                json
201
//        @Security                BearerAuth
202
//        @Param                        address        path                string                                true        "Organization address"
203
//        @Success                200                {object}        map[string]string        "Contains portalURL"
204
//        @Failure                400                {object}        errors.Error                "Invalid input data"
205
//        @Failure                401                {object}        errors.Error                "Unauthorized"
206
//        @Failure                404                {object}        errors.Error                "Organization not found"
207
//        @Failure                500                {object}        errors.Error                "Internal server error"
208
//        @Router                        /subscriptions/{address}/portal [get]
209
func (h *StripeHandlers) CreateSubscriptionPortalSession(w http.ResponseWriter, r *http.Request, a *API) {
210
        user, ok := apicommon.UserFromContext(r.Context())
×
211
        if !ok {
×
212
                errors.ErrUnauthorized.Write(w)
×
213
                return
×
214
        }
×
UNCOV
215

×
216
        // Get the organization info from the request context
217
        org, _, ok := a.organizationFromRequest(r)
218
        if !ok {
×
219
                errors.ErrNoOrganizationProvided.Write(w)
×
220
                return
×
221
        }
×
UNCOV
222

×
223
        if !user.HasRoleFor(org.Address, db.AdminRole) {
224
                errors.ErrUnauthorized.Withf("user is not admin of organization").Write(w)
×
225
                return
×
226
        }
×
UNCOV
227

×
228
        session, err := h.service.CreatePortalSession(org.Creator)
229
        if err != nil {
×
230
                log.Errorf("failed to create portal session: %v", err)
×
231
                errors.ErrStripeError.Withf("Cannot create customer portal session: %v", err).Write(w)
×
232
                return
×
233
        }
×
UNCOV
234

×
235
        data := &struct {
236
                PortalURL string `json:"portalURL"`
×
237
        }{
×
238
                PortalURL: session.URL,
×
239
        }
×
240

×
241
        apicommon.HTTPWriteJSON(w, data)
×
UNCOV
242
}
×
243

244
// Repository adapter to make db.MongoStorage compatible with stripe.Repository
245
type RepositoryAdapter struct {
246
        db *db.MongoStorage
247
}
248

249
// NewRepositoryAdapter creates a new repository adapter
250
func NewRepositoryAdapter(database *db.MongoStorage) *RepositoryAdapter {
251
        return &RepositoryAdapter{db: database}
×
252
}
×
UNCOV
253

×
254
// Organization implements stripe.Repository
255
func (r *RepositoryAdapter) Organization(address common.Address) (*db.Organization, error) {
256
        return r.db.Organization(address)
×
257
}
×
UNCOV
258

×
259
// SetOrganization implements stripe.Repository
260
func (r *RepositoryAdapter) SetOrganization(org *db.Organization) error {
261
        return r.db.SetOrganization(org)
×
262
}
×
UNCOV
263

×
264
// SetOrganizationSubscription implements stripe.Repository
265
func (r *RepositoryAdapter) SetOrganizationSubscription(address common.Address, subscription *db.OrganizationSubscription) error {
266
        return r.db.SetOrganizationSubscription(address, subscription)
×
267
}
×
UNCOV
268

×
269
// Plan implements stripe.Repository
270
func (r *RepositoryAdapter) Plan(planID uint64) (*db.Plan, error) {
271
        return r.db.Plan(planID)
×
272
}
×
UNCOV
273

×
274
// PlanByStripeID implements stripe.Repository
275
func (r *RepositoryAdapter) PlanByStripeID(stripeID string) (*db.Plan, error) {
276
        return r.db.PlanByStripeID(stripeID)
×
277
}
×
UNCOV
278

×
279
// DefaultPlan implements stripe.Repository
280
func (r *RepositoryAdapter) DefaultPlan() (*db.Plan, error) {
281
        return r.db.DefaultPlan()
×
282
}
×
UNCOV
283

×
284
// SetPlan implements stripe.Repository
285
func (r *RepositoryAdapter) SetPlan(plan *db.Plan) (uint64, error) {
286
        return r.db.SetPlan(plan)
×
287
}
×
UNCOV
288

×
289
// InitializeStripeService initializes the Stripe service with proper configuration
290
func (a *API) InitializeStripeService() error {
291
        // Create Stripe configuration
1✔
292
        config, err := stripe.NewConfig()
1✔
293
        if err != nil {
1✔
294
                return err
2✔
295
        }
1✔
296

1✔
297
        // Create repository adapter
298
        repository := NewRepositoryAdapter(a.db)
299

×
300
        // Create event store (in production, use Redis or database)
×
301
        eventStore := stripe.NewMemoryEventStore(24 * time.Hour)
×
302

×
303
        // Create service
×
304
        service, err := stripe.NewService(config, repository, eventStore)
×
305
        if err != nil {
×
306
                return err
×
307
        }
×
UNCOV
308

×
309
        // Load plans from Stripe and populate the database
310
        plans, err := service.GetPlansFromStripe()
311
        if err != nil {
×
312
                return fmt.Errorf("failed to load plans from Stripe: %w", err)
×
313
        }
×
UNCOV
314

×
315
        // Store plans in database
316
        for _, plan := range plans {
317
                if _, err := a.db.SetPlan(plan); err != nil {
×
318
                        return fmt.Errorf("failed to store plan %s: %w", plan.Name, err)
×
319
                }
×
UNCOV
320
        }
×
321

322
        log.Infof("Loaded %d plans from Stripe", len(plans))
323

×
324
        // Create handlers
×
325
        a.stripeHandlers = NewStripeHandlers(service)
×
326

×
327
        log.Infof("Stripe service initialized successfully")
×
328
        return nil
×
UNCOV
329
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc