• 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

0.0
/stripe/service.go
1
// Package stripe provides integration with the Stripe payment service,
2
// handling subscriptions, invoices, and webhook events.
3
package stripe
4

5
import (
6
        "encoding/json"
7
        "fmt"
8
        "time"
9

10
        "github.com/ethereum/go-ethereum/common"
11
        stripeapi "github.com/stripe/stripe-go/v82"
12
        "github.com/vocdoni/saas-backend/db"
13
        "go.vocdoni.io/dvote/log"
14
)
15

16
// Repository defines the database operations needed by the Stripe service
17
type Repository interface {
18
        Organization(address common.Address) (*db.Organization, error)
19
        SetOrganization(org *db.Organization) error
20
        SetOrganizationSubscription(address common.Address, subscription *db.OrganizationSubscription) error
21
        Plan(planID uint64) (*db.Plan, error)
22
        PlanByStripeID(stripeID string) (*db.Plan, error)
23
        DefaultPlan() (*db.Plan, error)
24
        SetPlan(plan *db.Plan) (uint64, error)
25
}
26

27
// EventStore defines the interface for storing and checking webhook events for idempotency
28
type EventStore interface {
29
        EventExists(eventID string) bool
30
        MarkProcessed(eventID string) error
31
}
32

33
// Service provides the main business logic for Stripe operations
34
type Service struct {
35
        client      *Client
36
        repository  Repository
37
        eventStore  EventStore
38
        lockManager *LockManager
39
        config      *Config
40
}
41

42
// NewService creates a new Stripe service
43
func NewService(config *Config, repository Repository, eventStore EventStore) (*Service, error) {
44
        if config == nil {
×
45
                return nil, fmt.Errorf("config is required")
×
46
        }
×
47
        if repository == nil {
×
48
                return nil, fmt.Errorf("repository is required")
×
49
        }
×
50
        if eventStore == nil {
×
51
                return nil, fmt.Errorf("eventStore is required")
×
52
        }
×
UNCOV
53

×
54
        client := NewClient(config)
55
        lockManager := NewLockManager()
×
56

×
57
        return &Service{
×
58
                client:      client,
×
59
                repository:  repository,
×
60
                eventStore:  eventStore,
×
61
                lockManager: lockManager,
×
62
                config:      config,
×
63
        }, nil
×
UNCOV
64
}
×
65

66
// ProcessWebhookEvent processes a webhook event with idempotency and proper locking
67
func (s *Service) ProcessWebhookEvent(payload []byte, signatureHeader string) error {
68
        // Validate and parse the event
×
69
        event, err := s.client.ValidateWebhookEvent(payload, signatureHeader)
×
70
        if err != nil {
×
71
                return err
×
72
        }
×
UNCOV
73

×
74
        // Check if event was already processed (idempotency)
75
        if s.eventStore.EventExists(event.ID) {
76
                log.Debugf("stripe webhook: event %s already processed, skipping", event.ID)
×
77
                return nil
×
78
        }
×
UNCOV
79

×
80
        // Process the event based on its type
81
        var processingErr error
82
        switch event.Type {
×
UNCOV
83
        case stripeapi.EventTypeCustomerSubscriptionCreated,
×
84
                stripeapi.EventTypeCustomerSubscriptionUpdated,
85
                stripeapi.EventTypeCustomerSubscriptionDeleted:
86
                processingErr = s.handleSubscription(event)
×
87
        case stripeapi.EventTypeInvoicePaymentSucceeded:
×
88
                processingErr = s.handleInvoicePayment(event)
×
89
        case stripeapi.EventTypeProductUpdated:
×
90
                processingErr = s.handleProductUpdate(event)
×
91
        default:
×
92
                log.Debugf("stripe webhook: received unhandled event type %s (event %s)", event.Type, event.ID)
×
UNCOV
93
                // For unknown events, we still mark them as processed to avoid reprocessing
×
94
        }
95

96
        // Mark event as processed if successful
97
        if processingErr == nil {
98
                if err := s.eventStore.MarkProcessed(event.ID); err != nil {
×
99
                        log.Errorf("stripe webhook: failed to mark event %s as processed: %v", event.ID, err)
×
100
                        // Don't return error here as the event was processed successfully
×
101
                }
×
UNCOV
102
        }
×
103

104
        return processingErr
UNCOV
105
}
×
106

107
// handleSubscription processes a subscription creation or update event
108
func (s *Service) handleSubscription(event *stripeapi.Event) error {
109
        subscriptionInfo, err := s.client.ParseSubscriptionFromEvent(event)
×
110
        if err != nil {
×
111
                return fmt.Errorf("failed to parse subscription from event: %w", err)
×
112
        }
×
UNCOV
113

×
114
        // Use per-organization locking
115
        unlock := s.lockManager.LockOrganization(subscriptionInfo.OrgAddress)
116
        defer unlock()
×
117

×
118
        // Get organization
×
119
        org, err := s.repository.Organization(subscriptionInfo.OrgAddress)
×
120
        if err != nil || org == nil {
×
121
                return NewStripeError("organization_not_found",
×
122
                        fmt.Sprintf("organization %s not found for subscription %s",
×
123
                                subscriptionInfo.OrgAddress, subscriptionInfo.ID), err)
×
124
        }
×
UNCOV
125

×
126
        // Handle different subscription statuses
127
        switch subscriptionInfo.Status {
128
        case stripeapi.SubscriptionStatusActive:
×
129
                return s.handleSubscriptionCreateOrUpdate(subscriptionInfo, org)
×
UNCOV
130
        case stripeapi.SubscriptionStatusCanceled,
×
131
                stripeapi.SubscriptionStatusUnpaid:
132
                return s.handleSubscriptionCancellation(subscriptionInfo.ID, org)
×
133
        default:
×
UNCOV
134
                // No action needed for other statuses
×
135
        }
136
        return nil
UNCOV
137
}
×
138

139
// handleSubscriptionCreateOrUpdate handles creating (or updating) a subscription.
140
func (s *Service) handleSubscriptionCreateOrUpdate(subscriptionInfo *SubscriptionInfo, org *db.Organization) error {
141
        // Get plan by Stripe product ID
×
142
        plan, err := s.repository.PlanByStripeID(subscriptionInfo.ProductID)
×
143
        if err != nil || plan == nil {
×
144
                return NewStripeError("plan_not_found",
×
145
                        fmt.Sprintf("plan with Stripe ID %s not found for subscription %s",
×
146
                                subscriptionInfo.ProductID, subscriptionInfo.ID), err)
×
147
        }
×
UNCOV
148

×
149
        org.Subscription.PlanID = plan.ID
150
        org.Subscription.StartDate = subscriptionInfo.StartDate
×
151
        org.Subscription.RenewalDate = subscriptionInfo.EndDate
×
152
        org.Subscription.Active = (subscriptionInfo.Status == stripeapi.SubscriptionStatusActive)
×
153
        org.Subscription.Email = subscriptionInfo.CustomerEmail
×
154

×
155
        // Save subscription
×
156
        if err := s.repository.SetOrganization(org); err != nil {
×
157
                return NewStripeError("database_error",
×
158
                        fmt.Sprintf("failed to save subscription %s (planID=%d, status=%s) for organization %s",
×
159
                                subscriptionInfo.ID, plan.ID, subscriptionInfo.Status, subscriptionInfo.OrgAddress), err)
×
160
        }
×
UNCOV
161

×
162
        log.Infof("stripe webhook: subscription %s (planID=%d, status=%s) saved for organization %s",
×
163
                subscriptionInfo.ID, plan.ID, subscriptionInfo.Status, subscriptionInfo.OrgAddress)
×
164
        return nil
165
}
UNCOV
166

×
UNCOV
167
// handleSubscriptionCancellation handles a canceled subscription by switching to the default plan
×
168
func (s *Service) handleSubscriptionCancellation(subscriptionID string, org *db.Organization) error {
×
169
        // Get default plan
×
170
        defaultPlan, err := s.repository.DefaultPlan()
×
171
        if err != nil || defaultPlan == nil {
×
172
                return NewStripeError("plan_not_found", "default plan not found", err)
×
173
        }
×
UNCOV
174

×
UNCOV
175
        // Create subscription with default plan
×
176
        orgSubscription := &db.OrganizationSubscription{
×
177
                PlanID:          defaultPlan.ID,
×
178
                StartDate:       time.Now(),
×
179
                LastPaymentDate: org.Subscription.LastPaymentDate,
180
                Active:          true,
181
        }
182

×
183
        if err := s.repository.SetOrganizationSubscription(org.Address, orgSubscription); err != nil {
×
184
                return NewStripeError("database_error",
×
185
                        fmt.Sprintf("failed to cancel subscription %s for organization %s",
×
186
                                subscriptionID, org.Address), err)
×
187
        }
×
188

189
        log.Infof("stripe webhook: subscription %s canceled for organization %s, switched to default plan",
190
                subscriptionID, org.Address)
×
191
        return nil
×
UNCOV
192
}
×
UNCOV
193

×
UNCOV
194
// handleInvoicePayment processes a successful payment event
×
195
func (s *Service) handleInvoicePayment(event *stripeapi.Event) error {
×
196
        invoiceInfo, err := s.client.ParseInvoiceFromEvent(event)
×
197
        if err != nil {
×
198
                return fmt.Errorf("failed to parse invoice from event: %w", err)
×
199
        }
×
UNCOV
200

×
UNCOV
201
        // Use per-organization locking
×
202
        unlock := s.lockManager.LockOrganization(invoiceInfo.OrgAddress)
×
203
        defer unlock()
204

×
205
        // Get organization
×
206
        org, err := s.repository.Organization(invoiceInfo.OrgAddress)
×
207
        if err != nil || org == nil {
208
                return NewStripeError("organization_not_found",
209
                        fmt.Sprintf("organization %s not found for payment %s",
210
                                invoiceInfo.OrgAddress, invoiceInfo.ID), err)
×
211
        }
×
UNCOV
212

×
UNCOV
213
        // Update last payment date
×
214
        org.Subscription.LastPaymentDate = invoiceInfo.PaymentTime
×
215
        if err := s.repository.SetOrganization(org); err != nil {
216
                return NewStripeError("database_error",
217
                        fmt.Sprintf("failed to update payment date for organization %s",
×
218
                                invoiceInfo.OrgAddress), err)
×
219
        }
×
UNCOV
220

×
221
        log.Infof("stripe webhook: payment %s processed for organization %s",
×
222
                invoiceInfo.ID, invoiceInfo.OrgAddress)
×
223
        return nil
×
UNCOV
224
}
×
UNCOV
225

×
UNCOV
226
// handleProductUpdate processes a product update event
×
227
func (s *Service) handleProductUpdate(event *stripeapi.Event) error {
228
        product, err := s.client.ParseProductFromEvent(event)
229
        if err != nil {
×
230
                return fmt.Errorf("failed to parse product from event: %w", err)
×
231
        }
×
UNCOV
232

×
UNCOV
233
        // Get the existing plan by Stripe product ID
×
234
        existingPlan, err := s.repository.PlanByStripeID(product.ID)
×
235
        if err != nil || existingPlan == nil {
236
                // If plan doesn't exist in our database, we can skip this update
×
237
                // This might happen if the product is not one of our configured plans
×
238
                log.Debugf("stripe webhook: product %s not found in database, skipping update", product.ID)
×
239
                return nil
240
        }
241

UNCOV
242
        // Update the plan with new product information
×
243
        updatedPlan, err := processProductToPlan(existingPlan.ID, product)
×
244
        if err != nil {
×
245
                return fmt.Errorf("failed to process updated product %s: %w", product.ID, err)
×
246
        }
×
247

248
        // Update the plan in the database
249
        if _, err := s.repository.SetPlan(updatedPlan); err != nil {
×
250
                return NewStripeError("database_error",
×
251
                        fmt.Sprintf("failed to update plan for product %s", product.ID), err)
×
252
        }
×
UNCOV
253

×
254
        log.Infof("stripe webhook: product %s updated, plan %d refreshed", product.ID, updatedPlan.ID)
×
255
        return nil
×
256
}
UNCOV
257

×
UNCOV
258
// CreateCheckoutSessionWithLookupKey creates a new checkout session by resolving the lookup key to get the plan
×
UNCOV
259
func (s *Service) CreateCheckoutSessionWithLookupKey(
×
UNCOV
260
        lookupKey uint64, returnURL string, orgAddress common.Address, locale string,
×
261
) (*stripeapi.CheckoutSession, error) {
262
        // Resolve the lookup key to get the plan
263
        plan, err := s.repository.Plan(lookupKey)
×
264
        if err != nil {
×
265
                return nil, NewStripeError("plan_not_found", fmt.Sprintf("plan with lookup key %d not found", lookupKey), err)
×
266
        }
×
267

268
        org, err := s.repository.Organization(orgAddress)
269
        if err != nil {
×
270
                return nil, NewStripeError("org_not_found", fmt.Sprintf("organization %s not found", orgAddress), err)
×
271
        }
×
UNCOV
272

×
273
        // Create checkout session parameters with the resolved Stripe price ID
274
        params := &CheckoutSessionParams{
×
275
                PriceID:       plan.StripePriceID,
×
276
                ReturnURL:     returnURL,
277
                OrgAddress:    orgAddress.Hex(),
278
                CustomerEmail: org.Creator,
279
                Locale:        locale,
280
                Quantity:      1,
281
        }
×
282

×
283
        return s.client.CreateCheckoutSession(params)
×
UNCOV
284
}
×
UNCOV
285

×
UNCOV
286
// CreateCheckoutSession creates a new checkout session
×
287
func (s *Service) CreateCheckoutSession(params *CheckoutSessionParams) (*stripeapi.CheckoutSession, error) {
288
        return s.client.CreateCheckoutSession(params)
×
289
}
×
UNCOV
290

×
UNCOV
291
// GetCheckoutSession retrieves a checkout session status
×
292
func (s *Service) GetCheckoutSession(sessionID string) (*CheckoutSessionStatus, error) {
293
        return s.client.GetCheckoutSession(sessionID)
294
}
UNCOV
295

×
UNCOV
296
// CreatePortalSession creates a billing portal session
×
297
func (s *Service) CreatePortalSession(customerEmail string) (*stripeapi.BillingPortalSession, error) {
×
298
        return s.client.CreatePortalSession(customerEmail)
×
299
}
300

UNCOV
301
// GetPlansFromStripe retrieves plans from Stripe and converts them to database format
×
302
func (s *Service) GetPlansFromStripe() ([]*db.Plan, error) {
×
303
        var plans []*db.Plan
×
304

×
305
        for i, p := range s.config.Plans {
×
306
                if p.ProductID == "" {
×
307
                        continue
×
UNCOV
308
                }
×
UNCOV
309

×
310
                product, err := s.client.GetProduct(p.ProductID)
×
311
                if err != nil {
×
312
                        return nil, fmt.Errorf("failed to get product %s: %w", p.ProductID, err)
×
313
                }
×
UNCOV
314

×
315
                plan, err := processProductToPlan(uint64(i), product)
×
316
                if err != nil {
×
317
                        return nil, fmt.Errorf("failed to process product %s: %w", p.ProductID, err)
×
318
                }
UNCOV
319

×
320
                plans = append(plans, plan)
321
        }
322

323
        return plans, nil
×
UNCOV
324
}
×
UNCOV
325

×
326
// processProductToPlan converts a Stripe product to a database plan
327
func processProductToPlan(planID uint64, product *stripeapi.Product) (*db.Plan, error) {
328
        organizationData, err := extractPlanMetadata[db.PlanLimits](product.Metadata["organization"])
×
329
        if err != nil {
×
330
                return nil, err
×
331
        }
332

333
        votingTypesData, err := extractPlanMetadata[db.VotingTypes](product.Metadata["votingTypes"])
×
334
        if err != nil {
×
335
                return nil, err
×
336
        }
337

338
        featuresData, err := extractPlanMetadata[db.Features](product.Metadata["features"])
×
339
        if err != nil {
×
340
                return nil, err
×
341
        }
×
UNCOV
342

×
343
        return &db.Plan{
×
344
                ID:            planID,
345
                Name:          product.Name,
346
                StartingPrice: product.DefaultPrice.UnitAmount,
×
347
                StripeID:      product.ID,
×
348
                StripePriceID: product.DefaultPrice.ID,
×
349
                Default:       isDefaultPlan(product),
×
350
                Organization:  organizationData,
351
                VotingTypes:   votingTypesData,
×
352
                Features:      featuresData,
×
353
        }, nil
×
UNCOV
354
}
×
355

UNCOV
356
// extractPlanMetadata extracts and parses plan metadata from a JSON string.
×
UNCOV
357
// It unmarshals the JSON data into the specified type T and returns it.
×
358
func extractPlanMetadata[T any](metadataValue string) (T, error) {
×
359
        var result T
×
360
        if err := json.Unmarshal([]byte(metadataValue), &result); err != nil {
361
                return result, fmt.Errorf("error parsing plan metadata JSON: %s", err.Error())
×
362
        }
363
        return result, nil
UNCOV
364
}
×
365

366
func isDefaultPlan(product *stripeapi.Product) bool {
367
        return product.DefaultPrice.Metadata["Default"] == "true"
368
}
×
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