diff --git a/authz/authz.go b/authz/authz.go index c5972230..8d3dd446 100644 --- a/authz/authz.go +++ b/authz/authz.go @@ -87,6 +87,7 @@ p, *, *, GET, /api/get-prometheus-info, *, * p, *, *, *, /api/metrics, *, * p, *, *, GET, /api/get-pricing, *, * p, *, *, GET, /api/get-plan, *, * +p, *, *, GET, /api/get-subscription, *, * p, *, *, GET, /api/get-organization-names, *, * ` diff --git a/controllers/account.go b/controllers/account.go index cf41e429..1e2f2b6c 100644 --- a/controllers/account.go +++ b/controllers/account.go @@ -153,12 +153,22 @@ func (c *ApiController) Signup() { return } + userType := "normal-user" + if authForm.Plan != "" && authForm.Pricing != "" { + err = object.CheckPricingAndPlan(authForm.Organization, authForm.Pricing, authForm.Plan) + if err != nil { + c.ResponseError(err.Error()) + return + } + userType = "paid-user" + } + user := &object.User{ Owner: authForm.Organization, Name: username, CreatedTime: util.GetCurrentTime(), Id: id, - Type: "normal-user", + Type: userType, Password: authForm.Password, DisplayName: authForm.Name, Avatar: organization.DefaultAvatar, @@ -210,7 +220,7 @@ func (c *ApiController) Signup() { return } - if application.HasPromptPage() { + if application.HasPromptPage() && user.Type == "normal-user" { // The prompt page needs the user to be signed in c.SetSessionUsername(user.GetId()) } @@ -227,15 +237,6 @@ func (c *ApiController) Signup() { return } - isSignupFromPricing := authForm.Plan != "" && authForm.Pricing != "" - if isSignupFromPricing { - _, err = object.Subscribe(organization.Name, user.Name, authForm.Plan, authForm.Pricing) - if err != nil { - c.ResponseError(err.Error()) - return - } - } - record := object.NewRecord(c.Ctx) record.Organization = application.Organization record.User = user.Name diff --git a/controllers/auth.go b/controllers/auth.go index dfa5275b..e2b992e3 100644 --- a/controllers/auth.go +++ b/controllers/auth.go @@ -78,6 +78,46 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob } } + // check whether paid-user have active subscription + if user.Type == "paid-user" { + subscriptions, err := object.GetSubscriptionsByUser(user.Owner, user.Name) + if err != nil { + c.ResponseError(err.Error()) + return + } + existActiveSubscription := false + for _, subscription := range subscriptions { + if subscription.State == object.SubStateActive { + existActiveSubscription = true + break + } + } + if !existActiveSubscription { + // check pending subscription + for _, sub := range subscriptions { + if sub.State == object.SubStatePending { + c.ResponseOk("BuyPlanResult", sub) + return + } + } + // paid-user does not have active or pending subscription, find the default pricing of application + pricing, err := object.GetApplicationDefaultPricing(application.Organization, application.Name) + if err != nil { + c.ResponseError(err.Error()) + return + } + if pricing == nil { + c.ResponseError(fmt.Sprintf(c.T("auth:paid-user %s does not have active or pending subscription and the application: %s does not have default pricing"), user.Name, application.Name)) + return + } else { + // let the paid-user select plan + c.ResponseOk("SelectPlan", pricing) + return + } + + } + } + if form.Type == ResponseTypeLogin { c.SetSessionUsername(userId) util.LogInfo(c.Ctx, "API: [%s] signed in", userId) diff --git a/controllers/plan.go b/controllers/plan.go index a9f71a6e..1b05c3ee 100644 --- a/controllers/plan.go +++ b/controllers/plan.go @@ -16,6 +16,7 @@ package controllers import ( "encoding/json" + "fmt" "github.com/beego/beego/utils/pagination" "github.com/casdoor/casdoor/object" @@ -82,7 +83,10 @@ func (c *ApiController) GetPlan() { c.ResponseError(err.Error()) return } - + if plan == nil { + c.ResponseError(fmt.Sprintf(c.T("plan:The plan: %s does not exist"), id)) + return + } if includeOption { options, err := object.GetPermissionsByRole(plan.Role) if err != nil { @@ -117,7 +121,20 @@ func (c *ApiController) UpdatePlan() { c.ResponseError(err.Error()) return } - + if plan.Product != "" { + planId := util.GetId(plan.Owner, plan.Product) + product, err := object.GetProduct(planId) + if err != nil { + c.ResponseError(err.Error()) + return + } + object.UpdateProductForPlan(&plan, product) + _, err = object.UpdateProduct(planId, product) + if err != nil { + c.ResponseError(err.Error()) + return + } + } c.Data["json"] = wrapActionResponse(object.UpdatePlan(id, &plan)) c.ServeJSON() } @@ -136,7 +153,14 @@ func (c *ApiController) AddPlan() { c.ResponseError(err.Error()) return } - + // Create a related product for plan + product := object.CreateProductForPlan(&plan) + _, err = object.AddProduct(product) + if err != nil { + c.ResponseError(err.Error()) + return + } + plan.Product = product.Name c.Data["json"] = wrapActionResponse(object.AddPlan(&plan)) c.ServeJSON() } @@ -155,7 +179,13 @@ func (c *ApiController) DeletePlan() { c.ResponseError(err.Error()) return } - + if plan.Product != "" { + _, err = object.DeleteProduct(&object.Product{Owner: plan.Owner, Name: plan.Product}) + if err != nil { + c.ResponseError(err.Error()) + return + } + } c.Data["json"] = wrapActionResponse(object.DeletePlan(&plan)) c.ServeJSON() } diff --git a/controllers/pricing.go b/controllers/pricing.go index e683e19a..a1b379a7 100644 --- a/controllers/pricing.go +++ b/controllers/pricing.go @@ -16,6 +16,7 @@ package controllers import ( "encoding/json" + "fmt" "github.com/beego/beego/utils/pagination" "github.com/casdoor/casdoor/object" @@ -80,7 +81,10 @@ func (c *ApiController) GetPricing() { c.ResponseError(err.Error()) return } - + if pricing == nil { + c.ResponseError(fmt.Sprintf(c.T("pricing:The pricing: %s does not exist"), id)) + return + } c.ResponseOk(pricing) } diff --git a/controllers/product.go b/controllers/product.go index ec12cb54..f88e89e6 100644 --- a/controllers/product.go +++ b/controllers/product.go @@ -161,10 +161,17 @@ func (c *ApiController) DeleteProduct() { // @router /buy-product [post] func (c *ApiController) BuyProduct() { id := c.Input().Get("id") - providerName := c.Input().Get("providerName") host := c.Ctx.Request.Host - - userId := c.GetSessionUsername() + providerName := c.Input().Get("providerName") + // buy `pricingName/planName` for `paidUserName` + pricingName := c.Input().Get("pricingName") + planName := c.Input().Get("planName") + paidUserName := c.Input().Get("userName") + owner, _ := util.GetOwnerAndNameFromId(id) + userId := util.GetId(owner, paidUserName) + if paidUserName == "" { + userId = c.GetSessionUsername() + } if userId == "" { c.ResponseError(c.T("general:Please login first")) return @@ -175,13 +182,12 @@ func (c *ApiController) BuyProduct() { c.ResponseError(err.Error()) return } - if user == nil { c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId)) return } - payUrl, orderId, err := object.BuyProduct(id, providerName, user, host) + payUrl, orderId, err := object.BuyProduct(id, user, providerName, pricingName, planName, host) if err != nil { c.ResponseError(err.Error()) return diff --git a/object/plan.go b/object/plan.go index f2e0cf6b..a73c6ad1 100644 --- a/object/plan.go +++ b/object/plan.go @@ -28,15 +28,21 @@ type Plan struct { DisplayName string `xorm:"varchar(100)" json:"displayName"` Description string `xorm:"varchar(100)" json:"description"` - PricePerMonth float64 `json:"pricePerMonth"` - PricePerYear float64 `json:"pricePerYear"` - Currency string `xorm:"varchar(100)" json:"currency"` - IsEnabled bool `json:"isEnabled"` + PricePerMonth float64 `json:"pricePerMonth"` + PricePerYear float64 `json:"pricePerYear"` + Currency string `xorm:"varchar(100)" json:"currency"` + Product string `json:"product"` // related product id + PaymentProviders []string `xorm:"varchar(100)" json:"paymentProviders"` // payment providers for related product + IsEnabled bool `json:"isEnabled"` Role string `xorm:"varchar(100)" json:"role"` Options []string `xorm:"-" json:"options"` } +func (plan *Plan) GetId() string { + return fmt.Sprintf("%s/%s", plan.Owner, plan.Name) +} + func GetPlanCount(owner, field, value string) (int64, error) { session := GetSession(owner, -1, -1, field, value, "", "") return session.Count(&Plan{}) @@ -114,38 +120,3 @@ func DeletePlan(plan *Plan) (bool, error) { } return affected != 0, nil } - -func (plan *Plan) GetId() string { - return fmt.Sprintf("%s/%s", plan.Owner, plan.Name) -} - -func Subscribe(owner string, user string, plan string, pricing string) (*Subscription, error) { - selectedPricing, err := GetPricing(fmt.Sprintf("%s/%s", owner, pricing)) - if err != nil { - return nil, err - } - - valid := selectedPricing != nil && selectedPricing.IsEnabled - - if !valid { - return nil, nil - } - - planBelongToPricing, err := selectedPricing.HasPlan(owner, plan) - if err != nil { - return nil, err - } - - if planBelongToPricing { - newSubscription := NewSubscription(owner, user, plan, selectedPricing.TrialDuration) - affected, err := AddSubscription(newSubscription) - if err != nil { - return nil, err - } - - if affected { - return newSubscription, nil - } - } - return nil, nil -} diff --git a/object/pricing.go b/object/pricing.go index 8d81f424..d628690a 100644 --- a/object/pricing.go +++ b/object/pricing.go @@ -16,7 +16,6 @@ package object import ( "fmt" - "strings" "github.com/casdoor/casdoor/util" "github.com/xorm-io/core" @@ -41,6 +40,26 @@ type Pricing struct { State string `xorm:"varchar(100)" json:"state"` } +func (pricing *Pricing) GetId() string { + return fmt.Sprintf("%s/%s", pricing.Owner, pricing.Name) +} + +func (pricing *Pricing) HasPlan(planName string) (bool, error) { + planId := util.GetId(pricing.Owner, planName) + plan, err := GetPlan(planId) + if err != nil { + return false, err + } + if plan == nil { + return false, fmt.Errorf("plan: %s does not exist", planId) + } + + if util.InSlice(pricing.Plans, plan.Name) { + return true, nil + } + return false, nil +} + func GetPricingCount(owner, field, value string) (int64, error) { session := GetSession(owner, -1, -1, field, value, "", "") return session.Count(&Pricing{}) @@ -74,7 +93,7 @@ func getPricing(owner, name string) (*Pricing, error) { pricing := Pricing{Owner: owner, Name: name} existed, err := ormer.Engine.Get(&pricing) if err != nil { - return &pricing, err + return nil, err } if existed { return &pricing, nil @@ -88,6 +107,20 @@ func GetPricing(id string) (*Pricing, error) { return getPricing(owner, name) } +func GetApplicationDefaultPricing(owner, appName string) (*Pricing, error) { + pricings := make([]*Pricing, 0, 1) + err := ormer.Engine.Asc("created_time").Find(&pricings, &Pricing{Owner: owner, Application: appName}) + if err != nil { + return nil, err + } + for _, pricing := range pricings { + if pricing.IsEnabled { + return pricing, nil + } + } + return nil, nil +} + func UpdatePricing(id string, pricing *Pricing) (bool, error) { owner, name := util.GetOwnerAndNameFromId(id) if p, err := getPricing(owner, name); err != nil { @@ -120,28 +153,21 @@ func DeletePricing(pricing *Pricing) (bool, error) { return affected != 0, nil } -func (pricing *Pricing) GetId() string { - return fmt.Sprintf("%s/%s", pricing.Owner, pricing.Name) -} - -func (pricing *Pricing) HasPlan(owner string, plan string) (bool, error) { - selectedPlan, err := GetPlan(fmt.Sprintf("%s/%s", owner, plan)) - if err != nil { - return false, err - } - - if selectedPlan == nil { - return false, nil - } - - result := false - - for _, pricingPlan := range pricing.Plans { - if strings.Contains(pricingPlan, selectedPlan.Name) { - result = true - break +func CheckPricingAndPlan(owner, pricingName, planName string) error { + pricingId := util.GetId(owner, pricingName) + pricing, err := GetPricing(pricingId) + if pricing == nil || err != nil { + if pricing == nil && err == nil { + err = fmt.Errorf("pricing: %s does not exist", pricingName) } + return err } - - return result, nil + ok, err := pricing.HasPlan(planName) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("pricing: %s does not have plan: %s", pricingName, planName) + } + return nil } diff --git a/object/product.go b/object/product.go index 4501cd18..17ad6801 100644 --- a/object/product.go +++ b/object/product.go @@ -141,24 +141,24 @@ func (product *Product) isValidProvider(provider *Provider) bool { return false } -func (product *Product) getProvider(providerId string) (*Provider, error) { - provider, err := getProvider(product.Owner, providerId) +func (product *Product) getProvider(providerName string) (*Provider, error) { + provider, err := getProvider(product.Owner, providerName) if err != nil { return nil, err } if provider == nil { - return nil, fmt.Errorf("the payment provider: %s does not exist", providerId) + return nil, fmt.Errorf("the payment provider: %s does not exist", providerName) } if !product.isValidProvider(provider) { - return nil, fmt.Errorf("the payment provider: %s is not valid for the product: %s", providerId, product.Name) + return nil, fmt.Errorf("the payment provider: %s is not valid for the product: %s", providerName, product.Name) } return provider, nil } -func BuyProduct(id string, providerName string, user *User, host string) (string, string, error) { +func BuyProduct(id string, user *User, providerName, pricingName, planName, host string) (string, string, error) { product, err := GetProduct(id) if err != nil { return "", "", err @@ -181,13 +181,24 @@ func BuyProduct(id string, providerName string, user *User, host string) (string owner := product.Owner productName := product.Name payerName := fmt.Sprintf("%s | %s", user.Name, user.DisplayName) - paymentName := util.GenerateTimeId() + paymentName := fmt.Sprintf("payment_%v", util.GenerateTimeId()) productDisplayName := product.DisplayName originFrontend, originBackend := getOriginFromHost(host) returnUrl := fmt.Sprintf("%s/payments/%s/%s/result", originFrontend, owner, paymentName) notifyUrl := fmt.Sprintf("%s/api/notify-payment/%s/%s", originBackend, owner, paymentName) - // Create an Order and get the payUrl + if user.Type == "paid-user" { + // Create a subscription for `paid-user` + if pricingName != "" && planName != "" { + sub := NewSubscription(owner, user.Name, pricingName, planName, paymentName) + _, err := AddSubscription(sub) + if err != nil { + return "", "", err + } + returnUrl = fmt.Sprintf("%s/buy-plan/%s/%s/result?subscription=%s", originFrontend, owner, pricingName, sub.Name) + } + } + // Create an OrderId and get the payUrl payUrl, orderId, err := pProvider.Pay(providerName, productName, payerName, paymentName, productDisplayName, product.Price, product.Currency, returnUrl, notifyUrl) if err != nil { return "", "", err @@ -228,7 +239,6 @@ func BuyProduct(id string, providerName string, user *User, host string) (string if !affected { return "", "", fmt.Errorf("failed to add payment: %s", util.StructToJson(payment)) } - return payUrl, orderId, err } @@ -252,3 +262,34 @@ func ExtendProductWithProviders(product *Product) error { return nil } + +func CreateProductForPlan(plan *Plan) *Product { + product := &Product{ + Owner: plan.Owner, + Name: fmt.Sprintf("product_%v", util.GetRandomName()), + DisplayName: fmt.Sprintf("Auto Created Product for Plan %v(%v)", plan.GetId(), plan.DisplayName), + CreatedTime: plan.CreatedTime, + + Image: "https://cdn.casbin.org/img/casdoor-logo_1185x256.png", // TODO + Detail: fmt.Sprintf("This Product was auto created for Plan %v(%v)", plan.GetId(), plan.DisplayName), + Description: plan.Description, + Tag: "auto_created_product_for_plan", + Price: plan.PricePerMonth, // TODO + Currency: plan.Currency, + + Quantity: 999, + Sold: 0, + + Providers: plan.PaymentProviders, + State: "Published", + } + return product +} + +func UpdateProductForPlan(plan *Plan, product *Product) { + product.DisplayName = fmt.Sprintf("Auto Created Product for Plan %v(%v)", plan.GetId(), plan.DisplayName) + product.Detail = fmt.Sprintf("This Product was auto created for Plan %v(%v)", plan.GetId(), plan.DisplayName) + product.Price = plan.PricePerMonth // TODO + product.Providers = plan.PaymentProviders + product.Currency = plan.Currency +} diff --git a/object/subscription.go b/object/subscription.go index 2aba13fa..9fcc7315 100644 --- a/object/subscription.go +++ b/object/subscription.go @@ -18,47 +18,108 @@ import ( "fmt" "time" + "github.com/casdoor/casdoor/pp" + "github.com/casdoor/casdoor/util" "github.com/xorm-io/core" ) -const defaultStatus = "Pending" +type SubscriptionState string + +const ( + SubStatePending SubscriptionState = "Pending" + SubStateError SubscriptionState = "Error" + SubStateSuspended SubscriptionState = "Suspended" // suspended by the admin + + SubStateActive SubscriptionState = "Active" + SubStateUpcoming SubscriptionState = "Upcoming" + SubStateExpired SubscriptionState = "Expired" +) type Subscription struct { Owner string `xorm:"varchar(100) notnull pk" json:"owner"` Name string `xorm:"varchar(100) notnull pk" json:"name"` - CreatedTime string `xorm:"varchar(100)" json:"createdTime"` DisplayName string `xorm:"varchar(100)" json:"displayName"` + CreatedTime string `xorm:"varchar(100)" json:"createdTime"` + Description string `xorm:"varchar(100)" json:"description"` - StartDate time.Time `json:"startDate"` - EndDate time.Time `json:"endDate"` - Duration int `json:"duration"` - Description string `xorm:"varchar(100)" json:"description"` + User string `xorm:"varchar(100)" json:"user"` + Pricing string `xorm:"varchar(100)" json:"pricing"` + Plan string `xorm:"varchar(100)" json:"plan"` + Payment string `xorm:"varchar(100)" json:"payment"` - User string `xorm:"mediumtext" json:"user"` - Plan string `xorm:"varchar(100)" json:"plan"` - - IsEnabled bool `json:"isEnabled"` - Submitter string `xorm:"varchar(100)" json:"submitter"` - Approver string `xorm:"varchar(100)" json:"approver"` - ApproveTime string `xorm:"varchar(100)" json:"approveTime"` - - State string `xorm:"varchar(100)" json:"state"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + Duration int `json:"duration"` + State SubscriptionState `xorm:"varchar(100)" json:"state"` } -func NewSubscription(owner string, user string, plan string, duration int) *Subscription { +func (sub *Subscription) GetId() string { + return fmt.Sprintf("%s/%s", sub.Owner, sub.Name) +} + +func (sub *Subscription) UpdateState() error { + preState := sub.State + // update subscription state by payment state + if sub.State == SubStatePending { + if sub.Payment == "" { + return nil + } + payment, err := GetPayment(util.GetId(sub.Owner, sub.Payment)) + if err != nil { + return err + } + if payment == nil { + sub.Description = fmt.Sprintf("payment: %s does not exist", sub.Payment) + sub.State = SubStateError + } else { + if payment.State == pp.PaymentStatePaid { + sub.State = SubStateActive + } else if payment.State != pp.PaymentStateCreated { + // other states: Canceled, Timeout, Error + sub.Description = fmt.Sprintf("payment: %s state is %v", sub.Payment, payment.State) + sub.State = SubStateError + } + } + } + + if sub.State == SubStateActive || sub.State == SubStateUpcoming || sub.State == SubStateExpired { + if sub.EndTime.Before(time.Now()) { + sub.State = SubStateExpired + } else if sub.StartTime.After(time.Now()) { + sub.State = SubStateUpcoming + } else { + sub.State = SubStateActive + } + } + + if preState != sub.State { + _, err := UpdateSubscription(sub.GetId(), sub) + if err != nil { + return err + } + } + + return nil +} + +func NewSubscription(owner, userName, pricingName, planName, paymentName string) *Subscription { id := util.GenerateId()[:6] return &Subscription{ - Name: "Subscription_" + id, - DisplayName: "New Subscription - " + id, Owner: owner, - User: owner + "/" + user, - Plan: owner + "/" + plan, + Name: "sub_" + id, + DisplayName: "New Subscription - " + id, CreatedTime: util.GetCurrentTime(), - State: defaultStatus, - Duration: duration, - StartDate: time.Now(), - EndDate: time.Now().AddDate(0, 0, duration), + + User: userName, + Pricing: pricingName, + Plan: planName, + Payment: paymentName, + + StartTime: time.Now(), + EndTime: time.Now().AddDate(0, 0, 30), + Duration: 30, // TODO + State: SubStatePending, // waiting for payment complete } } @@ -73,7 +134,28 @@ func GetSubscriptions(owner string) ([]*Subscription, error) { if err != nil { return subscriptions, err } + for _, sub := range subscriptions { + err = sub.UpdateState() + if err != nil { + return nil, err + } + } + return subscriptions, nil +} +func GetSubscriptionsByUser(owner, userName string) ([]*Subscription, error) { + subscriptions := []*Subscription{} + err := ormer.Engine.Desc("created_time").Find(&subscriptions, &Subscription{Owner: owner, User: userName}) + if err != nil { + return subscriptions, err + } + // update subscription state + for _, sub := range subscriptions { + err = sub.UpdateState() + if err != nil { + return subscriptions, err + } + } return subscriptions, nil } @@ -84,7 +166,12 @@ func GetPaginationSubscriptions(owner string, offset, limit int, field, value, s if err != nil { return subscriptions, err } - + for _, sub := range subscriptions { + err = sub.UpdateState() + if err != nil { + return nil, err + } + } return subscriptions, nil } @@ -144,7 +231,3 @@ func DeleteSubscription(subscription *Subscription) (bool, error) { return affected != 0, nil } - -func (subscription *Subscription) GetId() string { - return fmt.Sprintf("%s/%s", subscription.Owner, subscription.Name) -} diff --git a/object/user.go b/object/user.go index 56008b55..258eb544 100644 --- a/object/user.go +++ b/object/user.go @@ -541,7 +541,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er } } if isAdmin { - columns = append(columns, "name", "email", "phone", "country_code") + columns = append(columns, "name", "email", "phone", "country_code", "type") } if util.ContainsString(columns, "groups") { diff --git a/pp/paypal.go b/pp/paypal.go index a5bcc862..d54c2fa2 100644 --- a/pp/paypal.go +++ b/pp/paypal.go @@ -114,8 +114,8 @@ func (pp *PaypalPaymentProvider) Notify(request *http.Request, body []byte, auth if err != nil { return nil, err } - if captureRsp.Code != paypal.Success { - errDetail := captureRsp.ErrorResponse.Details[0] + if detailRsp.Code != paypal.Success { + errDetail := detailRsp.ErrorResponse.Details[0] switch errDetail.Issue { case "ORDER_NOT_APPROVED": notifyResult.PaymentStatus = PaymentStateCanceled diff --git a/web/src/App.js b/web/src/App.js index d5d2987d..46b98415 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -657,7 +657,8 @@ class App extends Component { window.location.pathname.startsWith("/result") || window.location.pathname.startsWith("/cas") || window.location.pathname.startsWith("/auto-signup") || - window.location.pathname.startsWith("/select-plan"); + window.location.pathname.startsWith("/select-plan") || + window.location.pathname.startsWith("/buy-plan"); } renderPage() { diff --git a/web/src/EntryPage.js b/web/src/EntryPage.js index dc75880b..d0570ffd 100644 --- a/web/src/EntryPage.js +++ b/web/src/EntryPage.js @@ -29,6 +29,8 @@ import PromptPage from "./auth/PromptPage"; import ResultPage from "./auth/ResultPage"; import CasLogout from "./auth/CasLogout"; import {authConfig} from "./auth/Auth"; +import ProductBuyPage from "./ProductBuyPage"; +import PaymentResultPage from "./PaymentResultPage"; class EntryPage extends React.Component { constructor(props) { @@ -108,7 +110,9 @@ class EntryPage extends React.Component { this.renderHomeIfLoggedIn()} /> this.renderHomeIfLoggedIn()} /> {return ();}} /> - this.renderHomeIfLoggedIn()} /> + } /> + } /> + } /> ); diff --git a/web/src/PaymentListPage.js b/web/src/PaymentListPage.js index d9c33cd4..6cd14f43 100644 --- a/web/src/PaymentListPage.js +++ b/web/src/PaymentListPage.js @@ -158,14 +158,6 @@ class PaymentListPage extends BaseListPage { return Setting.getFormattedDate(text); }, }, - // { - // title: i18next.t("general:Display name"), - // dataIndex: 'displayName', - // key: 'displayName', - // width: '160px', - // sorter: true, - // ...this.getColumnSearchProps('displayName'), - // }, { title: i18next.t("provider:Type"), dataIndex: "type", @@ -187,6 +179,13 @@ class PaymentListPage extends BaseListPage { // width: '160px', sorter: true, ...this.getColumnSearchProps("productDisplayName"), + render: (text, record, index) => { + return ( + + {text} + + ); + }, }, { title: i18next.t("product:Price"), diff --git a/web/src/PaymentResultPage.js b/web/src/PaymentResultPage.js index 6e22c889..19bfb123 100644 --- a/web/src/PaymentResultPage.js +++ b/web/src/PaymentResultPage.js @@ -15,17 +15,24 @@ import React from "react"; import {Button, Result, Spin} from "antd"; import * as PaymentBackend from "./backend/PaymentBackend"; +import * as PricingBackend from "./backend/PricingBackend"; +import * as SubscriptionBackend from "./backend/SubscriptionBackend"; import * as Setting from "./Setting"; import i18next from "i18next"; class PaymentResultPage extends React.Component { constructor(props) { super(props); + const params = new URLSearchParams(window.location.search); this.state = { classes: props, - paymentName: props.match.params.paymentName, - organizationName: props.match.params.organizationName, + owner: props.match?.params?.organizationName ?? props.match?.params?.owner ?? null, + paymentName: props.match?.params?.paymentName ?? null, + pricingName: props.pricingName ?? props.match?.params?.pricingName ?? null, + subscriptionName: params.get("subscription"), payment: null, + pricing: props.pricing ?? null, + subscription: props.subscription ?? null, timeout: null, }; } @@ -40,28 +47,77 @@ class PaymentResultPage extends React.Component { } } - getPayment() { - PaymentBackend.getPayment(this.state.organizationName, this.state.paymentName) - .then((res) => { - this.setState({ - payment: res.data, - }); - // window.console.log("payment=", res.data); - if (res.data.state === "Created") { - if (["PayPal", "Stripe"].includes(res.data.type)) { - this.setState({ - timeout: setTimeout(() => { - PaymentBackend.notifyPayment(this.state.organizationName, this.state.paymentName) - .then((res) => { - this.getPayment(); - }); - }, 1000), - }); - } else { - this.setState({timeout: setTimeout(() => this.getPayment(), 1000)}); - } - } + setStateAsync(state) { + return new Promise((resolve, reject) => { + this.setState(state, () => { + resolve(); }); + }); + } + + onUpdatePricing(pricing) { + this.props.onUpdatePricing(pricing); + } + + async getPayment() { + if (!(this.state.owner && (this.state.paymentName || (this.state.pricingName && this.state.subscriptionName)))) { + return ; + } + try { + // loading price & subscription + if (this.state.pricingName && this.state.subscriptionName) { + if (!this.state.pricing) { + const res = await PricingBackend.getPricing(this.state.owner, this.state.pricingName); + if (res.status !== "ok") { + throw new Error(res.msg); + } + const pricing = res.data; + await this.setStateAsync({ + pricing: pricing, + }); + } + if (!this.state.subscription) { + const res = await SubscriptionBackend.getSubscription(this.state.owner, this.state.subscriptionName); + if (res.status !== "ok") { + throw new Error(res.msg); + } + const subscription = res.data; + await this.setStateAsync({ + subscription: subscription, + }); + } + const paymentName = this.state.subscription.payment; + await this.setStateAsync({ + paymentName: paymentName, + }); + this.onUpdatePricing(this.state.pricing); + } + const res = await PaymentBackend.getPayment(this.state.owner, this.state.paymentName); + if (res.status !== "ok") { + throw new Error(res.msg); + } + const payment = res.data; + await this.setStateAsync({ + payment: payment, + }); + if (payment.state === "Created") { + if (["PayPal", "Stripe"].includes(payment.type)) { + this.setState({ + timeout: setTimeout(async() => { + await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName); + this.getPayment(); + }, 1000), + }); + } else { + this.setState({ + timeout: setTimeout(() => this.getPayment(), 1000), + }); + } + } + } catch (err) { + Setting.showMessage("error", err.message); + return; + } } goToPaymentUrl(payment) { @@ -81,7 +137,7 @@ class PaymentResultPage extends React.Component { if (payment.state === "Paid") { return ( -
+
{ Setting.renderHelmet(payment) } @@ -101,7 +157,7 @@ class PaymentResultPage extends React.Component { ); } else if (payment.state === "Created") { return ( -
+
{ Setting.renderHelmet(payment) } @@ -117,7 +173,7 @@ class PaymentResultPage extends React.Component { ); } else if (payment.state === "Canceled") { return ( -
+
{ Setting.renderHelmet(payment) } @@ -137,7 +193,7 @@ class PaymentResultPage extends React.Component { ); } else if (payment.state === "Timeout") { return ( -
+
{ Setting.renderHelmet(payment) } @@ -157,7 +213,7 @@ class PaymentResultPage extends React.Component { ); } else { return ( -
+
{ Setting.renderHelmet(payment) } diff --git a/web/src/PlanEditPage.js b/web/src/PlanEditPage.js index b2c2374f..4abe42e6 100644 --- a/web/src/PlanEditPage.js +++ b/web/src/PlanEditPage.js @@ -18,6 +18,7 @@ import * as OrganizationBackend from "./backend/OrganizationBackend"; import * as RoleBackend from "./backend/RoleBackend"; import * as PlanBackend from "./backend/PlanBackend"; import * as UserBackend from "./backend/UserBackend"; +import * as ProviderBackend from "./backend/ProviderBackend"; import * as Setting from "./Setting"; import i18next from "i18next"; @@ -28,14 +29,14 @@ class PlanEditPage extends React.Component { super(props); this.state = { classes: props, - organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName, - planName: props.match.params.planName, + organizationName: props?.organizationName ?? props?.match?.params?.organizationName ?? null, + planName: props?.match?.params?.planName ?? null, plan: null, organizations: [], users: [], roles: [], - providers: [], - mode: props.location.mode !== undefined ? props.location.mode : "edit", + paymentProviders: [], + mode: props?.location?.mode ?? "edit", }; } @@ -58,6 +59,7 @@ class PlanEditPage extends React.Component { this.getUsers(this.state.organizationName); this.getRoles(this.state.organizationName); + this.getPaymentProviders(this.state.organizationName); }); } @@ -89,6 +91,20 @@ class PlanEditPage extends React.Component { }); } + getPaymentProviders(organizationName) { + ProviderBackend.getProviders(organizationName) + .then((res) => { + if (res.status === "ok") { + this.setState({ + paymentProviders: res.data.filter(provider => provider.category === "Payment"), + }); + return; + } + + Setting.showMessage("error", res.msg); + }); + } + getOrganizations() { OrganizationBackend.getOrganizations("admin") .then((res) => { @@ -165,7 +181,7 @@ class PlanEditPage extends React.Component { + + + {Setting.getLabel(i18next.t("product:Payment providers"), i18next.t("product:Payment providers - Tooltip"))} : + + + + + {Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} : diff --git a/web/src/PlanListPage.js b/web/src/PlanListPage.js index 8896c828..e4a035ba 100644 --- a/web/src/PlanListPage.js +++ b/web/src/PlanListPage.js @@ -126,6 +126,14 @@ class PlanListPage extends BaseListPage { sorter: true, ...this.getColumnSearchProps("displayName"), }, + { + title: i18next.t("payment:Currency"), + dataIndex: "currency", + key: "currency", + width: "120px", + sorter: true, + ...this.getColumnSearchProps("currency"), + }, { title: i18next.t("plan:Price per month"), dataIndex: "pricePerMonth", @@ -148,7 +156,21 @@ class PlanListPage extends BaseListPage { ...this.getColumnSearchProps("role"), render: (text, record, index) => { return ( - + + {text} + + ); + }, + }, + { + title: i18next.t("plan:Related product"), + dataIndex: "product", + key: "product", + width: "130px", + ...this.getColumnSearchProps("product"), + render: (text, record, index) => { + return ( + {text} ); diff --git a/web/src/PricingEditPage.js b/web/src/PricingEditPage.js index b28378f8..54a11938 100644 --- a/web/src/PricingEditPage.js +++ b/web/src/PricingEditPage.js @@ -190,7 +190,7 @@ class PricingEditPage extends React.Component { onChange={(value => { this.updatePricingField("plans", value); })} - options={this.state.plans.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))} + options={this.state.plans.map((plan) => Setting.getOption(plan.name, plan.name))} /> @@ -294,7 +294,7 @@ class PricingEditPage extends React.Component { - + ); diff --git a/web/src/PricingListPage.js b/web/src/PricingListPage.js index 967f2f28..38b5b23a 100644 --- a/web/src/PricingListPage.js +++ b/web/src/PricingListPage.js @@ -14,7 +14,8 @@ import React from "react"; import {Link} from "react-router-dom"; -import {Button, Switch, Table} from "antd"; +import {Button, Col, Row, Switch, Table, Tooltip} from "antd"; +import {EditOutlined} from "@ant-design/icons"; import moment from "moment"; import * as Setting from "./Setting"; import * as PricingBackend from "./backend/PricingBackend"; @@ -118,11 +119,58 @@ class PricingListPage extends BaseListPage { title: i18next.t("general:Display name"), dataIndex: "displayName", key: "displayName", - // width: "170px", + width: "170px", sorter: true, ...this.getColumnSearchProps("displayName"), }, - + { + title: i18next.t("general:Application"), + dataIndex: "application", + key: "application", + width: "170px", + sorter: true, + ...this.getColumnSearchProps("application"), + render: (text, record, index) => { + return ( + + {text} + + ); + }, + }, + { + title: i18next.t("general:Plans"), + dataIndex: "plans", + key: "plans", + // width: "170px", + sorter: true, + ...this.getColumnSearchProps("plans"), + render: (plans, record, index) => { + if (plans.length === 0) { + return `(${i18next.t("general:empty")})`; + } + return ( +
+ + { + plans.map((plan) => ( + +
+ +
+ + )) + } +
+
+ ); + }, + }, { title: i18next.t("general:Is enabled"), dataIndex: "isEnabled", diff --git a/web/src/ProductBuyPage.js b/web/src/ProductBuyPage.js index e7992a7f..3d3501fe 100644 --- a/web/src/ProductBuyPage.js +++ b/web/src/ProductBuyPage.js @@ -17,16 +17,24 @@ import {Button, Descriptions, Modal, Spin} from "antd"; import {CheckCircleTwoTone} from "@ant-design/icons"; import i18next from "i18next"; import * as ProductBackend from "./backend/ProductBackend"; +import * as PlanBackend from "./backend/PlanBackend"; +import * as PricingBackend from "./backend/PricingBackend"; import * as Setting from "./Setting"; class ProductBuyPage extends React.Component { constructor(props) { super(props); + const params = new URLSearchParams(window.location.search); this.state = { classes: props, - organizationName: props.organizationName !== undefined ? props.organizationName : props?.match?.params?.organizationName, - productName: props.productName !== undefined ? props.productName : props?.match?.params?.productName, + owner: props?.organizationName ?? props?.match?.params?.organizationName ?? props?.match?.params?.owner ?? null, + productName: props?.productName ?? props?.match?.params?.productName ?? null, + pricingName: props?.pricingName ?? props?.match?.params?.pricingName ?? null, + planName: params.get("plan"), + userName: params.get("user"), product: null, + pricing: props?.pricing ?? null, + plan: null, isPlacingOrder: false, qrCodeModalProvider: null, }; @@ -36,20 +44,58 @@ class ProductBuyPage extends React.Component { this.getProduct(); } - getProduct() { - if (this.state.productName === undefined || this.state.organizationName === undefined) { + setStateAsync(state) { + return new Promise((resolve, reject) => { + this.setState(state, () => { + resolve(); + }); + }); + } + + onUpdatePricing(pricing) { + this.props.onUpdatePricing(pricing); + } + + async getProduct() { + if (!this.state.owner || (!this.state.productName && !this.state.pricingName)) { return ; } - ProductBackend.getProduct(this.state.organizationName, this.state.productName) - .then((res) => { - if (res.status === "error") { - Setting.showMessage("error", res.msg); - return; + try { + // load pricing & plan + if (this.state.pricingName) { + if (!this.state.planName || !this.state.userName) { + return ; } - this.setState({ - product: res.data, + let res = await PricingBackend.getPricing(this.state.owner, this.state.pricingName); + if (res.status !== "ok") { + throw new Error(res.msg); + } + const pricing = res.data; + res = await PlanBackend.getPlan(this.state.owner, this.state.planName); + if (res.status !== "ok") { + throw new Error(res.msg); + } + const plan = res.data; + const productName = plan.product; + await this.setStateAsync({ + pricing: pricing, + plan: plan, + productName: productName, }); + this.onUpdatePricing(pricing); + } + // load product + const res = await ProductBackend.getProduct(this.state.owner, this.state.productName); + if (res.status !== "ok") { + throw new Error(res.msg); + } + this.setState({ + product: res.data, }); + } catch (err) { + Setting.showMessage("error", err.message); + return; + } } getProductObj() { @@ -96,7 +142,7 @@ class ProductBuyPage extends React.Component { isPlacingOrder: true, }); - ProductBackend.buyProduct(product.owner, product.name, provider.name) + ProductBackend.buyProduct(product.owner, product.name, provider.name, this.state.pricingName ?? "", this.state.planName ?? "", this.state.userName ?? "") .then((res) => { if (res.status === "ok") { const payUrl = res.data; @@ -215,11 +261,11 @@ class ProductBuyPage extends React.Component { } return ( -
+
- + {i18next.t("product:Buy Product")}} bordered> - + {Setting.getLanguageText(product?.displayName)} diff --git a/web/src/Setting.js b/web/src/Setting.js index fd8b8f68..a5db5cd9 100644 --- a/web/src/Setting.js +++ b/web/src/Setting.js @@ -1170,9 +1170,9 @@ export function getTags(tags, urlPrefix = null) { return res; } -export function getTag(color, text) { +export function getTag(color, text, icon) { return ( - + {text} ); @@ -1254,3 +1254,13 @@ export function builtInObject(obj) { } return obj.owner === "built-in" && BuiltInObjects.includes(obj.name); } + +export function getCurrencySymbol(currency) { + if (currency === "USD" || currency === "usd") { + return "$"; + } else if (currency === "CNY" || currency === "cny") { + return "¥"; + } else { + return currency; + } +} diff --git a/web/src/SubscriptionEditPage.js b/web/src/SubscriptionEditPage.js index 9f307c5e..2125e4dc 100644 --- a/web/src/SubscriptionEditPage.js +++ b/web/src/SubscriptionEditPage.js @@ -14,8 +14,9 @@ import moment from "moment"; import React from "react"; -import {Button, Card, Col, DatePicker, Input, InputNumber, Row, Select, Switch} from "antd"; +import {Button, Card, Col, DatePicker, Input, InputNumber, Row, Select} from "antd"; import * as OrganizationBackend from "./backend/OrganizationBackend"; +import * as PricingBackend from "./backend/PricingBackend"; import * as PlanBackend from "./backend/PlanBackend"; import * as SubscriptionBackend from "./backend/SubscriptionBackend"; import * as UserBackend from "./backend/UserBackend"; @@ -33,7 +34,8 @@ class SubscriptionEditPage extends React.Component { subscription: null, organizations: [], users: [], - planes: [], + pricings: [], + plans: [], providers: [], mode: props.location.mode !== undefined ? props.location.mode : "edit", }; @@ -62,15 +64,25 @@ class SubscriptionEditPage extends React.Component { }); this.getUsers(this.state.organizationName); - this.getPlanes(this.state.organizationName); + this.getPricings(this.state.organizationName); + this.getPlans(this.state.organizationName); }); } - getPlanes(organizationName) { + getPricings(organizationName) { + PricingBackend.getPricings(organizationName) + .then((res) => { + this.setState({ + pricings: res.data, + }); + }); + } + + getPlans(organizationName) { PlanBackend.getPlans(organizationName) .then((res) => { this.setState({ - planes: res.data, + plans: res.data, }); }); } @@ -133,7 +145,7 @@ class SubscriptionEditPage extends React.Component { {this.updateSubscriptionField("user", value);})} - options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))} + options={this.state.users.map((user) => Setting.getOption(user.name, user.name))} /> - + + + {Setting.getLabel(i18next.t("general:Pricing"), i18next.t("general:Pricing - Tooltip"))} : + + + {this.updateSubscriptionField("plan", value);})} - options={this.state.planes.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`)) + { + this.updateSubscriptionField("payment", e.target.value); + }} /> + + {Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} : @@ -221,46 +254,6 @@ class SubscriptionEditPage extends React.Component { }} /> - - - {Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} : - - - { - this.updateSubscriptionField("isEnabled", checked); - }} /> - - - - - {Setting.getLabel(i18next.t("permission:Submitter"), i18next.t("permission:Submitter - Tooltip"))} : - - - { - this.updateSubscriptionField("submitter", e.target.value); - }} /> - - - - - {Setting.getLabel(i18next.t("permission:Approver"), i18next.t("permission:Approver - Tooltip"))} : - - - { - this.updateSubscriptionField("approver", e.target.value); - }} /> - - - - - {Setting.getLabel(i18next.t("permission:Approve time"), i18next.t("permission:Approve time - Tooltip"))} : - - - { - this.updatePermissionField("approveTime", e.target.value); - }} /> - - {Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} : @@ -280,8 +273,12 @@ class SubscriptionEditPage extends React.Component { this.updateSubscriptionField("state", value); })} options={[ - {value: "Approved", name: i18next.t("permission:Approved")}, {value: "Pending", name: i18next.t("permission:Pending")}, + {value: "Active", name: i18next.t("permission:Active")}, + {value: "Upcoming", name: i18next.t("permission:Upcoming")}, + {value: "Expired", name: i18next.t("permission:Expired")}, + {value: "Error", name: i18next.t("permission:Error")}, + {value: "Suspended", name: i18next.t("permission:Suspended")}, ].map((item) => Setting.getOption(item.name, item.value))} /> diff --git a/web/src/SubscriptionListPage.js b/web/src/SubscriptionListPage.js index 60825656..dd4a0b49 100644 --- a/web/src/SubscriptionListPage.js +++ b/web/src/SubscriptionListPage.js @@ -15,6 +15,7 @@ import React from "react"; import {Link} from "react-router-dom"; import {Button, Table} from "antd"; +import {ClockCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, MinusCircleOutlined, SyncOutlined} from "@ant-design/icons"; import moment from "moment"; import * as Setting from "./Setting"; import * as SubscriptionBackend from "./backend/SubscriptionBackend"; @@ -26,24 +27,20 @@ class SubscriptionListPage extends BaseListPage { newSubscription() { const randomName = Setting.getRandomName(); const owner = Setting.getRequestOrganization(this.props.account); - const defaultDuration = 365; + const defaultDuration = 30; return { owner: owner, - name: `subscription_${randomName}`, + name: `sub_${randomName}`, createdTime: moment().format(), displayName: `New Subscription - ${randomName}`, - startDate: moment().format(), - endDate: moment().add(defaultDuration, "d").format(), + startTime: moment().format(), + endTime: moment().add(defaultDuration, "d").format(), duration: defaultDuration, description: "", user: "", plan: "", - isEnabled: true, - submitter: this.props.account.name, - approver: this.props.account.name, - approveTime: moment().format(), - state: "Approved", + state: "Active", }; } @@ -139,6 +136,34 @@ class SubscriptionListPage extends BaseListPage { width: "140px", ...this.getColumnSearchProps("duration"), }, + { + title: i18next.t("subscription:Start time"), + dataIndex: "startTime", + key: "startTime", + width: "140px", + ...this.getColumnSearchProps("startTime"), + }, + { + title: i18next.t("subscription:End time"), + dataIndex: "endTime", + key: "endTime", + width: "140px", + ...this.getColumnSearchProps("endTime"), + }, + { + title: i18next.t("general:Pricing"), + dataIndex: "pricing", + key: "pricing", + width: "140px", + ...this.getColumnSearchProps("pricing"), + render: (text, record, index) => { + return ( + + {text} + + ); + }, + }, { title: i18next.t("general:Plan"), dataIndex: "plan", @@ -147,7 +172,7 @@ class SubscriptionListPage extends BaseListPage { ...this.getColumnSearchProps("plan"), render: (text, record, index) => { return ( - + {text} ); @@ -161,7 +186,21 @@ class SubscriptionListPage extends BaseListPage { ...this.getColumnSearchProps("user"), render: (text, record, index) => { return ( - + + {text} + + ); + }, + }, + { + title: i18next.t("general:Payment"), + dataIndex: "payment", + key: "payment", + width: "140px", + ...this.getColumnSearchProps("payment"), + render: (text, record, index) => { + return ( + {text} ); @@ -176,10 +215,18 @@ class SubscriptionListPage extends BaseListPage { ...this.getColumnSearchProps("state"), render: (text, record, index) => { switch (text) { - case "Approved": - return Setting.getTag("success", i18next.t("permission:Approved")); case "Pending": - return Setting.getTag("error", i18next.t("permission:Pending")); + return Setting.getTag("processing", i18next.t("permission:Pending"), ); + case "Active": + return Setting.getTag("success", i18next.t("permission:Active"), ); + case "Upcoming": + return Setting.getTag("warning", i18next.t("permission:Upcoming"), ); + case "Expired": + return Setting.getTag("warning", i18next.t("permission:Expired"), ); + case "Error": + return Setting.getTag("error", i18next.t("permission:Error"), ); + case "Suspended": + return Setting.getTag("default", i18next.t("permission:Suspended"), ); default: return null; } diff --git a/web/src/UserEditPage.js b/web/src/UserEditPage.js index f1084e65..bb0b187b 100644 --- a/web/src/UserEditPage.js +++ b/web/src/UserEditPage.js @@ -390,7 +390,7 @@ class UserEditPage extends React.Component {