feat: support Pricings flow (#2250)

* feat: fix price display

* feat: support subscription

* feat: fix select-plan-> signup -> buy-plan -> login flow

* feat: support paid-user to login and jump to the pricing page

* feat: support more subscription state

* feat: add payment providers for plan

* feat: format code

* feat: gofumpt

* feat: redirect to buy-plan-result page when user have pending subscription

* feat: response err when pricing don't exit

* Update PricingListPage.js

* Update ProductBuyPage.js

* Update LoginPage.js

---------

Co-authored-by: hsluoyz <hsluoyz@qq.com>
This commit is contained in:
haiwu
2023-08-24 23:20:50 +08:00
committed by GitHub
parent 8073dfa88c
commit 05b2f00057
31 changed files with 759 additions and 295 deletions

View File

@ -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, *, *
`

View File

@ -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

View File

@ -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)

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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

View File

@ -31,12 +31,18 @@ type Plan struct {
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
}

View File

@ -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 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)
}
func (pricing *Pricing) HasPlan(owner string, plan string) (bool, error) {
selectedPlan, err := GetPlan(fmt.Sprintf("%s/%s", owner, plan))
return err
}
ok, err := pricing.HasPlan(planName)
if err != nil {
return false, err
return err
}
if selectedPlan == nil {
return false, nil
if !ok {
return fmt.Errorf("pricing: %s does not have plan: %s", pricingName, planName)
}
result := false
for _, pricingPlan := range pricing.Plans {
if strings.Contains(pricingPlan, selectedPlan.Name) {
result = true
break
}
}
return result, nil
return nil
}

View File

@ -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
}

View File

@ -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"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
Duration int `json:"duration"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
Description string `xorm:"varchar(100)" json:"description"`
User string `xorm:"mediumtext" json:"user"`
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"`
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)
}

View File

@ -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") {

View File

@ -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

View File

@ -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() {

View File

@ -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 {
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/login" render={(props) => {return (<LoginPage {...this.props} application={this.state.application} type={"cas"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />);}} />
<Route exact path="/select-plan/:owner/:pricingName" render={(props) => this.renderHomeIfLoggedIn(<PricingPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />)} />
<Route exact path="/select-plan/:owner/:pricingName" render={(props) => <PricingPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />} />
<Route exact path="/buy-plan/:owner/:pricingName" render={(props) => <ProductBuyPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />} />
<Route exact path="/buy-plan/:owner/:pricingName/result" render={(props) => <PaymentResultPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />} />
</Switch>
</div>
);

View File

@ -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 (
<Link to={`/products/${record.owner}/${record.productName}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("product:Price"),

View File

@ -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,29 +47,78 @@ class PaymentResultPage extends React.Component {
}
}
getPayment() {
PaymentBackend.getPayment(this.state.organizationName, this.state.paymentName)
.then((res) => {
this.setState({
payment: res.data,
setStateAsync(state) {
return new Promise((resolve, reject) => {
this.setState(state, () => {
resolve();
});
// window.console.log("payment=", res.data);
if (res.data.state === "Created") {
if (["PayPal", "Stripe"].includes(res.data.type)) {
});
}
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(() => {
PaymentBackend.notifyPayment(this.state.organizationName, this.state.paymentName)
.then((res) => {
timeout: setTimeout(async() => {
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);
this.getPayment();
});
}, 1000),
});
} else {
this.setState({timeout: setTimeout(() => this.getPayment(), 1000)});
}
}
this.setState({
timeout: setTimeout(() => this.getPayment(), 1000),
});
}
}
} catch (err) {
Setting.showMessage("error", err.message);
return;
}
}
goToPaymentUrl(payment) {
if (payment.returnUrl === undefined || payment.returnUrl === null || payment.returnUrl === "") {
@ -81,7 +137,7 @@ class PaymentResultPage extends React.Component {
if (payment.state === "Paid") {
return (
<div>
<div className="login-content">
{
Setting.renderHelmet(payment)
}
@ -101,7 +157,7 @@ class PaymentResultPage extends React.Component {
);
} else if (payment.state === "Created") {
return (
<div>
<div className="login-content">
{
Setting.renderHelmet(payment)
}
@ -117,7 +173,7 @@ class PaymentResultPage extends React.Component {
);
} else if (payment.state === "Canceled") {
return (
<div>
<div className="login-content">
{
Setting.renderHelmet(payment)
}
@ -137,7 +193,7 @@ class PaymentResultPage extends React.Component {
);
} else if (payment.state === "Timeout") {
return (
<div>
<div className="login-content">
{
Setting.renderHelmet(payment)
}
@ -157,7 +213,7 @@ class PaymentResultPage extends React.Component {
);
} else {
return (
<div>
<div className="login-content">
{
Setting.renderHelmet(payment)
}

View File

@ -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 {
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.role} onChange={(value => {this.updatePlanField("role", value);})}
options={this.state.roles.map((role) => Setting.getOption(`${role.owner}/${role.name}`, `${role.owner}/${role.name}`))
options={this.state.roles.map((role) => Setting.getOption(role.name, role.name))
} />
</Col>
</Row>
@ -216,6 +232,18 @@ class PlanEditPage extends React.Component {
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("product:Payment providers"), i18next.t("product:Payment providers - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} mode="multiple" style={{width: "100%"}} value={this.state.plan.paymentProviders ?? []} onChange={(value => {this.updatePlanField("paymentProviders", value);})}>
{
this.state.paymentProviders.map((provider, index) => <Option key={index} value={provider.name}>{provider.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :

View File

@ -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 (
<Link to={`/roles/${encodeURIComponent(text)}`}>
<Link to={`/roles/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("plan:Related product"),
dataIndex: "product",
key: "product",
width: "130px",
...this.getColumnSearchProps("product"),
render: (text, record, index) => {
return (
<Link to={`/products/${record.owner}/${text}`}>
{text}
</Link>
);

View File

@ -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))}
/>
</Col>
</Row>
@ -294,7 +294,7 @@ class PricingEditPage extends React.Component {
</Button>
</Col>
<Col>
<PricingPage pricing={this.state.pricing}></PricingPage>
<PricingPage pricing={this.state.pricing} owner={this.state.pricing.owner}></PricingPage>
</Col>
</React.Fragment>
);

View File

@ -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 (
<Link to={`/applications/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
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 (
<div>
<Row>
{
plans.map((plan) => (
<Col key={plan}>
<div style={{display: "inline", marginRight: "20px"}}>
<Tooltip placement="topLeft" title="Edit">
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/plans/${record.owner}/${plan}`)} />
</Tooltip>
<Link to={`/plans/${record.owner}/${plan}`}>
{plan}
</Link>
</div>
</Col>
))
}
</Row>
</div>
);
},
},
{
title: i18next.t("general:Is enabled"),
dataIndex: "isEnabled",

View File

@ -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);
try {
// load pricing & plan
if (this.state.pricingName) {
if (!this.state.planName || !this.state.userName) {
return ;
}
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 (
<div>
<div className="login-content">
<Spin spinning={this.state.isPlacingOrder} size="large" tip={i18next.t("product:Placing order...")} style={{paddingTop: "10%"}} >
<Descriptions title={i18next.t("product:Buy Product")} bordered>
<Descriptions title={<span style={{fontSize: 28}}>{i18next.t("product:Buy Product")}</span>} bordered>
<Descriptions.Item label={i18next.t("general:Name")} span={3}>
<span style={{fontSize: 28}}>
<span style={{fontSize: 25}}>
{Setting.getLanguageText(product?.displayName)}
</span>
</Descriptions.Item>

View File

@ -1170,9 +1170,9 @@ export function getTags(tags, urlPrefix = null) {
return res;
}
export function getTag(color, text) {
export function getTag(color, text, icon) {
return (
<Tag color={color}>
<Tag color={color} icon={icon}>
{text}
</Tag>
);
@ -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;
}
}

View File

@ -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 {
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.owner} onChange={(owner => {
this.updateSubscriptionField("owner", owner);
this.getUsers(owner);
this.getPlanes(owner);
this.getPlans(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
@ -171,21 +183,21 @@ class SubscriptionEditPage extends React.Component {
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Start date"), i18next.t("subscription:Start date - Tooltip"))}
{Setting.getLabel(i18next.t("subscription:Start time"), i18next.t("subscription:Start time - Tooltip"))}
</Col>
<Col span={22} >
<DatePicker value={dayjs(this.state.subscription.startDate)} onChange={value => {
this.updateSubscriptionField("startDate", value);
<DatePicker value={dayjs(this.state.subscription.startTime)} onChange={value => {
this.updateSubscriptionField("startTime", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:End date"), i18next.t("subscription:End date - Tooltip"))}
{Setting.getLabel(i18next.t("subscription:End time"), i18next.t("subscription:End time - Tooltip"))}
</Col>
<Col span={22} >
<DatePicker value={dayjs(this.state.subscription.endDate)} onChange={value => {
this.updateSubscriptionField("endDate", value);
<DatePicker value={dayjs(this.state.subscription.endTime)} onChange={value => {
this.updateSubscriptionField("endTime", value);
}} />
</Col>
</Row>
@ -196,21 +208,42 @@ class SubscriptionEditPage extends React.Component {
<Col span={22} >
<Select style={{width: "100%"}} value={this.state.subscription.user}
onChange={(value => {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))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Pricing"), i18next.t("general:Pricing - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.pricing}
onChange={(value => {this.updateSubscriptionField("pricing", value);})}
options={this.state.pricings.map((pricing) => Setting.getOption(pricing.name, pricing.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Plan"), i18next.t("general:Plan - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.plan} onChange={(value => {this.updateSubscriptionField("plan", value);})}
options={this.state.planes.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.plan}
onChange={(value => {this.updateSubscriptionField("plan", value);})}
options={this.state.plans.map((plan) => Setting.getOption(plan.name, plan.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Payment"), i18next.t("general:Payment - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.payment} disabled={true} onChange={e => {
this.updateSubscriptionField("payment", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
@ -221,46 +254,6 @@ class SubscriptionEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.subscription.isEnabled} onChange={checked => {
this.updateSubscriptionField("isEnabled", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("permission:Submitter"), i18next.t("permission:Submitter - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.subscription.submitter} onChange={e => {
this.updateSubscriptionField("submitter", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("permission:Approver"), i18next.t("permission:Approver - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.subscription.approver} onChange={e => {
this.updateSubscriptionField("approver", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("permission:Approve time"), i18next.t("permission:Approve time - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={Setting.getFormattedDate(this.state.subscription.approveTime)} onChange={e => {
this.updatePermissionField("approveTime", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{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))}
/>
</Col>

View File

@ -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 (
<Link to={`/pricings/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Plan"),
dataIndex: "plan",
@ -147,7 +172,7 @@ class SubscriptionListPage extends BaseListPage {
...this.getColumnSearchProps("plan"),
render: (text, record, index) => {
return (
<Link to={`/plans/${text}`}>
<Link to={`/plans/${record.owner}/${text}`}>
{text}
</Link>
);
@ -161,7 +186,21 @@ class SubscriptionListPage extends BaseListPage {
...this.getColumnSearchProps("user"),
render: (text, record, index) => {
return (
<Link to={`/users/${text}`}>
<Link to={`/users/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Payment"),
dataIndex: "payment",
key: "payment",
width: "140px",
...this.getColumnSearchProps("payment"),
render: (text, record, index) => {
return (
<Link to={`/payments/${record.owner}/${text}`}>
{text}
</Link>
);
@ -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"), <ExclamationCircleOutlined />);
case "Active":
return Setting.getTag("success", i18next.t("permission:Active"), <SyncOutlined spin />);
case "Upcoming":
return Setting.getTag("warning", i18next.t("permission:Upcoming"), <ClockCircleOutlined />);
case "Expired":
return Setting.getTag("warning", i18next.t("permission:Expired"), <ClockCircleOutlined />);
case "Error":
return Setting.getTag("error", i18next.t("permission:Error"), <CloseCircleOutlined />);
case "Suspended":
return Setting.getTag("default", i18next.t("permission:Suspended"), <MinusCircleOutlined />);
default:
return null;
}

View File

@ -390,7 +390,7 @@ class UserEditPage extends React.Component {
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.user.type} onChange={(value => {this.updateUserField("type", value);})}
options={["normal-user"].map(item => Setting.getOption(item, item))}
options={["normal-user", "paid-user"].map(item => Setting.getOption(item, item))}
/>
</Col>
</Row>

View File

@ -403,6 +403,14 @@ class LoginPage extends React.Component {
/>);
},
});
} else if (res.data === "SelectPlan") {
// paid-user does not have active or pending subscription, go to application default pricing page to select-plan
const pricing = res.data2;
Setting.goToLink(`/select-plan/${pricing.owner}/${pricing.name}?user=${values.username}`);
} else if (res.data === "BuyPlanResult") {
// paid-user has pending subscription, go to buy-plan/result apge to notify payment result
const sub = res.data2;
Setting.goToLink(`/buy-plan/${sub.owner}/${sub.pricing}/result?subscription=${sub.name}`);
} else {
callback(res);
}

View File

@ -133,7 +133,11 @@ class SignupPage extends React.Component {
});
}
getResultPath(application) {
getResultPath(application, signupParams) {
if (signupParams?.plan && signupParams?.pricing) {
// the prompt page needs the user to be signed in, so for paid-user sign up, just go to buy-plan page
return `/buy-plan/${application.organization}/${signupParams?.pricing}?user=${signupParams.username}&plan=${signupParams.plan}`;
}
if (authConfig.appName === application.name) {
return "/result";
} else {
@ -173,13 +177,13 @@ class SignupPage extends React.Component {
const application = this.getApplicationObj();
const params = new URLSearchParams(window.location.search);
values["plan"] = params.get("plan");
values["pricing"] = params.get("pricing");
values.plan = params.get("plan");
values.pricing = params.get("pricing");
AuthBackend.signup(values)
.then((res) => {
if (res.status === "ok") {
if (Setting.hasPromptPage(application)) {
if (Setting.hasPromptPage(application) && (!values.plan || !values.pricing)) {
AuthBackend.getAccount("")
.then((res) => {
let account = null;
@ -188,13 +192,13 @@ class SignupPage extends React.Component {
account.organization = res.data2;
this.onUpdateAccount(account);
Setting.goToLinkSoft(this, this.getResultPath(application));
Setting.goToLinkSoft(this, this.getResultPath(application, values));
} else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
}
});
} else {
Setting.goToLinkSoft(this, this.getResultPath(application));
Setting.goToLinkSoft(this, this.getResultPath(application, values));
}
} else {
Setting.showMessage("error", i18next.t(`signup:${res.msg}`));

View File

@ -24,18 +24,8 @@ export function getPlans(owner, page = "", pageSize = "", field = "", value = ""
}).then(res => res.json());
}
export function getPlanById(id, includeOption = false) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${id}&includeOption=${includeOption}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPlan(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${owner}/${encodeURIComponent(name)}`, {
export function getPlan(owner, name, includeOption = false) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${owner}/${encodeURIComponent(name)}&includeOption=${includeOption}`, {
method: "GET",
credentials: "include",
headers: {

View File

@ -70,8 +70,8 @@ export function deleteProduct(product) {
}).then(res => res.json());
}
export function buyProduct(owner, name, providerId) {
return fetch(`${Setting.ServerUrl}/api/buy-product?id=${owner}/${encodeURIComponent(name)}&providerName=${providerId}`, {
export function buyProduct(owner, name, providerName, pricingName = "", planName = "", userName = "") {
return fetch(`${Setting.ServerUrl}/api/buy-product?id=${owner}/${encodeURIComponent(name)}&providerName=${providerName}&pricingName=${pricingName}&planName=${planName}&userName=${userName}`, {
method: "POST",
credentials: "include",
headers: {

View File

@ -24,11 +24,13 @@ import i18next from "i18next";
class PricingPage extends React.Component {
constructor(props) {
super(props);
const params = new URLSearchParams(window.location.search);
this.state = {
classes: props,
applications: null,
owner: props.owner ?? (props.match?.params?.owner ?? null),
pricingName: (props.pricingName ?? props.match?.params?.pricingName) ?? null,
userName: params.get("user"),
pricing: props.pricing,
plans: null,
loading: false,
@ -39,7 +41,9 @@ class PricingPage extends React.Component {
this.setState({
applications: [],
});
if (this.state.userName) {
Setting.showMessage("info", `${i18next.t("pricing:paid-user do not have active subscription or pending subscription, please select a plan to buy")}`);
}
if (this.state.pricing) {
this.loadPlans();
} else {
@ -60,7 +64,7 @@ class PricingPage extends React.Component {
loadPlans() {
const plans = this.state.pricing.plans.map((plan) =>
PlanBackend.getPlanById(plan, true));
PlanBackend.getPlan(this.state.owner, plan, true));
Promise.all(plans)
.then(results => {
@ -70,7 +74,7 @@ class PricingPage extends React.Component {
return;
}
this.setState({
plans: results,
plans: results.map(result => result.data),
loading: false,
});
})
@ -90,7 +94,6 @@ class PricingPage extends React.Component {
Setting.showMessage("error", res.msg);
return;
}
this.setState({
loading: false,
pricing: res.data,
@ -105,9 +108,12 @@ class PricingPage extends React.Component {
renderCards() {
const getUrlByPlan = (plan) => {
const getUrlByPlan = (planName) => {
const pricing = this.state.pricing;
const signUpUrl = `/signup/${pricing.application}?plan=${plan}&pricing=${pricing.name}`;
let signUpUrl = `/signup/${pricing.application}?plan=${planName}&pricing=${pricing.name}`;
if (this.state.userName) {
signUpUrl = `/buy-plan/${pricing.owner}/${pricing.name}?plan=${planName}&user=${this.state.userName}`;
}
return `${window.location.origin}${signUpUrl}`;
};

View File

@ -29,21 +29,16 @@ class SingleCard extends React.Component {
}
renderCard(plan, isSingle, link) {
return (
<Col style={{minWidth: "320px", paddingLeft: "20px", paddingRight: "20px", paddingBottom: "20px", marginBottom: "20px", paddingTop: "0px"}} span={6}>
<Card
hoverable
onClick={() => Setting.isMobile() ? window.location.href = link : null}
style={isSingle ? {width: "320px", height: "100%"} : {width: "100%", height: "100%", paddingTop: "0px"}}
title={<h2>{plan.displayName}</h2>}
>
<div style={{textAlign: "right"}}>
<h2
style={{marginTop: "0px"}}>{plan.displayName}</h2>
</div>
<div style={{textAlign: "left"}} className="px-10 mt-5">
<span style={{fontWeight: 700, fontSize: "48px"}}>$ {plan.pricePerMonth}</span>
<span style={{fontSize: "40px", fontWeight: 700}}>{Setting.getCurrencySymbol(plan.currency)} {plan.pricePerMonth}</span>
<span style={{fontSize: "18px", fontWeight: 600, color: "gray"}}> {i18next.t("plan:per month")}</span>
</div>