Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementation of the subscriptions model #17

Merged
merged 16 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,20 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: 1.22.x
cache: true
- name: Run golangci-lint
# run: |
# curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.30.0
# $(go env GOPATH)/bin/golangci-lint run --timeout=5m -c .golangci.yml
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v6
### golangci-lint will take much time if loading multiple linters in .golangci.yml
with:
version: latest
args: --timeout 5m --verbose
skip-cache: false
skip-pkg-cache: false
skip-build-cache: false
skip-cache: true
only-new-issues: true

test:
Expand All @@ -31,7 +29,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go environment
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Tidy go module
Expand Down
21 changes: 21 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"github.com/vocdoni/saas-backend/account"
"github.com/vocdoni/saas-backend/db"
"github.com/vocdoni/saas-backend/notifications"
"github.com/vocdoni/saas-backend/stripe"
"github.com/vocdoni/saas-backend/subscriptions"
"go.vocdoni.io/dvote/apiclient"
"go.vocdoni.io/dvote/log"
)
Expand All @@ -33,6 +35,10 @@ type APIConfig struct {
// FullTransparentMode if true allows signing all transactions and does not
// modify any of them.
FullTransparentMode bool
// Stripe secrets
StripeClient *stripe.StripeClient
// Subscriptions permissions manager
Subscriptions *subscriptions.Subscriptions
}

// API type represents the API HTTP server with JWT authentication capabilities.
Expand All @@ -47,6 +53,8 @@ type API struct {
mail notifications.NotificationService
secret string
transparentMode bool
stripe *stripe.StripeClient
subscriptions *subscriptions.Subscriptions
}

// New creates a new API HTTP server. It does not start the server. Use Start() for that.
Expand All @@ -64,6 +72,8 @@ func New(conf *APIConfig) *API {
mail: conf.MailService,
secret: conf.Secret,
transparentMode: conf.FullTransparentMode,
stripe: conf.StripeClient,
subscriptions: conf.Subscriptions,
}
}

Expand Down Expand Up @@ -128,6 +138,9 @@ func (a *API) initRouter() http.Handler {
// update the organization
log.Infow("new route", "method", "PUT", "path", organizationEndpoint)
r.Put(organizationEndpoint, a.updateOrganizationHandler)
// get organization subscription
log.Infow("new route", "method", "GET", "path", organizationSubscriptionEndpoint)
r.Get(organizationSubscriptionEndpoint, a.getOrganizationSubscriptionHandler)
// invite a new admin member to the organization
log.Infow("new route", "method", "POST", "path", organizationAddMemberEndpoint)
r.Post(organizationAddMemberEndpoint, a.inviteOrganizationMemberHandler)
Expand Down Expand Up @@ -179,6 +192,14 @@ func (a *API) initRouter() http.Handler {
// get organization types
log.Infow("new route", "method", "GET", "path", organizationTypesEndpoint)
r.Get(organizationTypesEndpoint, a.organizationsTypesHandler)
// get subscriptions
log.Infow("new route", "method", "GET", "path", plansEndpoint)
r.Get(plansEndpoint, a.getPlansHandler)
// get subscription info
log.Infow("new route", "method", "GET", "path", planInfoEndpoint)
r.Get(planInfoEndpoint, a.planInfoHandler)
log.Infow("new route", "method", "POST", "path", subscriptionsWebhook)
r.Post(subscriptionsWebhook, a.handleWebhook)
})
a.router = r
return r
Expand Down
2 changes: 1 addition & 1 deletion api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestMain(m *testing.M) {
// set reset db env var to true
_ = os.Setenv("VOCDONI_MONGO_RESET_DB", "true")
// create a new MongoDB connection with the test database
if testDB, err = db.New(mongoURI, test.RandomDatabaseName()); err != nil {
if testDB, err = db.New(mongoURI, test.RandomDatabaseName(), "subscriptions.json"); err != nil {
panic(err)
}
defer testDB.Close()
Expand Down
144 changes: 143 additions & 1 deletion api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@
- [🧑‍💼 Invite organization member](#-invite-organization-member)
- [⏳ List pending invitations](#-list-pending-invitations)
- [🤝 Accept organization invitation](#-accept-organization-invitation)
- [💸 Organization Subscription Info](#-organization-subscription-info)
- [🤠 Available organization members roles](#-available-organization-members-roles)
- [🏛️ Available organization types](#-available-organization-types)
- [🏦 Plans](#-plans)
- [🛒 Get Available Plans](#-get-plans)
- [🛍️ Get Plan Info](#-get-plan-info)

</details>

Expand Down Expand Up @@ -316,7 +320,15 @@ This endpoint only returns the addresses of the organizations where the current
"active": true,
"parent": {
"...": "..."
}
},
"subscription":{
"PlanID":3,
"StartDate":"2024-11-07T15:25:49.218Z",
"EndDate":"0001-01-01T00:00:00Z",
"RenewalDate":"0001-01-01T00:00:00Z",
"Active":true,
"MaxCensusSize":10
},
}
}
]
Expand Down Expand Up @@ -662,6 +674,61 @@ Only the following parameters can be changed. Every parameter is optional.
| `409` | `40901` | `duplicate conflict` |
| `500` | `50002` | `internal server error` |

### 💸 Organization subscription info

* **Path** `/organizations/{address}/subscription`
* **Method** `GET`
* **Request**
```json
{
"subscriptionDetails":{
"planID":3,
"startDate":"2024-11-07T15:25:49.218Z",
"endDate":"0001-01-01T00:00:00Z",
"renewalDate":"0001-01-01T00:00:00Z",
"active":true,
"maxCensusSize":10
},
"usage":{
"sentSMS":0,
"sentEmails":0,
"subOrgs":0,
"members":0
},
"plan":{
"id":3,
"name":"free",
"stripeID":"stripe_789",
"default":true,
"organization":{
"memberships":10,
"subOrgs":5,
"censusSize":10
},
"votingTypes":{
"approval":false,
"ranked":false,
"weighted":true
},
"features":{
"personalization":false,
"emailReminder":false,
"smsNotification":false
}
}
}
```
This request can be made only by organization admins.

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40009` | `organization not found` |
| `400` | `40011` | `no organization provided` |
| `500` | `50002` | `internal server error` |

### 🤠 Available organization members roles
* **Path** `/organizations/roles`
* **Method** `GET`
Expand Down Expand Up @@ -754,3 +821,78 @@ Only the following parameters can be changed. Every parameter is optional.
]
}
```

## 🏦 Plans

### 🛒 Get Plans

* **Path** `/plans`
* **Method** `GET`
* **Response**
```json
{
"plans": [
{
"id":1,
"name":"Basic",
"stripeID":"stripe_123",
"organization":{
"memberships":1,
"subOrgs":1
},
"votingTypes":{
"approval":true,
"ranked":true,
"weighted":true
},
"features":{
"personalization":false,
"emailReminder":true,
"smsNotification":false
}
},
...
]
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `500` | `50002` | `internal server error` |

### 🛍️ Get Plan info

* **Path** `/plans/{planID}`
* **Method** `GET`
* **Response**
```json
{
"id":1,
"name":"Basic",
"stripeID":"stripe_123",
"organization":{
"memberships":1,
"subOrgs":1
},
"votingTypes":{
"approval":true,
"ranked":true,
"weighted":true
},
"features":{
"personalization":false,
"emailReminder":true,
"smsNotification":false
}
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `400` | `40010` | `malformed URL parameter` |
| `400` | `40023` | `plan not found` |
| `500` | `50002` | `internal server error` |
44 changes: 24 additions & 20 deletions api/errors_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,30 @@ import (
// Do note that HTTPstatus 204 No Content implies the response body will be empty,
// so the Code and Message will actually be discarded, never sent to the client
var (
ErrUnauthorized = Error{Code: 40001, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user not authorized")}
ErrEmailMalformed = Error{Code: 40002, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("email malformed")}
ErrPasswordTooShort = Error{Code: 40003, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("password too short")}
ErrMalformedBody = Error{Code: 40004, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed JSON body")}
ErrDuplicateConflict = Error{Code: 40901, HTTPstatus: http.StatusConflict, Err: fmt.Errorf("duplicate conflict")}
ErrInvalidUserData = Error{Code: 40005, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid user data")}
ErrCouldNotSignTransaction = Error{Code: 40006, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("could not sign transaction")}
ErrInvalidTxFormat = Error{Code: 40007, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid transaction format")}
ErrTxTypeNotAllowed = Error{Code: 40008, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("transaction type not allowed")}
ErrOrganizationNotFound = Error{Code: 40009, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization not found")}
ErrMalformedURLParam = Error{Code: 40010, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed URL parameter")}
ErrNoOrganizationProvided = Error{Code: 40011, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("no organization provided")}
ErrNoOrganizations = Error{Code: 40012, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("this user has not been assigned to any organization")}
ErrInvalidOrganizationData = Error{Code: 40013, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid organization data")}
ErrUserNoVerified = Error{Code: 40014, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user account not verified")}
ErrUserAlreadyVerified = Error{Code: 40015, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("user account already verified")}
ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")}
ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")}
ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")}
ErrInvitationExpired = Error{Code: 40019, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("inviation code expired")}
ErrUnauthorized = Error{Code: 40001, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user not authorized")}
ErrEmailMalformed = Error{Code: 40002, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("email malformed")}
ErrPasswordTooShort = Error{Code: 40003, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("password too short")}
ErrMalformedBody = Error{Code: 40004, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed JSON body")}
ErrDuplicateConflict = Error{Code: 40901, HTTPstatus: http.StatusConflict, Err: fmt.Errorf("duplicate conflict")}
ErrInvalidUserData = Error{Code: 40005, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid user data")}
ErrCouldNotSignTransaction = Error{Code: 40006, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("could not sign transaction")}
ErrInvalidTxFormat = Error{Code: 40007, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid transaction format")}
ErrTxTypeNotAllowed = Error{Code: 40008, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("transaction type not allowed")}
ErrOrganizationNotFound = Error{Code: 40009, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization not found")}
ErrMalformedURLParam = Error{Code: 40010, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed URL parameter")}
ErrNoOrganizationProvided = Error{Code: 40011, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("no organization provided")}
ErrNoOrganizations = Error{Code: 40012, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("this user has not been assigned to any organization")}
ErrInvalidOrganizationData = Error{Code: 40013, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid organization data")}
ErrUserNoVerified = Error{Code: 40014, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user account not verified")}
ErrUserAlreadyVerified = Error{Code: 40015, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("user account already verified")}
ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")}
ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")}
ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")}
ErrInvitationExpired = Error{Code: 40019, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("inviation code expired")}
ErrNoOrganizationSubscription = Error{Code: 40020, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization subscription not found")}
ErrOganizationSubscriptionIncative = Error{Code: 40021, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("organization subscription not active")}
ErrNoDefaultPLan = Error{Code: 40022, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("did not found default plan for organization")}
ErPlanNotFound = Error{Code: 40023, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("plan not found")}

ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")}
ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")}
Expand Down
Loading
Loading