Compare commits

..

4 Commits

Author SHA1 Message Date
Coki
7f9f7c6468 feat: add CLI tools auto-downloader and updater (#3559)
* feat: add CLI downloader feature

* feat: add CLI refresh endpoint and scheduler

* feat: improve binary names mapping for different platforms and architectures

* fix: format binary names in getBinaryNames function

* fix: change file permission notation to octal in cli_downloader.go

* feat: add isDemoMode check for CLI downloader features
2025-02-07 19:22:56 +08:00
Cutsin
b7a818e2d3 feat: support AirWallex payment provider (#3558)
* feat: support AirWallex payment provider

* chore: add some information due to AirWallex's risk control policy
2025-02-07 19:19:30 +08:00
DacongDA
1a8cfe4ee6 feat: can fetch SAML metadata from URL (#3560) 2025-02-06 23:50:39 +08:00
hsluoyz
b3526de675 feat: add checkOrgMasterVerificationCode() 2025-02-06 23:46:22 +08:00
40 changed files with 952 additions and 19 deletions

View File

@@ -99,6 +99,7 @@ p, *, *, GET, /api/get-all-objects, *, *
p, *, *, GET, /api/get-all-actions, *, * p, *, *, GET, /api/get-all-actions, *, *
p, *, *, GET, /api/get-all-roles, *, * p, *, *, GET, /api/get-all-roles, *, *
p, *, *, GET, /api/run-casbin-command, *, * p, *, *, GET, /api/run-casbin-command, *, *
p, *, *, POST, /api/refresh-engines, *, *
p, *, *, GET, /api/get-invitation-info, *, * p, *, *, GET, /api/get-invitation-info, *, *
p, *, *, GET, /api/faceid-signin-begin, *, * p, *, *, GET, /api/faceid-signin-begin, *, *
` `

View File

@@ -910,11 +910,20 @@ func (c *ApiController) Login() {
return return
} }
err = mfaUtil.Verify(authForm.Passcode) passed, err := c.checkOrgMasterVerificationCode(user, authForm.Passcode)
if err != nil { if err != nil {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
if !passed {
err = mfaUtil.Verify(authForm.Passcode)
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.SetSession("verificationCodeType", "") c.SetSession("verificationCodeType", "")
} else if authForm.RecoveryCode != "" { } else if authForm.RecoveryCode != "" {
err = object.MfaRecover(user, authForm.RecoveryCode) err = object.MfaRecover(user, authForm.RecoveryCode)

View File

@@ -0,0 +1,502 @@
package controllers
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/beego/beego"
)
const (
javaCliRepo = "https://api.github.com/repos/jcasbin/casbin-java-cli/releases/latest"
goCliRepo = "https://api.github.com/repos/casbin/casbin-go-cli/releases/latest"
rustCliRepo = "https://api.github.com/repos/casbin-rs/casbin-rust-cli/releases/latest"
downloadFolder = "bin"
)
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
// @Title getBinaryNames
// @Description Get binary names for different platforms and architectures
// @Success 200 {map[string]string} map[string]string "Binary names map"
func getBinaryNames() map[string]string {
const (
golang = "go"
java = "java"
rust = "rust"
)
arch := runtime.GOARCH
archMap := map[string]struct{ goArch, rustArch string }{
"amd64": {"x86_64", "x86_64"},
"arm64": {"arm64", "aarch64"},
}
archNames, ok := archMap[arch]
if !ok {
archNames = struct{ goArch, rustArch string }{arch, arch}
}
switch runtime.GOOS {
case "windows":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Windows_%s.zip", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-pc-windows-gnu", archNames.rustArch),
}
case "darwin":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Darwin_%s.tar.gz", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-apple-darwin", archNames.rustArch),
}
case "linux":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Linux_%s.tar.gz", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-unknown-linux-gnu", archNames.rustArch),
}
default:
return nil
}
}
// @Title getFinalBinaryName
// @Description Get final binary name for specific language
// @Param lang string true "Language type (go/java/rust)"
// @Success 200 {string} string "Final binary name"
func getFinalBinaryName(lang string) string {
switch lang {
case "go":
if runtime.GOOS == "windows" {
return "casbin-go-cli.exe"
}
return "casbin-go-cli"
case "java":
return "casbin-java-cli.jar"
case "rust":
if runtime.GOOS == "windows" {
return "casbin-rust-cli.exe"
}
return "casbin-rust-cli"
default:
return ""
}
}
// @Title getLatestCLIURL
// @Description Get latest CLI download URL from GitHub
// @Param repoURL string true "GitHub repository URL"
// @Param language string true "Language type"
// @Success 200 {string} string "Download URL and version"
func getLatestCLIURL(repoURL string, language string) (string, string, error) {
resp, err := http.Get(repoURL)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", "", err
}
binaryNames := getBinaryNames()
if binaryNames == nil {
return "", "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
}
binaryName := binaryNames[language]
for _, asset := range release.Assets {
if asset.Name == binaryName {
return asset.URL, release.TagName, nil
}
}
return "", "", fmt.Errorf("no suitable binary found for OS: %s, language: %s", runtime.GOOS, language)
}
// @Title extractGoCliFile
// @Description Extract the Go CLI file
// @Param filePath string true "The file path"
// @Success 200 {string} string "The extracted file path"
// @router /extractGoCliFile [post]
func extractGoCliFile(filePath string) error {
tempDir := filepath.Join(downloadFolder, "temp")
if err := os.MkdirAll(tempDir, 0o755); err != nil {
return err
}
defer os.RemoveAll(tempDir)
if runtime.GOOS == "windows" {
if err := unzipFile(filePath, tempDir); err != nil {
return err
}
} else {
if err := untarFile(filePath, tempDir); err != nil {
return err
}
}
execName := "casbin-go-cli"
if runtime.GOOS == "windows" {
execName += ".exe"
}
var execPath string
err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if info.Name() == execName {
execPath = path
return nil
}
return nil
})
if err != nil {
return err
}
finalPath := filepath.Join(downloadFolder, execName)
if err := os.Rename(execPath, finalPath); err != nil {
return err
}
return os.Remove(filePath)
}
// @Title unzipFile
// @Description Unzip the file
// @Param zipPath string true "The zip file path"
// @Param destDir string true "The destination directory"
// @Success 200 {string} string "The extracted file path"
// @router /unzipFile [post]
func unzipFile(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
// @Title untarFile
// @Description Untar the file
// @Param tarPath string true "The tar file path"
// @Param destDir string true "The destination directory"
// @Success 200 {string} string "The extracted file path"
// @router /untarFile [post]
func untarFile(tarPath, destDir string) error {
file, err := os.Open(tarPath)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
case tar.TypeReg:
outFile, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
}
}
return nil
}
// @Title createJavaCliWrapper
// @Description Create the Java CLI wrapper
// @Param binPath string true "The binary path"
// @Success 200 {string} string "The created file path"
// @router /createJavaCliWrapper [post]
func createJavaCliWrapper(binPath string) error {
if runtime.GOOS == "windows" {
// Create a Windows CMD file
cmdPath := filepath.Join(binPath, "casbin-java-cli.cmd")
cmdContent := fmt.Sprintf(`@echo off
java -jar "%s\casbin-java-cli.jar" %%*`, binPath)
err := os.WriteFile(cmdPath, []byte(cmdContent), 0o755)
if err != nil {
return fmt.Errorf("failed to create Java CLI wrapper: %v", err)
}
} else {
// Create Unix shell script
shPath := filepath.Join(binPath, "casbin-java-cli")
shContent := fmt.Sprintf(`#!/bin/sh
java -jar "%s/casbin-java-cli.jar" "$@"`, binPath)
err := os.WriteFile(shPath, []byte(shContent), 0o755)
if err != nil {
return fmt.Errorf("failed to create Java CLI wrapper: %v", err)
}
}
return nil
}
// @Title downloadCLI
// @Description Download and setup CLI tools
// @Success 200 {error} error "Error if any"
func downloadCLI() error {
pathEnv := os.Getenv("PATH")
binPath, err := filepath.Abs(downloadFolder)
if err != nil {
return fmt.Errorf("failed to get absolute path to download directory: %v", err)
}
if !strings.Contains(pathEnv, binPath) {
newPath := fmt.Sprintf("%s%s%s", binPath, string(os.PathListSeparator), pathEnv)
if err := os.Setenv("PATH", newPath); err != nil {
return fmt.Errorf("failed to update PATH environment variable: %v", err)
}
}
if err := os.MkdirAll(downloadFolder, 0o755); err != nil {
return fmt.Errorf("failed to create download directory: %v", err)
}
repos := map[string]string{
"java": javaCliRepo,
"go": goCliRepo,
"rust": rustCliRepo,
}
for lang, repo := range repos {
cliURL, version, err := getLatestCLIURL(repo, lang)
if err != nil {
fmt.Printf("failed to get %s CLI URL: %v\n", lang, err)
continue
}
originalPath := filepath.Join(downloadFolder, getBinaryNames()[lang])
fmt.Printf("downloading %s CLI: %s\n", lang, cliURL)
resp, err := http.Get(cliURL)
if err != nil {
fmt.Printf("failed to download %s CLI: %v\n", lang, err)
continue
}
func() {
defer resp.Body.Close()
out, err := os.Create(originalPath)
if err != nil {
fmt.Printf("failed to create %s CLI file: %v\n", lang, err)
return
}
defer out.Close()
if _, err = io.Copy(out, resp.Body); err != nil {
fmt.Printf("failed to save %s CLI: %v\n", lang, err)
return
}
}()
if lang == "go" {
if err := extractGoCliFile(originalPath); err != nil {
fmt.Printf("failed to extract Go CLI: %v\n", err)
continue
}
} else {
finalPath := filepath.Join(downloadFolder, getFinalBinaryName(lang))
if err := os.Rename(originalPath, finalPath); err != nil {
fmt.Printf("failed to rename %s CLI: %v\n", lang, err)
continue
}
}
if runtime.GOOS != "windows" {
execPath := filepath.Join(downloadFolder, getFinalBinaryName(lang))
if err := os.Chmod(execPath, 0o755); err != nil {
fmt.Printf("failed to set %s CLI execution permission: %v\n", lang, err)
continue
}
}
fmt.Printf("downloaded %s CLI version: %s\n", lang, version)
if lang == "java" {
if err := createJavaCliWrapper(binPath); err != nil {
fmt.Printf("failed to create Java CLI wrapper: %v\n", err)
continue
}
}
}
return nil
}
// @Title RefreshEngines
// @Tag CLI API
// @Description Refresh all CLI engines
// @Param m query string true "Hash for request validation"
// @Param t query string true "Timestamp for request validation"
// @Success 200 {object} controllers.Response The Response object
// @router /refresh-engines [post]
func (c *ApiController) RefreshEngines() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
c.ResponseError("refresh engines is only available in demo mode")
return
}
hash := c.Input().Get("m")
timestamp := c.Input().Get("t")
if hash == "" || timestamp == "" {
c.ResponseError("invalid identifier")
return
}
requestTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
c.ResponseError("invalid identifier")
return
}
timeDiff := time.Since(requestTime)
if timeDiff > 5*time.Minute || timeDiff < -5*time.Minute {
c.ResponseError("invalid identifier")
return
}
version := "casbin-editor-v1"
rawString := fmt.Sprintf("%s|%s", version, timestamp)
hasher := sha256.New()
hasher.Write([]byte(rawString))
calculatedHash := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if calculatedHash != strings.ToLower(hash) {
c.ResponseError("invalid identifier")
return
}
err = downloadCLI()
if err != nil {
c.ResponseError(fmt.Sprintf("failed to refresh engines: %v", err))
return
}
c.ResponseOk(map[string]string{
"status": "success",
"message": "CLI engines updated successfully",
})
}
// @Title ScheduleCLIUpdater
// @Description Start periodic CLI update scheduler
func ScheduleCLIUpdater() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
return
}
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
err := downloadCLI()
if err != nil {
fmt.Printf("failed to update CLI: %v\n", err)
} else {
fmt.Println("CLI updated successfully")
}
}
}
// @Title DownloadCLI
// @Description Download the CLI
// @Success 200 {string} string "The downloaded file path"
// @router /downloadCLI [post]
func DownloadCLI() error {
return downloadCLI()
}
// @Title InitCLIDownloader
// @Description Initialize CLI downloader and start update scheduler
func InitCLIDownloader() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
return
}
err := DownloadCLI()
if err != nil {
fmt.Printf("failed to initialize CLI downloader: %v\n", err)
}
go ScheduleCLIUpdater()
}

View File

@@ -510,20 +510,28 @@ func (c *ApiController) VerifyCode() {
} }
} }
result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage()) passed, err := c.checkOrgMasterVerificationCode(user, authForm.Code)
if err != nil { if err != nil {
c.ResponseError(c.T(err.Error())) c.ResponseError(c.T(err.Error()))
return return
} }
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
return
}
err = object.DisableVerificationCode(checkDest) if !passed {
if err != nil { result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage())
c.ResponseError(err.Error()) if err != nil {
return c.ResponseError(err.Error())
return
}
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
return
}
err = object.DisableVerificationCode(checkDest)
if err != nil {
c.ResponseError(err.Error())
return
}
} }
c.SetSession("verifiedCode", authForm.Code) c.SetSession("verifiedCode", authForm.Code)

View File

@@ -0,0 +1,36 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"fmt"
"github.com/casdoor/casdoor/object"
)
func (c *ApiController) checkOrgMasterVerificationCode(user *object.User, code string) (bool, error) {
organization, err := object.GetOrganizationByUser(user)
if err != nil {
return false, err
}
if organization == nil {
return false, fmt.Errorf("The organization: %s does not exist", user.Owner)
}
if organization.MasterVerificationCode != "" && organization.MasterVerificationCode == code {
return true, nil
}
return false, nil
}

View File

@@ -22,6 +22,7 @@ import (
_ "github.com/beego/beego/session/redis" _ "github.com/beego/beego/session/redis"
"github.com/casdoor/casdoor/authz" "github.com/casdoor/casdoor/authz"
"github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/controllers"
"github.com/casdoor/casdoor/ldap" "github.com/casdoor/casdoor/ldap"
"github.com/casdoor/casdoor/object" "github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/proxy" "github.com/casdoor/casdoor/proxy"
@@ -45,6 +46,7 @@ func main() {
object.InitCasvisorConfig() object.InitCasvisorConfig()
util.SafeGoroutine(func() { object.RunSyncUsersJob() }) util.SafeGoroutine(func() { object.RunSyncUsersJob() })
controllers.InitCLIDownloader()
// beego.DelStaticPath("/static") // beego.DelStaticPath("/static")
// beego.SetStaticPath("/static", "web/build/static") // beego.SetStaticPath("/static", "web/build/static")

View File

@@ -219,8 +219,11 @@ func BuyProduct(id string, user *User, providerName, pricingName, planName, host
ProductName: product.Name, ProductName: product.Name,
PayerName: payerName, PayerName: payerName,
PayerId: user.Id, PayerId: user.Id,
PayerEmail: user.Email,
PaymentName: paymentName, PaymentName: paymentName,
ProductDisplayName: product.DisplayName, ProductDisplayName: product.DisplayName,
ProductDescription: product.Description,
ProductImage: product.Image,
Price: product.Price, Price: product.Price,
Currency: product.Currency, Currency: product.Currency,
ReturnUrl: returnUrl, ReturnUrl: returnUrl,

View File

@@ -325,6 +325,12 @@ func GetPaymentProvider(p *Provider) (pp.PaymentProvider, error) {
return nil, err return nil, err
} }
return pp, nil return pp, nil
} else if typ == "AirWallex" {
pp, err := pp.NewAirwallexPaymentProvider(p.ClientId, p.ClientSecret)
if err != nil {
return nil, err
}
return pp, nil
} else if typ == "Balance" { } else if typ == "Balance" {
pp, err := pp.NewBalancePaymentProvider() pp, err := pp.NewBalancePaymentProvider()
if err != nil { if err != nil {

289
pp/airwallex.go Normal file
View File

@@ -0,0 +1,289 @@
package pp
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/conf"
)
type AirwallexPaymentProvider struct {
Client *AirwallexClient
}
func NewAirwallexPaymentProvider(clientId string, apiKey string) (*AirwallexPaymentProvider, error) {
isProd := conf.GetConfigString("runmode") == "prod"
apiEndpoint := "https://api-demo.airwallex.com/api/v1"
apiCheckout := "https://checkout-demo.airwallex.com/#/standalone/checkout?"
if isProd {
apiEndpoint = "https://api.airwallex.com/api/v1"
apiCheckout = "https://checkout.airwallex.com/#/standalone/checkout?"
}
client := &AirwallexClient{
ClientId: clientId,
APIKey: apiKey,
APIEndpoint: apiEndpoint,
APICheckout: apiCheckout,
client: &http.Client{Timeout: 15 * time.Second},
}
pp := &AirwallexPaymentProvider{
Client: client,
}
return pp, nil
}
func (pp *AirwallexPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
// Create a payment intent
intent, err := pp.Client.CreateIntent(r)
if err != nil {
return nil, err
}
payUrl, err := pp.Client.GetCheckoutUrl(intent, r)
if err != nil {
return nil, err
}
return &PayResp{
PayUrl: payUrl,
OrderId: intent.MerchantOrderId,
}, nil
}
func (pp *AirwallexPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
notifyResult := &NotifyResult{}
intent, err := pp.Client.GetIntentByOrderId(orderId)
if err != nil {
return nil, err
}
// Check intent status
switch intent.Status {
case "PENDING", "REQUIRES_PAYMENT_METHOD", "REQUIRES_CUSTOMER_ACTION", "REQUIRES_CAPTURE":
notifyResult.PaymentStatus = PaymentStateCreated
return notifyResult, nil
case "CANCELLED":
notifyResult.PaymentStatus = PaymentStateCanceled
return notifyResult, nil
case "EXPIRED":
notifyResult.PaymentStatus = PaymentStateTimeout
return notifyResult, nil
case "SUCCEEDED":
// Skip
default:
notifyResult.PaymentStatus = PaymentStateError
notifyResult.NotifyMessage = fmt.Sprintf("unexpected airwallex checkout status: %v", intent.Status)
return notifyResult, nil
}
// Check attempt status
if intent.PaymentStatus != "" {
switch intent.PaymentStatus {
case "CANCELLED", "EXPIRED", "RECEIVED", "AUTHENTICATION_REDIRECTED", "AUTHORIZED", "CAPTURE_REQUESTED":
notifyResult.PaymentStatus = PaymentStateCreated
return notifyResult, nil
case "PAID", "SETTLED":
// Skip
default:
notifyResult.PaymentStatus = PaymentStateError
notifyResult.NotifyMessage = fmt.Sprintf("unexpected airwallex checkout payment status: %v", intent.PaymentStatus)
return notifyResult, nil
}
}
// The Payment has succeeded.
var productDisplayName, productName, providerName string
if description, ok := intent.Metadata["description"]; ok {
productName, productDisplayName, providerName, _ = parseAttachString(description.(string))
}
orderId = intent.MerchantOrderId
return &NotifyResult{
PaymentName: orderId,
PaymentStatus: PaymentStatePaid,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: priceStringToFloat64(intent.Amount.String()),
Currency: intent.Currency,
OrderId: orderId,
}, nil
}
func (pp *AirwallexPaymentProvider) GetInvoice(paymentName, personName, personIdCard, personEmail, personPhone, invoiceType, invoiceTitle, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *AirwallexPaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}
/*
* Airwallex Client implementation (to be removed upon official SDK release)
*/
type AirwallexClient struct {
ClientId string
APIKey string
APIEndpoint string
APICheckout string
client *http.Client
tokenCache *AirWallexTokenInfo
tokenMutex sync.RWMutex
}
type AirWallexTokenInfo struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
parsedExpiresAt time.Time
}
type AirWallexIntentResp struct {
Id string `json:"id"`
ClientSecret string `json:"client_secret"`
MerchantOrderId string `json:"merchant_order_id"`
}
func (c *AirwallexClient) GetToken() (string, error) {
c.tokenMutex.Lock()
defer c.tokenMutex.Unlock()
if c.tokenCache != nil && time.Now().Before(c.tokenCache.parsedExpiresAt) {
return c.tokenCache.Token, nil
}
req, _ := http.NewRequest("POST", c.APIEndpoint+"/authentication/login", bytes.NewBuffer([]byte("{}")))
req.Header.Set("x-client-id", c.ClientId)
req.Header.Set("x-api-key", c.APIKey)
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result AirWallexTokenInfo
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Token == "" {
return "", fmt.Errorf("invalid token response")
}
expiresAt := strings.Replace(result.ExpiresAt, "+0000", "+00:00", 1)
result.parsedExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
c.tokenCache = &result
return result.Token, nil
}
func (c *AirwallexClient) authRequest(method, url string, body interface{}) (map[string]interface{}, error) {
token, err := c.GetToken()
if err != nil {
return nil, err
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest(method, url, bytes.NewBuffer(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *AirwallexClient) CreateIntent(r *PayReq) (*AirWallexIntentResp, error) {
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
orderId := r.PaymentName
intentReq := map[string]interface{}{
"currency": r.Currency,
"amount": r.Price,
"merchant_order_id": orderId,
"request_id": orderId,
"descriptor": string([]rune(description)[:32]), // display to the customer.
"metadata": map[string]interface{}{"description": description},
"order": map[string]interface{}{"products": []map[string]interface{}{{"name": r.ProductDisplayName, "quantity": 1, "desc": r.ProductDescription, "image_url": r.ProductImage}}},
"customer": map[string]interface{}{"merchant_customer_id": r.PayerId, "email": r.PayerEmail, "first_name": r.PayerName, "last_name": r.PayerName},
}
intentUrl := fmt.Sprintf("%s/pa/payment_intents/create", c.APIEndpoint)
intentRes, err := c.authRequest("POST", intentUrl, intentReq)
if err != nil {
return nil, fmt.Errorf("failed to create payment intent: %v", err)
}
return &AirWallexIntentResp{
Id: intentRes["id"].(string),
ClientSecret: intentRes["client_secret"].(string),
MerchantOrderId: intentRes["merchant_order_id"].(string),
}, nil
}
type AirwallexIntent struct {
Amount json.Number `json:"amount"`
Currency string `json:"currency"`
Id string `json:"id"`
Status string `json:"status"`
Descriptor string `json:"descriptor"`
MerchantOrderId string `json:"merchant_order_id"`
LatestPaymentAttempt struct {
Status string `json:"status"`
} `json:"latest_payment_attempt"`
Metadata map[string]interface{} `json:"metadata"`
}
type AirwallexIntents struct {
Items []AirwallexIntent `json:"items"`
}
type AirWallexIntentInfo struct {
Amount json.Number
Currency string
Id string
Status string
Descriptor string
MerchantOrderId string
PaymentStatus string
Metadata map[string]interface{}
}
func (c *AirwallexClient) GetIntentByOrderId(orderId string) (*AirWallexIntentInfo, error) {
intentUrl := fmt.Sprintf("%s/pa/payment_intents/?merchant_order_id=%s", c.APIEndpoint, orderId)
intentRes, err := c.authRequest("GET", intentUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to get payment intent: %v", err)
}
items := intentRes["items"].([]interface{})
if len(items) == 0 {
return nil, fmt.Errorf("no payment intent found for order id: %s", orderId)
}
var intent AirwallexIntent
if b, err := json.Marshal(items[0]); err == nil {
json.Unmarshal(b, &intent)
}
return &AirWallexIntentInfo{
Id: intent.Id,
Amount: intent.Amount,
Currency: intent.Currency,
Status: intent.Status,
Descriptor: intent.Descriptor,
MerchantOrderId: intent.MerchantOrderId,
PaymentStatus: intent.LatestPaymentAttempt.Status,
Metadata: intent.Metadata,
}, nil
}
func (c *AirwallexClient) GetCheckoutUrl(intent *AirWallexIntentResp, r *PayReq) (string, error) {
return fmt.Sprintf("%sintent_id=%s&client_secret=%s&mode=payment&currency=%s&amount=%v&requiredBillingContactFields=%s&successUrl=%s&failUrl=%s&logoUrl=%s",
c.APICheckout,
intent.Id,
intent.ClientSecret,
r.Currency,
r.Price,
url.QueryEscape(`["address"]`),
r.ReturnUrl,
r.ReturnUrl,
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=", // replace default logo
), nil
}

View File

@@ -33,8 +33,11 @@ type PayReq struct {
ProductName string ProductName string
PayerName string PayerName string
PayerId string PayerId string
PayerEmail string
PaymentName string PaymentName string
ProductDisplayName string ProductDisplayName string
ProductDescription string
ProductImage string
Price float64 Price float64
Currency string Currency string

View File

@@ -175,6 +175,7 @@ func initAPI() {
beego.Router("/api/get-all-roles", &controllers.ApiController{}, "GET:GetAllRoles") beego.Router("/api/get-all-roles", &controllers.ApiController{}, "GET:GetAllRoles")
beego.Router("/api/run-casbin-command", &controllers.ApiController{}, "GET:RunCasbinCommand") beego.Router("/api/run-casbin-command", &controllers.ApiController{}, "GET:RunCasbinCommand")
beego.Router("/api/refresh-engines", &controllers.ApiController{}, "POST:RefreshEngines")
beego.Router("/api/get-sessions", &controllers.ApiController{}, "GET:GetSessions") beego.Router("/api/get-sessions", &controllers.ApiController{}, "GET:GetSessions")
beego.Router("/api/get-session", &controllers.ApiController{}, "GET:GetSingleSession") beego.Router("/api/get-session", &controllers.ApiController{}, "GET:GetSingleSession")

View File

@@ -122,7 +122,7 @@ class PaymentResultPage extends React.Component {
payment: payment, payment: payment,
}); });
if (payment.state === "Created") { if (payment.state === "Created") {
if (["PayPal", "Stripe", "Alipay", "WeChat Pay", "Balance"].includes(payment.type)) { if (["PayPal", "Stripe", "AirWallex", "Alipay", "WeChat Pay", "Balance"].includes(payment.type)) {
this.setState({ this.setState({
timeout: setTimeout(async() => { timeout: setTimeout(async() => {
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName); await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);

View File

@@ -238,6 +238,8 @@ class ProductBuyPage extends React.Component {
text = i18next.t("product:PayPal"); text = i18next.t("product:PayPal");
} else if (provider.type === "Stripe") { } else if (provider.type === "Stripe") {
text = i18next.t("product:Stripe"); text = i18next.t("product:Stripe");
} else if (provider.type === "AirWallex") {
text = i18next.t("product:AirWallex");
} }
return ( return (

View File

@@ -467,6 +467,39 @@ class ProviderEditPage extends React.Component {
this.updateProviderField("issuerUrl", issuerUrl); this.updateProviderField("issuerUrl", issuerUrl);
} }
fetchSamlMetadata() {
this.setState({
metadataLoading: true,
});
fetch(this.state.requestUrl, {
method: "GET",
}).then(res => {
if (!res.ok) {
return Promise.reject("error");
}
return res.text();
}).then(text => {
this.updateProviderField("metadata", text);
this.parseSamlMetadata();
Setting.showMessage("success", i18next.t("general:Successfully added"));
}).catch(err => {
Setting.showMessage("error", err.message);
}).finally(() => {
this.setState({
metadataLoading: false,
});
});
}
parseSamlMetadata() {
try {
this.loadSamlConfiguration();
Setting.showMessage("success", i18next.t("provider:Parse metadata successfully"));
} catch (err) {
Setting.showMessage("error", i18next.t("provider:Can not parse metadata"));
}
}
renderProvider() { renderProvider() {
return ( return (
<Card size="small" title={ <Card size="small" title={
@@ -1242,6 +1275,21 @@ class ProviderEditPage extends React.Component {
}} /> }} />
</Col> </Col>
</Row> </Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata url"), i18next.t("provider:Metadata url - Tooltip"))} :
</Col>
<Col span={6} >
<Input value={this.state.requestUrl} onChange={e => {
this.setState({
requestUrl: e.target.value,
});
}} />
</Col>
<Col span={16} >
<Button type="primary" loading={this.state.metadataLoading} onClick={() => {this.fetchSamlMetadata();}}>{i18next.t("general:Request")}</Button>
</Col>
</Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} : {Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
@@ -1255,14 +1303,7 @@ class ProviderEditPage extends React.Component {
<Row style={{marginTop: "20px"}}> <Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={2} /> <Col style={{marginTop: "5px"}} span={2} />
<Col span={2}> <Col span={2}>
<Button type="primary" onClick={() => { <Button type="primary" onClick={() => {this.parseSamlMetadata();}}>
try {
this.loadSamlConfiguration();
Setting.showMessage("success", i18next.t("provider:Parse metadata successfully"));
} catch (err) {
Setting.showMessage("error", i18next.t("provider:Can not parse metadata"));
}
}}>
{i18next.t("provider:Parse")} {i18next.t("provider:Parse")}
</Button> </Button>
</Col> </Col>

View File

@@ -279,6 +279,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/social_stripe.png`, logo: `${StaticBaseUrl}/img/social_stripe.png`,
url: "https://stripe.com/", url: "https://stripe.com/",
}, },
"AirWallex": {
logo: `${StaticBaseUrl}/img/payment_airwallex.svg`,
url: "https://airwallex.com/",
},
"GC": { "GC": {
logo: `${StaticBaseUrl}/img/payment_gc.png`, logo: `${StaticBaseUrl}/img/payment_gc.png`,
url: "https://gc.org", url: "https://gc.org",
@@ -1106,6 +1110,7 @@ export function getProviderTypeOptions(category) {
{id: "WeChat Pay", name: "WeChat Pay"}, {id: "WeChat Pay", name: "WeChat Pay"},
{id: "PayPal", name: "PayPal"}, {id: "PayPal", name: "PayPal"},
{id: "Stripe", name: "Stripe"}, {id: "Stripe", name: "Stripe"},
{id: "AirWallex", name: "AirWallex"},
{id: "GC", name: "GC"}, {id: "GC", name: "GC"},
]); ]);
} else if (category === "Captcha") { } else if (category === "Captcha") {

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Prodáno", "Sold": "Prodáno",
"Sold - Tooltip": "Prodávané množství", "Sold - Tooltip": "Prodávané množství",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Štítek produktu", "Tag - Tooltip": "Štítek produktu",
"Test buy page..": "Testovací stránka nákupu..", "Test buy page..": "Testovací stránka nákupu..",
"There is no payment channel for this product.": "Pro tento produkt neexistuje žádný platební kanál.", "There is no payment channel for this product.": "Pro tento produkt neexistuje žádný platební kanál.",

View File

@@ -757,6 +757,7 @@
"Sold": "Verkauft", "Sold": "Verkauft",
"Sold - Tooltip": "Menge verkauft", "Sold - Tooltip": "Menge verkauft",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag des Produkts", "Tag - Tooltip": "Tag des Produkts",
"Test buy page..": "Testkaufseite.", "Test buy page..": "Testkaufseite.",
"There is no payment channel for this product.": "Es gibt keinen Zahlungskanal für dieses Produkt.", "There is no payment channel for this product.": "Es gibt keinen Zahlungskanal für dieses Produkt.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Vendido", "Sold": "Vendido",
"Sold - Tooltip": "Cantidad vendida", "Sold - Tooltip": "Cantidad vendida",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Etiqueta de producto", "Tag - Tooltip": "Etiqueta de producto",
"Test buy page..": "Página de compra de prueba.", "Test buy page..": "Página de compra de prueba.",
"There is no payment channel for this product.": "No hay canal de pago para este producto.", "There is no payment channel for this product.": "No hay canal de pago para este producto.",

View File

@@ -757,6 +757,7 @@
"Sold": "فروخته شده", "Sold": "فروخته شده",
"Sold - Tooltip": "تعداد فروخته شده", "Sold - Tooltip": "تعداد فروخته شده",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "برچسب محصول", "Tag - Tooltip": "برچسب محصول",
"Test buy page..": "صفحه تست خرید..", "Test buy page..": "صفحه تست خرید..",
"There is no payment channel for this product.": "برای این محصول کانال پرداختی وجود ندارد.", "There is no payment channel for this product.": "برای این محصول کانال پرداختی وجود ندارد.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Vendu", "Sold": "Vendu",
"Sold - Tooltip": "Quantité vendue", "Sold - Tooltip": "Quantité vendue",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Étiquette de produit", "Tag - Tooltip": "Étiquette de produit",
"Test buy page..": "Page d'achat de test.", "Test buy page..": "Page d'achat de test.",
"There is no payment channel for this product.": "Il n'y a aucun canal de paiement pour ce produit.", "There is no payment channel for this product.": "Il n'y a aucun canal de paiement pour ce produit.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Terjual", "Sold": "Terjual",
"Sold - Tooltip": "Jumlah terjual", "Sold - Tooltip": "Jumlah terjual",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag produk", "Tag - Tooltip": "Tag produk",
"Test buy page..": "Halaman pembelian uji coba.", "Test buy page..": "Halaman pembelian uji coba.",
"There is no payment channel for this product.": "Tidak ada saluran pembayaran untuk produk ini.", "There is no payment channel for this product.": "Tidak ada saluran pembayaran untuk produk ini.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "売れました", "Sold": "売れました",
"Sold - Tooltip": "販売数量", "Sold - Tooltip": "販売数量",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "製品のタグ", "Tag - Tooltip": "製品のタグ",
"Test buy page..": "テスト購入ページ。", "Test buy page..": "テスト購入ページ。",
"There is no payment channel for this product.": "この製品には支払いチャネルがありません。", "There is no payment channel for this product.": "この製品には支払いチャネルがありません。",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "팔렸습니다", "Sold": "팔렸습니다",
"Sold - Tooltip": "판매량", "Sold - Tooltip": "판매량",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "제품 태그", "Tag - Tooltip": "제품 태그",
"Test buy page..": "시험 구매 페이지.", "Test buy page..": "시험 구매 페이지.",
"There is no payment channel for this product.": "이 제품에 대한 결제 채널이 없습니다.", "There is no payment channel for this product.": "이 제품에 대한 결제 채널이 없습니다.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Vendido", "Sold": "Vendido",
"Sold - Tooltip": "Quantidade vendida", "Sold - Tooltip": "Quantidade vendida",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag do produto", "Tag - Tooltip": "Tag do produto",
"Test buy page..": "Página de teste de compra...", "Test buy page..": "Página de teste de compra...",
"There is no payment channel for this product.": "Não há canal de pagamento disponível para este produto.", "There is no payment channel for this product.": "Não há canal de pagamento disponível para este produto.",

View File

@@ -757,6 +757,7 @@
"Sold": "Продано", "Sold": "Продано",
"Sold - Tooltip": "Количество проданных", "Sold - Tooltip": "Количество проданных",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Метка продукта", "Tag - Tooltip": "Метка продукта",
"Test buy page..": "Страница для тестовой покупки.", "Test buy page..": "Страница для тестовой покупки.",
"There is no payment channel for this product.": "Для этого продукта нет канала оплаты.", "There is no payment channel for this product.": "Для этого продукта нет канала оплаты.",

View File

@@ -757,6 +757,7 @@
"Sold": "Predané", "Sold": "Predané",
"Sold - Tooltip": "Množstvo predaných kusov", "Sold - Tooltip": "Množstvo predaných kusov",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Štítok produktu", "Tag - Tooltip": "Štítok produktu",
"Test buy page..": "Testovať stránku nákupu..", "Test buy page..": "Testovať stránku nákupu..",
"There is no payment channel for this product.": "Pre tento produkt neexistuje platobný kanál.", "There is no payment channel for this product.": "Pre tento produkt neexistuje platobný kanál.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold", "Sold": "Sold",
"Sold - Tooltip": "Quantity sold", "Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product", "Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..", "Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.", "There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Продано", "Sold": "Продано",
"Sold - Tooltip": "Продана кількість", "Sold - Tooltip": "Продана кількість",
"Stripe": "смужка", "Stripe": "смужка",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Тег товару", "Tag - Tooltip": "Тег товару",
"Test buy page..": "Сторінка тестової покупки..", "Test buy page..": "Сторінка тестової покупки..",
"There is no payment channel for this product.": "Для цього продукту немає платіжного каналу.", "There is no payment channel for this product.": "Для цього продукту немає платіжного каналу.",

View File

@@ -757,6 +757,7 @@
"Sold": "Đã bán", "Sold": "Đã bán",
"Sold - Tooltip": "Số lượng bán ra", "Sold - Tooltip": "Số lượng bán ra",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Nhãn sản phẩm", "Tag - Tooltip": "Nhãn sản phẩm",
"Test buy page..": "Trang mua thử.", "Test buy page..": "Trang mua thử.",
"There is no payment channel for this product.": "Không có kênh thanh toán cho sản phẩm này.", "There is no payment channel for this product.": "Không có kênh thanh toán cho sản phẩm này.",

View File

@@ -757,6 +757,7 @@
"Sold": "售出", "Sold": "售出",
"Sold - Tooltip": "已售出的数量", "Sold - Tooltip": "已售出的数量",
"Stripe": "Stripe", "Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "商品类别", "Tag - Tooltip": "商品类别",
"Test buy page..": "测试购买页面..", "Test buy page..": "测试购买页面..",
"There is no payment channel for this product.": "该商品没有付款方式。", "There is no payment channel for this product.": "该商品没有付款方式。",