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

vocdoni / saas-backend / 17604078704

10 Sep 2025 05:23AM UTC coverage: 59.0% (+0.2%) from 58.841%
17604078704

Pull #238

github

altergui
api: remove CensusSizeTiers

api: remove amount param from createSubscriptionCheckoutHandler
Pull Request #238: f/remove price tiers

3 of 21 new or added lines in 3 files covered. (14.29%)

2 existing lines in 2 files now uncovered.

5674 of 9617 relevant lines covered (59.0%)

32.04 hits per line

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

17.62
/stripe/stripe.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
        //revive:disable:import-alias-naming
11
        "github.com/ethereum/go-ethereum/common"
12
        stripeapi "github.com/stripe/stripe-go/v81"
13
        stripePortalSession "github.com/stripe/stripe-go/v81/billingportal/session"
14
        stripeCheckoutSession "github.com/stripe/stripe-go/v81/checkout/session"
15
        stripeCustomer "github.com/stripe/stripe-go/v81/customer"
16
        stripePrice "github.com/stripe/stripe-go/v81/price"
17
        stripeProduct "github.com/stripe/stripe-go/v81/product"
18
        stripeWebhook "github.com/stripe/stripe-go/v81/webhook"
19
        stripeDB "github.com/vocdoni/saas-backend/db"
20
        "go.vocdoni.io/dvote/log"
21
)
22

23
// ProductsIDs contains the Stripe product IDs for different subscription tiers
24
var ProductsIDs = []string{
25
        "prod_R3LTVsjklmuQAL", // Essential
26
        "prod_R0kTryoMNl8I19", // Premium
27
        "prod_RFObcbvED7MYbz", // Free
28
        "prod_RHurAb3OjkgJRy", // Custom
29
}
30

31
// ReturnStatus represents the response structure for checkout session status
32
type ReturnStatus struct {
33
        Status             string `json:"status"`
34
        CustomerEmail      string `json:"customer_email"`
35
        SubscriptionStatus string `json:"subscription_status"`
36
}
37

38
// SubscriptionInfo represents the information related to a Stripe subscription
39
// that are relevant for the application.
40
type SubscriptionInfo struct {
41
        ID                  string
42
        Status              string
43
        ProductID           string
44
        Quantity            int
45
        OrganizationAddress common.Address
46
        CustomerEmail       string
47
        StartDate           time.Time
48
        EndDate             time.Time
49
}
50

51
// whSecret is the global webhook secret.
52
var (
53
        whSecret    string
54
        initialized bool
55
)
56

57
// Init initializes the global Stripe client configuration.
58
// It must be called once during application startup.
59
func Init(apiSecret, webhookSecret string) {
×
60
        if initialized {
×
61
                panic("stripe.Init called more than once")
×
62
        }
63
        stripeapi.Key = apiSecret
×
64
        whSecret = webhookSecret
×
65
        initialized = true
×
66
}
67

68
// DecodeEvent decodes a Stripe webhook event from the given payload and signature header.
69
// It verifies the webhook signature and returns the decoded event or an error if validation fails.
70
func DecodeEvent(payload []byte, signatureHeader string) (*stripeapi.Event, error) {
×
71
        event := stripeapi.Event{}
×
72
        if err := json.Unmarshal(payload, &event); err != nil {
×
73
                log.Errorf("stripe webhook: error while parsing basic request. %s", err.Error())
×
74
                return nil, err
×
75
        }
×
76

77
        event, err := stripeWebhook.ConstructEvent(payload, signatureHeader, whSecret)
×
78
        if err != nil {
×
79
                log.Errorf("stripe webhook: webhook signature verification failed. %s", err.Error())
×
80
                return nil, err
×
81
        }
×
82
        return &event, nil
×
83
}
84

85
func GetInvoiceInfoFromEvent(event stripeapi.Event) (time.Time, string, error) {
×
86
        var invoice stripeapi.Invoice
×
87
        err := json.Unmarshal(event.Data.Raw, &invoice)
×
88
        if err != nil {
×
89
                return time.Time{}, "", fmt.Errorf("error parsing webhook JSON: %v", err)
×
90
        }
×
91
        if invoice.EffectiveAt == 0 {
×
92
                return time.Time{}, "", fmt.Errorf("invoice %s does not contain an effective date", invoice.ID)
×
93
        }
×
94
        if invoice.SubscriptionDetails == nil {
×
95
                return time.Time{}, "", fmt.Errorf("invoice %s does not contain subscription details", invoice.ID)
×
96
        }
×
97
        return time.Unix(invoice.EffectiveAt, 0), invoice.SubscriptionDetails.Metadata["address"], nil
×
98
}
99

100
// GetSubscriptionInfoFromEvent processes a Stripe event to extract subscription information.
101
// It unmarshals the event data and retrieves the associated customer and subscription details.
102
func GetSubscriptionInfoFromEvent(event stripeapi.Event) (*SubscriptionInfo, error) {
×
103
        var subscription stripeapi.Subscription
×
104
        err := json.Unmarshal(event.Data.Raw, &subscription)
×
105
        if err != nil {
×
106
                return &SubscriptionInfo{}, fmt.Errorf("error parsing webhook JSON: %v", err)
×
107
        }
×
108

109
        params := &stripeapi.CustomerParams{}
×
110
        customer, err := stripeCustomer.Get(subscription.Customer.ID, params)
×
111
        if err != nil || customer == nil {
×
112
                return &SubscriptionInfo{}, fmt.Errorf(
×
113
                        "could not update subscription %s, stripe internal error getting customer",
×
114
                        subscription.ID,
×
115
                )
×
116
        }
×
117
        address := common.HexToAddress(subscription.Metadata["address"])
×
118
        if len(address) == 0 {
×
119
                return &SubscriptionInfo{}, fmt.Errorf("subscription %s does not contain an address in metadata", subscription.ID)
×
120
        }
×
121

122
        if len(subscription.Items.Data) == 0 {
×
123
                return &SubscriptionInfo{}, fmt.Errorf("subscription %s does not contain any items", subscription.ID)
×
124
        }
×
125

126
        return &SubscriptionInfo{
×
127
                ID:                  subscription.ID,
×
128
                Status:              string(subscription.Status),
×
129
                ProductID:           subscription.Items.Data[0].Plan.Product.ID,
×
130
                Quantity:            int(subscription.Items.Data[0].Quantity),
×
131
                OrganizationAddress: address,
×
132
                CustomerEmail:       customer.Email,
×
133
                StartDate:           time.Unix(subscription.CurrentPeriodStart, 0),
×
134
                EndDate:             time.Unix(subscription.CurrentPeriodEnd, 0),
×
135
        }, nil
×
136
}
137

138
// GetPriceByID retrieves a Stripe price object by its ID.
139
// It searches for an active price with the given lookup key.
140
// Returns nil if no matching price is found.
141
func GetPriceByID(priceID string) *stripeapi.Price {
×
142
        params := &stripeapi.PriceSearchParams{
×
143
                SearchParams: stripeapi.SearchParams{
×
144
                        Query: fmt.Sprintf("active:'true' AND lookup_key:'%s'", priceID),
×
145
                },
×
146
        }
×
147
        params.AddExpand("data.tiers")
×
148
        if results := stripePrice.Search(params); results.Next() {
×
149
                return results.Price()
×
150
        }
×
151
        return nil
×
152
}
153

154
// GetProductByID retrieves a Stripe product by its ID.
155
// It expands the default price and its tiers in the response.
156
// Returns the product object and any error encountered.
157
func GetProductByID(productID string) (*stripeapi.Product, error) {
×
158
        params := &stripeapi.ProductParams{}
×
159
        params.AddExpand("default_price")
×
160
        params.AddExpand("default_price.tiers")
×
161
        product, err := stripeProduct.Get(productID, params)
×
162
        if err != nil {
×
163
                return nil, err
×
164
        }
×
165
        return product, nil
×
166
}
167

168
// GetPrices retrieves multiple Stripe prices by their IDs.
169
// It returns a slice of Price objects for all valid price IDs.
170
// Invalid or non-existent price IDs are silently skipped.
171
func GetPrices(priceIDs []string) []*stripeapi.Price {
×
172
        var prices []*stripeapi.Price
×
173
        for _, priceID := range priceIDs {
×
174
                if price := GetPriceByID(priceID); price != nil {
×
175
                        prices = append(prices, price)
×
176
                }
×
177
        }
178
        return prices
×
179
}
180

181
// extractPlanMetadata extracts and parses plan metadata from a Stripe product.
182
// It handles unmarshaling of organization limits, voting types, and features.
183
//
184
//revive:disable:function-result-limit
185
func extractPlanMetadata(product *stripeapi.Product) (
186
        stripeDB.PlanLimits, stripeDB.VotingTypes, stripeDB.Features, error,
187
) {
×
188
        var organizationData stripeDB.PlanLimits
×
189
        if err := json.Unmarshal([]byte(product.Metadata["organization"]), &organizationData); err != nil {
×
190
                return stripeDB.PlanLimits{}, stripeDB.VotingTypes{}, stripeDB.Features{},
×
191
                        fmt.Errorf("error parsing plan organization metadata JSON: %s", err.Error())
×
192
        }
×
193

194
        var votingTypesData stripeDB.VotingTypes
×
195
        if err := json.Unmarshal([]byte(product.Metadata["votingTypes"]), &votingTypesData); err != nil {
×
196
                return stripeDB.PlanLimits{}, stripeDB.VotingTypes{}, stripeDB.Features{},
×
197
                        fmt.Errorf("error parsing plan voting types metadata JSON: %s", err.Error())
×
198
        }
×
199

200
        var featuresData stripeDB.Features
×
201
        if err := json.Unmarshal([]byte(product.Metadata["features"]), &featuresData); err != nil {
×
202
                return stripeDB.PlanLimits{}, stripeDB.VotingTypes{}, stripeDB.Features{},
×
203
                        fmt.Errorf("error parsing plan features metadata JSON: %s", err.Error())
×
204
        }
×
205

206
        return organizationData, votingTypesData, featuresData, nil
×
207
}
208

209
// processProduct converts a Stripe product to an application-specific plan.
210
func processProduct(index int, productID string, product *stripeapi.Product) (*stripeDB.Plan, error) {
×
211
        organizationData, votingTypesData, featuresData, err := extractPlanMetadata(product)
×
212
        if err != nil {
×
213
                return nil, err
×
214
        }
×
215

216
        return &stripeDB.Plan{
×
NEW
217
                ID:            uint64(index + 1),
×
NEW
218
                Name:          product.Name,
×
NEW
219
                StartingPrice: product.DefaultPrice.UnitAmount,
×
NEW
220
                StripeID:      productID,
×
NEW
221
                StripePriceID: product.DefaultPrice.ID,
×
NEW
222
                Default:       product.DefaultPrice.Metadata["Default"] == "true",
×
NEW
223
                Organization:  organizationData,
×
NEW
224
                VotingTypes:   votingTypesData,
×
NEW
225
                Features:      featuresData,
×
UNCOV
226
        }, nil
×
227
}
228

229
// GetPlans retrieves and constructs a list of subscription plans from Stripe products.
230
// It processes product metadata to extract organization limits, voting types, and features.
231
// Returns a slice of Plan objects and any error encountered during processing.
232
func GetPlans() ([]*stripeDB.Plan, error) {
×
233
        var plans []*stripeDB.Plan
×
234

×
235
        for i, productID := range ProductsIDs {
×
236
                product, err := GetProductByID(productID)
×
237
                if err != nil || product == nil {
×
238
                        return nil, fmt.Errorf("error getting product %s: %s", productID, err.Error())
×
239
                }
×
240

241
                plan, err := processProduct(i, productID, product)
×
242
                if err != nil {
×
243
                        return nil, err
×
244
                }
×
245

246
                plans = append(plans, plan)
×
247
        }
248

249
        return plans, nil
×
250
}
251

252
// CreateSubscriptionCheckoutSession creates a new Stripe checkout session for a subscription.
253
// It configures the session with the specified price, amount return URL, and subscription metadata.
254
// The email provided is used in order to uniquely distinguish the customer on the Stripe side.
255
// The priceID is that is provided corrsponds to the subscription tier selected by the user.
256
// Returns the created checkout session and any error encountered.
257
// Overview of stripe checkout mechanics: https://docs.stripe.com/checkout/custom/quickstart
258
// API description https://docs.stripe.com/api/checkout/sessions
259
func CreateSubscriptionCheckoutSession(
260
        priceID, returnURL, address, email, locale string,
261
) (*stripeapi.CheckoutSession, error) {
1✔
262
        if len(locale) == 0 {
1✔
263
                locale = "auto"
×
264
        }
×
265
        if locale == "ca" {
1✔
266
                locale = "es"
×
267
        }
×
268
        checkoutParams := &stripeapi.CheckoutSessionParams{
1✔
269
                // Subscription mode
1✔
270
                Mode:          stripeapi.String(string(stripeapi.CheckoutSessionModeSubscription)),
1✔
271
                CustomerEmail: &email,
1✔
272
                LineItems: []*stripeapi.CheckoutSessionLineItemParams{
1✔
273
                        {
1✔
274
                                Price:    stripeapi.String(priceID),
1✔
275
                                Quantity: stripeapi.Int64(1),
1✔
276
                        },
1✔
277
                },
1✔
278
                // UI mode is set to embedded, since the client is integrated in our UI
1✔
279
                UIMode: stripeapi.String(string(stripeapi.CheckoutSessionUIModeEmbedded)),
1✔
280
                // Automatic tax calculation is enabled
1✔
281
                AutomaticTax: &stripeapi.CheckoutSessionAutomaticTaxParams{
1✔
282
                        Enabled: stripeapi.Bool(true),
1✔
283
                },
1✔
284
                // We store in the metadata the address of the organization
1✔
285
                SubscriptionData: &stripeapi.CheckoutSessionSubscriptionDataParams{
1✔
286
                        Metadata: map[string]string{
1✔
287
                                "address": address,
1✔
288
                        },
1✔
289
                },
1✔
290
                // The locale is being used to configure the language of the embedded client
1✔
291
                Locale: stripeapi.String(locale),
1✔
292
        }
1✔
293

1✔
294
        // The returnURL is used to redirect the user after the payment is completed
1✔
295
        if len(returnURL) > 0 {
2✔
296
                checkoutParams.ReturnURL = stripeapi.String(returnURL + "/{CHECKOUT_SESSION_ID}")
1✔
297
        } else {
1✔
298
                checkoutParams.RedirectOnCompletion = stripeapi.String("never")
×
299
        }
×
300
        session, err := stripeCheckoutSession.New(checkoutParams)
1✔
301
        if err != nil {
2✔
302
                return nil, err
1✔
303
        }
1✔
304

305
        return session, nil
×
306
}
307

308
// RetrieveCheckoutSession retrieves a checkout session from Stripe by session ID.
309
// It returns a ReturnStatus object and an error if any.
310
// The ReturnStatus object contains information about the session status,
311
// customer email, and subscription status.
312
func RetrieveCheckoutSession(sessionID string) (*ReturnStatus, error) {
×
313
        params := &stripeapi.CheckoutSessionParams{}
×
314
        params.AddExpand("line_items")
×
315
        sess, err := stripeCheckoutSession.Get(sessionID, params)
×
316
        if err != nil {
×
317
                return nil, err
×
318
        }
×
319
        data := &ReturnStatus{
×
320
                Status:             string(sess.Status),
×
321
                CustomerEmail:      sess.CustomerDetails.Email,
×
322
                SubscriptionStatus: string(sess.Subscription.Status),
×
323
        }
×
324
        return data, nil
×
325
}
326

327
// CreatePortalSession creates a new billing portal session for a customer based on an email address.
328
func CreatePortalSession(customerEmail string) (*stripeapi.BillingPortalSession, error) {
×
329
        // get stripe customer based on provided email
×
330
        customerParams := &stripeapi.CustomerListParams{
×
331
                Email: stripeapi.String(customerEmail),
×
332
        }
×
333
        var customerID string
×
334

×
335
        customers := stripeCustomer.List(customerParams)
×
336
        if !customers.Next() {
×
337
                return nil, fmt.Errorf("could not find customer with email %s", customerEmail)
×
338
        }
×
339
        customerID = customers.Customer().ID
×
340

×
341
        params := &stripeapi.BillingPortalSessionParams{
×
342
                Customer: &customerID,
×
343
        }
×
344
        return stripePortalSession.New(params)
×
345
}
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