Skip to content

Commit

Permalink
Initial stripe subscription payment integration
Browse files Browse the repository at this point in the history
  • Loading branch information
emmdim committed Dec 14, 2024
1 parent 888bb37 commit 75ce7c6
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 2 deletions.
7 changes: 7 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,15 @@ func (a *API) initRouter() http.Handler {
// get subscription info
log.Infow("new route", "method", "GET", "path", planInfoEndpoint)
r.Get(planInfoEndpoint, a.planInfoHandler)
// handle stripe webhook
log.Infow("new route", "method", "POST", "path", subscriptionsWebhook)
r.Post(subscriptionsWebhook, a.handleWebhook)
// handle stripe checkout session
log.Infow("new route", "method", "POST", "path", subscriptionsCheckout)
r.Post(subscriptionsCheckout, a.createSubscriptionCheckoutHandler)
// get stripe checkout session info
log.Infow("new route", "method", "GET", "path", subscriptionsCheckoutSession)
r.Get(subscriptionsCheckoutSession, a.checkoutSessionHandler)
})
a.router = r
return r
Expand Down
3 changes: 3 additions & 0 deletions api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
- [🏦 Plans](#-plans)
- [🛒 Get Available Plans](#-get-plans)
- [🛍️ Get Plan Info](#-get-plan-info)
- [Stripe](#-stripe)
- []
- []

</details>

Expand Down
1 change: 1 addition & 0 deletions api/errors_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ var (
ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")}
ErrCouldNotCreateFaucetPackage = Error{Code: 50003, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("could not create faucet package")}
ErrVochainRequestFailed = Error{Code: 50004, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("vochain request failed")}
ErrStripeError = Error{Code: 50005, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("stripe error")}
)
2 changes: 1 addition & 1 deletion api/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ func (a *API) getOrganizationSubscriptionHandler(w http.ResponseWriter, r *http.
return
}
if !org.Subscription.Active ||
(org.Subscription.EndDate.After(time.Now()) && org.Subscription.StartDate.Before(time.Now())) {
org.Subscription.EndDate.Before(time.Now()) || org.Subscription.StartDate.After(time.Now()) {
ErrOganizationSubscriptionIncative.Write(w)
return
}
Expand Down
4 changes: 4 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,8 @@ const (
planInfoEndpoint = "/plans/{planID}"
// POST /subscriptions/webhook to receive the subscription webhook from stripe
subscriptionsWebhook = "/subscriptions/webhook"
// POST /subscriptions/checkout to create a new subscription
subscriptionsCheckout = "/subscriptions/checkout"
// GET /subscriptions/checkout/{sessionID} to get the checkout session information
subscriptionsCheckoutSession = "/subscriptions/checkout/{sessionID}"
)
112 changes: 112 additions & 0 deletions api/stripe.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package api

import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"

"github.com/go-chi/chi/v5"
"github.com/vocdoni/saas-backend/db"
"go.vocdoni.io/dvote/log"
)
Expand Down Expand Up @@ -84,6 +87,115 @@ func (a *API) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
log.Debugf("stripe webhook: subscription %s for organization %s processed successfully", subscription.ID, org.Address)
case "customer.subscription.updated", "customer.subscription.deleted":
customer, subscription, err := a.stripe.GetInfoFromEvent(*event)
if err != nil {
log.Errorf("stripe webhook: error getting info from event: %s\n", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
address := subscription.Metadata["address"]
if len(address) == 0 {
log.Errorf("subscription %s does not contain an address in metadata", subscription.ID)
w.WriteHeader(http.StatusBadRequest)
return
}
org, _, err := a.db.Organization(address, false)
if err != nil || org == nil {
log.Errorf("could not update subscription %s, a corresponding organization with address %s was not found.",
subscription.ID, address)
log.Errorf("please do manually for creator %s \n Error: %s", customer.Email, err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
if subscription.Status == "canceled" && org.Subscription.Active {
// replace organization subscription with the default plan
defaultPlan, err := a.db.DefaultPlan()
if err != nil || defaultPlan == nil {
ErrNoDefaultPLan.WithErr((err)).Write(w)
return
}
orgSubscription := &db.OrganizationSubscription{
PlanID: defaultPlan.ID,
StartDate: time.Now(),
Active: true,
MaxCensusSize: defaultPlan.Organization.MaxCensus,
}
if err := a.db.SetOrganizationSubscription(org.Address, orgSubscription); err != nil {
log.Errorf("could not cancel subscription %s for organization %s: %s", subscription.ID, org.Address, err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
} else if subscription.Status == "active" && !org.Subscription.Active {
org.Subscription.Active = true
if err := a.db.SetOrganization(org); err != nil {
log.Errorf("could activate organizations %s subscription to active: %s", org.Address, err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
}
log.Debugf("stripe webhook: subscription %s for organization %s processed as %s successfully",
subscription.ID, org.Address, subscription.Status)
}
w.WriteHeader(http.StatusOK)
}

func (a *API) createSubscriptionCheckoutHandler(w http.ResponseWriter, r *http.Request) {
checkout := &SubscriptionCheckout{}
if err := json.NewDecoder(r.Body).Decode(checkout); err != nil {
ErrMalformedBody.Write(w)
return
}

if checkout.LookupKey == "" || checkout.ReturnURL == "" ||
checkout.Amount == "" || checkout.Address == "" {
ErrMalformedBody.Withf("Missing required fields").Write(w)
return
}

lookupKey, err := strconv.ParseUint(checkout.LookupKey, 10, 64)
if err != nil {
ErrMalformedURLParam.Withf("Invalid plan lookup key: %v", err).Write(w)
return
}

amount, err := strconv.ParseInt(checkout.Amount, 10, 64)
if err != nil {
ErrMalformedURLParam.Withf("Invalid census amount: %v", err).Write(w)
return
}

plan, err := a.db.Plan(lookupKey)
if err != nil {
ErrMalformedURLParam.Withf("Plan not found: %v", err).Write(w)
return
}

session, err := a.stripe.CreateSubscriptionCheckoutSession(plan.StripePriceID, checkout.ReturnURL, checkout.Address, amount)
if err != nil {
ErrStripeError.Withf("Cannot create session: %v", err).Write(w)
return
}

data := &struct {
ClientSecret string `json:"clientSecret"`
}{
ClientSecret: session.ClientSecret,
}
httpWriteJSON(w, data)
}

func (a *API) checkoutSessionHandler(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "sessionID")
if sessionID == "" {
ErrMalformedURLParam.Withf("sessionID is required").Write(w)
return
}
status, err := a.stripe.RetrieveCheckoutSession(sessionID)
if err != nil {
ErrStripeError.Withf("Cannot get session: %v", err).Write(w)
return
}

httpWriteJSON(w, status)
}
7 changes: 7 additions & 0 deletions api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,10 @@ type OrganizationSubscriptionInfo struct {
Usage *db.OrganizationCounters `json:"usage"`
Plan *db.Plan `json:"plan"`
}

type SubscriptionCheckout struct {
LookupKey string `json:"lookupKey"`
ReturnURL string `json:"returnURL"`
Amount string `json:"amount"`
Address string `json:"address"`
}
1 change: 1 addition & 0 deletions db/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ type Plan struct {
ID uint64 `json:"id" bson:"_id"`
Name string `json:"name" bson:"name"`
StripeID string `json:"stripeID" bson:"stripeID"`
StripePriceID string `json:"stripePriceID" bson:"stripePriceID"`
StartingPrice int64 `json:"startingPrice" bson:"startingPrice"`
Default bool `json:"default" bson:"default"`
Organization PlanLimits `json:"organization" bson:"organization"`
Expand Down
64 changes: 63 additions & 1 deletion stripe/stripe.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
"github.com/stripe/stripe-go/v81/customer"
"github.com/stripe/stripe-go/v81/price"
"github.com/stripe/stripe-go/v81/product"
Expand All @@ -20,6 +21,12 @@ var ProductsIDs = []string{
"prod_RHurAb3OjkgJRy", // Custom
}

type ReturnStatus struct {
Status string `json:"status"`
CustomerEmail string `json:"customer_email"`
SubscriptionStatus string `json:"subscription_status"`
}

// StripeClient is a client for interacting with the Stripe API.
// It holds the necessary configuration such as the webhook secret.
type StripeClient struct {
Expand Down Expand Up @@ -138,7 +145,8 @@ func (s *StripeClient) GetPlans() ([]*db.Plan, error) {
ID: uint64(i),
Name: product.Name,
StartingPrice: startingPrice,
StripeID: price.ID,
StripeID: productID,
StripePriceID: price.ID,
Default: price.Metadata["Default"] == "true",
Organization: organizationData,
VotingTypes: votingTypesData,
Expand All @@ -151,3 +159,57 @@ func (s *StripeClient) GetPlans() ([]*db.Plan, error) {
}
return plans, nil
}

func (s *StripeClient) CreateSubscriptionCheckoutSession(
priceID, returnURL, address string, amount int64,
) (*stripe.CheckoutSession, error) {
checkoutParams := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
AdjustableQuantity: &stripe.CheckoutSessionLineItemAdjustableQuantityParams{
Enabled: stripe.Bool(true),
Minimum: stripe.Int64(1),
Maximum: stripe.Int64(1000),
},
Quantity: stripe.Int64(amount),
},
},
UIMode: stripe.String(string(stripe.CheckoutSessionUIModeEmbedded)),
ReturnURL: stripe.String(returnURL + "/{CHECKOUT_SESSION_ID}"),
AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
Enabled: stripe.Bool(true),
},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{
"address": address,
},
},
}
session, err := session.New(checkoutParams)
if err != nil {
return nil, err
}

return session, nil
}

// RetrieveCheckoutSession retrieves a checkout session from Stripe by session ID.
// It returns a ReturnStatus object and an error if any.
// The ReturnStatus object contains information about the session status, customer email,
// faucet package, recipient, and quantity.
func (s *StripeClient) RetrieveCheckoutSession(sessionID string) (*ReturnStatus, error) {
params := &stripe.CheckoutSessionParams{}
params.AddExpand("line_items")
sess, err := session.Get(sessionID, params)
if err != nil {
return nil, err
}
data := &ReturnStatus{
Status: string(sess.Status),
CustomerEmail: sess.CustomerDetails.Email,
SubscriptionStatus: string(sess.Subscription.Status),
}
return data, nil
}

0 comments on commit 75ce7c6

Please sign in to comment.