diff --git a/iap/galaxystore.go b/iap/galaxystore.go new file mode 100644 index 0000000..023d052 --- /dev/null +++ b/iap/galaxystore.go @@ -0,0 +1,110 @@ +package iap + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" +) + +type GalaxyStoreIAPVerifier struct { + ItemId string `json:"itemId"` + PaymentId string `json:"paymentId"` + OrderId string `json:"orderId"` + PackageName string `json:"packageName"` + ItemName string `json:"itemName"` + ItemDesc string `json:"itemDesc"` + PurchaseDate string `json:"purchaseDate"` + PaymentAmount string `json:"paymentAmount"` + Status string `json:"status"` + PaymentMethod string `json:"paymentMethod"` + Mode string `json:"mode"` + ConsumeYN string `json:"consumeYN"` + ConsumeDate string `json:"consumeDate"` + ConsumeDeviceModel string `json:"consumeDeviceModel"` + PassThroughParam string `json:"passThroughParam"` + CurrencyCode string `json:"currencyCode"` + CurrencyUnit string `json:"currencyUnit"` + VerificationLog string +} + +func (verifer *GalaxyStoreIAPVerifier) Verify(purchaseId string, paymentId string, developerPayload string, itemId string, packageName string) (bool, error) { + + client := &http.Client{} + req, err := http.NewRequest("GET", "https://iap.samsungapps.com/iap/v6/receipt?purchaseID="+purchaseId, nil) + if err != nil { + return false, err + } + req.Header.Add("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return false, err + } + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return false, err + } + + json.Unmarshal(body, verifer) + + verifer.VerificationLog = string(body) + + if verifer.Status != "success" { + return false, errors.New("Status is wrong:" + verifer.Status) + } + + if verifer.ItemId != itemId { + return false, errors.New("itemId is different") + } + + if verifer.PackageName != packageName { + return false, errors.New("packageName is different") + } + + if !strings.EqualFold(verifer.PassThroughParam, developerPayload) { + return false, errors.New("developerPayload is different : " + verifer.PassThroughParam + "/" + developerPayload) + } + + if verifer.PaymentId != paymentId { + return false, errors.New("paymentId is different") + } + + return true, nil +} + +// Owned Item - mItemId: kd_currency_gold_75 +// Owned Item - mItemName: Gold x75 +// Owned Item - mItemPrice: 1300.000000 +// Owned Item - mItemPriceString: ₩1,300 +// Owned Item - mCurrencyUnit: ₩ +// Owned Item - mCurrencyCode: KRW +// Owned Item - mItemDesc: 75 Gold +// Owned Item - mPaymentId: TPMTID20240208KR32371566 +// Owned Item - mPurchaseId: 34c50c9677e8e837e7b7a6eaac37a385fb7e4b36437820825bc7d824647c6aa7 +// Owned Item - mPurchaseDate: -2147483648 +// Owned Item - mPassThroughParam: testpayload + +// { +// "itemId": "kd_currency_gold_75", +// "paymentId": "TPMTID20240208KR32367386", +// "orderId": "P20240208KR32367386", +// "packageName": "com.yjmgames.kingdom.gal", +// "itemName": "Gold x75", +// "itemDesc": "75 Gold", +// "purchaseDate": "2024-02-08 02:33:24", +// "paymentAmount": "1300.0", +// "status": "success", +// "paymentMethod": "Credit Card", +// "mode": "TEST", +// "consumeYN": "Y", +// "consumeDate": "2024-02-08 02:52:10", +// "consumeDeviceModel": "SM-S918N", +// "passThroughParam": "testpayload", +// "currencyCode": "KRW", +// "currencyUnit": "₩" +// } diff --git a/iap/iap.go b/iap/iap.go new file mode 100644 index 0000000..e3f213d --- /dev/null +++ b/iap/iap.go @@ -0,0 +1,173 @@ +package iap + +import ( + "context" + b64 "encoding/base64" + "encoding/json" + "io/ioutil" + "net/http" + "os" + + "go-ayo/logger" + + "github.com/awa/go-iap/appstore/api" + "github.com/awa/go-iap/playstore" +) + +var PlayStorePublicKey string +var PlayStorePackageName string +var PlayStoreClient *playstore.Client + +type PlayStoreReceiptInfo struct { + OrderId string `json:"orderId"` + PackageName string `json:"packageName"` + ProductId string `json:"productId"` + PurchaseTime int `json:"purchaseTime"` + PurchaseState int `json:"purchaseState"` + PurchaseToken string `json:"purchaseToken"` + Quantity int `json:"quantity"` + Acknowledged bool `json:"acknowledged"` +} + +func PlayStoreInit(packageName string, publickey string) { + jsonKey, err := ioutil.ReadFile("playstore-iap-kingdom.json") + if err != nil { + logger.Fatal(err) + return + } + + PlayStoreClient, err = playstore.New(jsonKey) + if err != nil { + logger.Fatal(err) + return + } + + PlayStorePublicKey = publickey + PlayStorePackageName = packageName +} + +func PlayStoreVerifyReceipt(receptData string, signature string) (isVerified bool, receiptInfo PlayStoreReceiptInfo) { + var info PlayStoreReceiptInfo + uDec, _ := b64.URLEncoding.DecodeString(receptData) + + bOk, err := playstore.VerifySignature(PlayStorePublicKey, []byte(uDec), signature) + if bOk == false || err != nil { + logger.Println("playstore - VerifySignature fail :" + err.Error()) + return bOk, info + } + json.Unmarshal(uDec, &info) + return bOk, info +} + +func PlayStoreVerifyProduct(info PlayStoreReceiptInfo) (bool, int, int) { + + productPurchases, err := PlayStoreClient.VerifyProduct(context.Background(), PlayStorePackageName, info.ProductId, info.PurchaseToken) + if err != nil { + logger.Println("playstore - PlayStoreVerifyProduct fail :" + err.Error()) + return false, -1, -1 + } + // fmt.Println(productPurchases) + // fmt.Println("================") + // fmt.Println(productPurchases.PurchaseState) + // fmt.Println(productPurchases.ConsumptionState) + // fmt.Println("================") + return true, int(productPurchases.PurchaseState), int(productPurchases.ConsumptionState) +} + +func PlayStoreConsumeProduct(info PlayStoreReceiptInfo) bool { + + err := PlayStoreClient.ConsumeProduct(context.Background(), PlayStorePackageName, info.ProductId, info.PurchaseToken) + if err != nil { + logger.Error("playstore - PlayStoreConsumeProduct fail :" + err.Error()) + return false + } + + return true +} + +var AppStoreClient *api.StoreClient + +func AppStoreInit(keyPath string, configFilePath string) { + keyContentBytes, err := os.ReadFile(keyPath) + if err != nil { + logger.Fatal(err) + return + } + configBytes, err := os.ReadFile(configFilePath) + if err != nil { + logger.Fatal(err) + return + } + + var configMap map[string]any + err = json.Unmarshal(configBytes, &configMap) + + config := &api.StoreConfig{ + KeyContent: keyContentBytes, + KeyID: configMap["KeyID"].(string), + BundleID: configMap["BundleID"].(string), + Issuer: configMap["Issuer"].(string), + Sandbox: configMap["Sandbox"].(bool), + } + + AppStoreClient = api.NewStoreClient(config) + + if config.Sandbox { + logger.Println("IPA - Appstore : Use Sandbox") + } else { + logger.Println("IPA - Appstore : Normal") + } +} + +func AppStoreVerifyReceipt(transactionId string) (bool, *api.JWSTransaction) { + res, err := AppStoreClient.GetTransactionInfo(context.Background(), transactionId) + if err != nil { + logger.Error(err) + return false, nil + } + + transaction, err := AppStoreClient.ParseSignedTransaction(res.SignedTransactionInfo) + if err != nil { + logger.Error(err) + return false, nil + } + + if transaction.TransactionID != transactionId { + return false, nil + } + + if len(transaction.OriginalTransactionId) == 0 { + return false, nil + } + + return true, transaction +} + +func AppStoreConsumeProduct(transaction *api.JWSTransaction) bool { + //https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest + consumeReqBody := api.ConsumptionRequestBody{ + AccountTenure: 0, + AppAccountToken: transaction.AppAccountToken, + ConsumptionStatus: 3, + CustomerConsented: true, + DeliveryStatus: 0, + LifetimeDollarsPurchased: 0, + LifetimeDollarsRefunded: 0, + Platform: 1, + PlayTime: 0, + SampleContentProvided: false, + UserStatus: 1, + } + statusCode, err := AppStoreClient.SendConsumptionInfo(context.Background(), transaction.OriginalTransactionId, consumeReqBody) + + if statusCode != http.StatusAccepted { + return false + } + + if err != nil { + logger.Error(err) + return false + } + + return true +} diff --git a/iap/onestore.go b/iap/onestore.go new file mode 100644 index 0000000..09c98a1 --- /dev/null +++ b/iap/onestore.go @@ -0,0 +1,249 @@ +package iap + +import ( + "bytes" + "encoding/json" + "errors" + "go-ayo/logger" + "io/ioutil" + "net/http" + "strings" + "sync" + "time" +) + +// Ref https://onestore-dev.gitbook.io/dev/tools/tools/v21/06.-api-api-v7#accesstoken +const shouldGenerateAccessTokenSec = 600 + +type OneStoreIAPVerificationInfo struct { + ConsumptionState int `json:"consumptionState"` + DeveloperPayload string `json:"developerPayload"` + PurchaseState int `json:"purchaseState"` + PurchaseTime int64 `json:"purchaseTime"` + PurchaseId string `json:"purchaseId"` + AcknowledgeState int `json:"acknowledgeState"` + Quantity int `json:"quantity"` +} + +type OneStoreIAPClientPurcharinfo struct { + OrderId string `json:"orderId"` + PackageName string `json:"packageName"` + ProductId string `json:"productId"` + PurchaseTime int64 `json:"purchaseTime"` + PurchaseId string `json:"purchaseId"` + PurchaseToken string `json:"purchaseToken"` + DeveloperPayload string `json:"developerPayload"` + Quantity int `json:"quantity"` +} + +type OneStoreConfig struct { + OneClientID string + OneClientSecret string +} + +type OneStoreTokenData struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type,omitempty"` + ClientId string `json:"client_id,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + Scope string `json:"scope,omitempty"` + Expiry time.Time +} + +func (s *OnestoreAccessToken) HostURL() string { + if s.isSandBox { + //-- Sandbox + return "https://sbpp.onestore.co.kr" + + } else { + //-- RealURL + return "https://iap-apis.onestore.net" + } +} + +func (s *OnestoreAccessToken) GenerateNewToken() error { + var newToken OneStoreTokenData + + //== + HostURL := s.HostURL() + data := []byte("grant_type=client_credentials&client_id=" + s.config.OneClientID + "&client_secret=" + s.config.OneClientSecret) + + client := &http.Client{} + req, err := http.NewRequest("POST", HostURL+"/v7/oauth/token", bytes.NewBuffer(data)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("x-market-code", s.marketCode) + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + json.Unmarshal(body, &newToken) + + newExpiredSec := (newToken.ExpiresIn - shouldGenerateAccessTokenSec) + if newExpiredSec < 0 { + newExpiredSec = 0 + } + newToken.Expiry = time.Now().Round(0).Add(time.Duration(newExpiredSec) * time.Second) + s.data = &newToken + logger.Println("IAP - OneStore : Generate Access Token :", s.data) + return nil +} + +type OnestoreAccessToken struct { + mu sync.Mutex // guards t + data *OneStoreTokenData + + marketCode string + isSandBox bool + config *OneStoreConfig +} + +func (t *OnestoreAccessToken) Valid() bool { + return t != nil && t.data != nil && t.data.AccessToken != "" && !t.expired() +} + +func (t *OnestoreAccessToken) expired() bool { + return t.data.Expiry.Before(time.Now()) +} + +func (s *OnestoreAccessToken) Token() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.Valid() { + return s.data.AccessToken, nil + } + //== Expire 되면 새로 얻어 오자.. + err := s.GenerateNewToken() + if err != nil { + return "", err + } + return s.data.AccessToken, nil +} + +type OnestoreIAPVerifier struct { + config OneStoreConfig + SandBox_OnestoreAccessToken_MKT_ONE OnestoreAccessToken + SandBox_OnestoreAccessToken_MKT_GLB OnestoreAccessToken + Live_OnestoreAccessToken_MKT_ONE OnestoreAccessToken + Live_OnestoreAccessToken_MKT_GLB OnestoreAccessToken +} + +func (s *OnestoreIAPVerifier) Init(clientId string, clientSecret string) { + s.config.OneClientID = clientId + s.config.OneClientSecret = clientSecret + s.SandBox_OnestoreAccessToken_MKT_ONE.marketCode = "MKT_ONE" + s.SandBox_OnestoreAccessToken_MKT_GLB.marketCode = "MKT_GLB" + s.Live_OnestoreAccessToken_MKT_ONE.marketCode = "MKT_ONE" + s.Live_OnestoreAccessToken_MKT_GLB.marketCode = "MKT_GLB" + s.SandBox_OnestoreAccessToken_MKT_ONE.isSandBox = true + s.SandBox_OnestoreAccessToken_MKT_GLB.isSandBox = true + s.Live_OnestoreAccessToken_MKT_ONE.isSandBox = false + s.Live_OnestoreAccessToken_MKT_GLB.isSandBox = false + s.SandBox_OnestoreAccessToken_MKT_ONE.config = &s.config + s.SandBox_OnestoreAccessToken_MKT_GLB.config = &s.config + s.Live_OnestoreAccessToken_MKT_ONE.config = &s.config + s.Live_OnestoreAccessToken_MKT_GLB.config = &s.config +} + +func (s *OnestoreIAPVerifier) Verify(marketCode string, isSandBox bool, packageName string, clientInfo OneStoreIAPClientPurcharinfo) (bool, error) { + var accesstoken string + + if marketCode != "MKT_ONE" && marketCode != "MKT_GLB" { + return false, errors.New("marketCode is wrong") + } + + if clientInfo.ProductId == "" { + return false, errors.New("productId is empty") + } + + if clientInfo.PurchaseToken == "" { + return false, errors.New("purchaseToken is empty") + } + + if packageName != clientInfo.PackageName { + return false, errors.New("packageName is wrong") + } + + var err error + var HostURL string + if marketCode == "MKT_ONE" { + if isSandBox { + accesstoken, err = s.SandBox_OnestoreAccessToken_MKT_ONE.Token() + HostURL = s.SandBox_OnestoreAccessToken_MKT_ONE.HostURL() + } else { + accesstoken, err = s.Live_OnestoreAccessToken_MKT_ONE.Token() + HostURL = s.Live_OnestoreAccessToken_MKT_ONE.HostURL() + } + } else { + if isSandBox { + accesstoken, err = s.SandBox_OnestoreAccessToken_MKT_GLB.Token() + HostURL = s.SandBox_OnestoreAccessToken_MKT_GLB.HostURL() + } else { + accesstoken, err = s.Live_OnestoreAccessToken_MKT_GLB.Token() + HostURL = s.Live_OnestoreAccessToken_MKT_GLB.HostURL() + } + } + if err != nil { + return false, err + } + + client := &http.Client{} + req, err := http.NewRequest("GET", HostURL+"/v7/apps/"+s.config.OneClientID+"/purchases/inapp/products/"+clientInfo.ProductId+"/"+clientInfo.PurchaseToken, nil) + if err != nil { + return false, err + } + req.Header.Add("Authorization", "Bearer "+accesstoken) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("x-market-code", marketCode) + + resp, err := client.Do(req) + if err != nil { + return false, err + } + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return false, err + } + + var verificatioinfo OneStoreIAPVerificationInfo + json.Unmarshal(body, &verificatioinfo) + + // fmt.Println("================") + // fmt.Println(string(body)) + // fmt.Println("================") + + if verificatioinfo.PurchaseState != 0 { + return false, errors.New("canceled payment") + } + + if !strings.EqualFold(clientInfo.DeveloperPayload, verificatioinfo.DeveloperPayload) { + return false, errors.New("developerPayload is wrong :" + clientInfo.DeveloperPayload + "/" + verificatioinfo.DeveloperPayload) + } + + if clientInfo.PurchaseId != verificatioinfo.PurchaseId { + return false, errors.New("purchaseId is wrong") + } + + if clientInfo.PurchaseTime != verificatioinfo.PurchaseTime { + return false, errors.New("purchaseTime is wrong") + } + + if clientInfo.Quantity != verificatioinfo.Quantity { + return false, errors.New("quantity is wrong") + } + + return true, nil +}