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 19, 2024
1 parent 888bb37 commit 6e98ac1
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 19 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
60 changes: 58 additions & 2 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)
- [Subscriptions](#-subscriptions)
- []
- []

</details>

Expand Down Expand Up @@ -324,8 +327,7 @@ This endpoint only returns the addresses of the organizations where the current
"subscription":{
"PlanID":3,
"StartDate":"2024-11-07T15:25:49.218Z",
"EndDate":"0001-01-01T00:00:00Z",
"RenewalDate":"0001-01-01T00:00:00Z",
"RenewalDate":"2025-11-07T15:25:49.218Z",
"Active":true,
"MaxCensusSize":10
},
Expand Down Expand Up @@ -911,8 +913,62 @@ This request can be made only by organization admins.

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `400` | `40004` | `malformed JSON body` |
| `404` | `40009` | `plan not found` |
| `500` | `50001` | `internal server error` |


## Subscriptions

### 🛒 Create Checkout session

* **Path** `/subscriptions/checkout/`
* **Method** `POST`
* **Request Body**
```json
{
"lookupKey": 1, // PLan's corresponging DB ID
"returnURL": "https://example.com/return",
"address": "[email protected]",
"amount": 1000, // The desired maxCensusSize
}
```

* **Response**
```json
{
"id": "cs_test_a1b2c3d4e5f6g7h8i9j0",
// ... rest of stripe session attributes
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `400` | `40010` | `malformed URL parameter` |
| `400` | `40023` | `plan not found` |
| `500` | `50002` | `internal server error` |

### 🛍️ Get Checkout session info

* **Path** `/subscriptions/checkout/{sessionID}`
* **Method** `GET`
* **Response**
```json
{
"status": "complete", // session status
"customer_email": "[email protected]",
"subscription_status": "active"
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `400` | `40010` | `malformed URL parameter` |
| `400` | `40023` | `session not found` |
| `500` | `50002` | `internal server error` |
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")}
)
5 changes: 0 additions & 5 deletions api/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,11 +496,6 @@ func (a *API) getOrganizationSubscriptionHandler(w http.ResponseWriter, r *http.
ErrNoOrganizationSubscription.Write(w)
return
}
if !org.Subscription.Active ||
(org.Subscription.EndDate.After(time.Now()) && org.Subscription.StartDate.Before(time.Now())) {
ErrOganizationSubscriptionIncative.Write(w)
return
}
// get the subscription from the database
plan, err := a.db.Plan(org.Subscription.PlanID)
if err != nil {
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}"
)
132 changes: 124 additions & 8 deletions api/stripe.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
package api

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

"github.com/go-chi/chi/v5"
"github.com/vocdoni/saas-backend/db"
"go.vocdoni.io/dvote/log"
)

// handleWebhook handles the incoming webhook event from Stripe.
// It takes the API data and signature as input parameters and returns the session ID and an error (if any).
// The request body and Stripe-Signature header are passed to ConstructEvent, along with the webhook signing key.
// If the event type is "customer.subscription.created", it unmarshals the event data into a CheckoutSession struct
// and returns the session ID. Otherwise, it returns an empty string.
// It processes various subscription-related events (created, updated, deleted)
// and updates the organization's subscription status accordingly.
// The webhook verifies the Stripe signature and handles different event types:
// - customer.subscription.created: Creates a new subscription for an organization
// - customer.subscription.updated: Updates an existing subscription
// - customer.subscription.deleted: Reverts to the default plan
// If any error occurs during processing, it returns an appropriate HTTP status code.
func (a *API) handleWebhook(w http.ResponseWriter, r *http.Request) {
const MaxBodyBytes = int64(65536)
r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
payload, err := io.ReadAll(r.Body)
if err != nil {

log.Errorf("stripe webhook: Error reading request body: %s\n", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
Expand Down Expand Up @@ -64,13 +68,11 @@ func (a *API) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
startDate := time.Unix(subscription.CurrentPeriodStart, 0)
endDate := time.Unix(subscription.CurrentPeriodEnd, 0)
renewalDate := time.Unix(subscription.BillingCycleAnchor, 0)
renewalDate := time.Unix(subscription.CurrentPeriodEnd, 0)

organizationSubscription := &db.OrganizationSubscription{
PlanID: dbSubscription.ID,
StartDate: startDate,
EndDate: endDate,
RenewalDate: renewalDate,
Active: subscription.Status == "active",
MaxCensusSize: int(subscription.Items.Data[0].Quantity),
Expand All @@ -84,6 +86,120 @@ 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
}
orgPlan, err := a.db.Plan(org.Subscription.PlanID)
if err != nil || orgPlan == nil {
log.Errorf("could not update subscription %s", subscription.ID)
log.Errorf("a corresponding plan with id %d for organization with address %s was not found",
org.Subscription.PlanID, 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" && len(subscription.Items.Data) > 0 &&
subscription.Items.Data[0].Plan.Product.ID == orgPlan.StripeID {
// 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)
}

// createSubscriptionCheckoutHandler handles requests to create a new Stripe checkout session
// for subscription purchases.
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.Amount == 0 || checkout.Address == "" {
ErrMalformedBody.Withf("Missing required fields").Write(w)
return
}

// TODO check if the user has another active paid subscription

plan, err := a.db.Plan(checkout.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, checkout.Amount)
if err != nil {
ErrStripeError.Withf("Cannot create session: %v", err).Write(w)
return
}

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

// checkoutSessionHandler retrieves the status of a Stripe checkout session.
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 uint64 `json:"lookupKey"`
ReturnURL string `json:"returnURL"`
Amount int64 `json:"amount"`
Address string `json:"address"`
}
2 changes: 0 additions & 2 deletions db/organizations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,11 @@ func TestAddOrganizationPlan(t *testing.T) {
// add a subscription to the organization
subscriptionName := "testPlan"
startDate := time.Now()
endDate := startDate.AddDate(1, 0, 0)
active := true
stripeID := "stripeID"
orgSubscription := &OrganizationSubscription{
PlanID: 100,
StartDate: startDate,
EndDate: endDate,
Active: true,
}
// using a non existing subscription should fail
Expand Down
2 changes: 1 addition & 1 deletion 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 All @@ -112,7 +113,6 @@ type PlanTier struct {
type OrganizationSubscription struct {
PlanID uint64 `json:"planID" bson:"planID"`
StartDate time.Time `json:"startDate" bson:"startDate"`
EndDate time.Time `json:"endDate" bson:"endDate"`
RenewalDate time.Time `json:"renewalDate" bson:"renewalDate"`
Active bool `json:"active" bson:"active"`
MaxCensusSize int `json:"maxCensusSize" bson:"maxCensusSize"`
Expand Down
Loading

0 comments on commit 6e98ac1

Please sign in to comment.