├── .github └── workflows │ ├── tagging_release.yml │ └── unit_test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── amazon ├── notification.go ├── validator.go └── validator_test.go ├── appstore ├── api │ ├── cert.go │ ├── error.go │ ├── error_test.go │ ├── model.go │ ├── store.go │ ├── token.go │ └── validator.go ├── cert.go ├── mocks │ ├── appstore.go │ └── store.go ├── model.go ├── model_test.go ├── notification.go ├── notification_v2.go ├── validator.go └── validator_test.go ├── go.mod ├── go.sum ├── hms ├── client.go ├── model.go ├── modifier.go ├── notification.go ├── notification_v2.go └── validator.go ├── microsoftstore ├── model.go └── validator.go └── playstore ├── mocks └── playstore.go ├── notification.go ├── testdata └── test_key.json ├── validator.go └── validator_test.go /.github/workflows/tagging_release.yml: -------------------------------------------------------------------------------- 1 | name: Bump version and Create release. 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | tagging-release: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 2 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Bump version and push tag 14 | id: tag_version 15 | uses: mathieudutour/github-tag-action@v6.1 16 | with: 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | release_branches: master 19 | tag_prefix: "v" 20 | 21 | - name: Get latest release 22 | uses: actions/github-script@v6 23 | id: latest_release 24 | with: 25 | result-encoding: string 26 | retries: 3 27 | script: | 28 | const res = await github.rest.repos.getLatestRelease({ 29 | owner: context.repo.owner, 30 | repo: context.repo.repo, 31 | }) 32 | return res.data.tag_name; 33 | 34 | - name: Generate release note and Create release 35 | uses: actions/github-script@v6 36 | with: 37 | retries: 3 38 | script: | 39 | const note = await github.rest.repos.generateReleaseNotes({ 40 | owner: context.repo.owner, 41 | repo: context.repo.repo, 42 | tag_name: "${{ steps.tag_version.outputs.new_tag }}", 43 | previous_tag_name: "${{ steps.latest_release.outputs.result }}" 44 | }) 45 | 46 | const res = await github.rest.repos.createRelease({ 47 | owner: context.repo.owner, 48 | repo: context.repo.repo, 49 | tag_name: "${{ steps.tag_version.outputs.new_tag }}", 50 | name: note.data.name, 51 | body: note.data.body 52 | }) 53 | return res.data; -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | name: unit test 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | go: [ '1.22', '1.23', '1.24' ] 15 | name: Go ${{ matrix.go }} test 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Setup go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: ${{ matrix.go }} 22 | - name: test 23 | run: go test -v -race ./... 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | coverage.txt 27 | 28 | .idea 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 AWA Developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONEY: all 2 | all: setup cover 3 | 4 | .PHONEY: setup 5 | setup: 6 | go get golang.org/x/tools/cmd/cover 7 | go get google.golang.org/appengine/urlfetch 8 | go get ./... 9 | 10 | .PHONEY: test 11 | test: 12 | go test -v ./... 13 | 14 | .PHONEY: cover 15 | cover: 16 | go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 17 | 18 | .PHONEY: generate 19 | generate: 20 | rm -rf ./appstore/mocks/* 21 | rm -rf ./playstore/mocks/* 22 | go generate ./... 23 | 24 | .PHONEY: update tidy update_all 25 | update: update_all tidy 26 | 27 | tidy: 28 | GO111MODULE=on go mod tidy 29 | 30 | update_all: 31 | GO111MODULE=on go get -v -u ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-iap 2 | ====== 3 | 4 | ![](https://img.shields.io/badge/golang-1.22+-blue.svg?style=flat) 5 | [![unit test](https://github.com/awa/go-iap/actions/workflows/unit_test.yml/badge.svg)](https://github.com/awa/go-iap/actions/workflows/unit_test.yml) 6 | 7 | >go-iap verifies the purchase receipt via AppStore, GooglePlayStore, Amazon AppStore, HMS or MicrosoftStore. 8 | 9 | Current API Documents: 10 | 11 | * AppStore: [![GoDoc](https://godoc.org/github.com/awa/go-iap/appstore?status.svg)](https://godoc.org/github.com/awa/go-iap/appstore) 12 | * AppStore Server API: [![GoDoc](https://godoc.org/github.com/awa/go-iap/appstore?status.svg)](https://godoc.org/github.com/awa/go-iap/appstore/api) 13 | * GooglePlay: [![GoDoc](https://godoc.org/github.com/awa/go-iap/playstore?status.svg)](https://godoc.org/github.com/awa/go-iap/playstore) 14 | * Amazon AppStore: [![GoDoc](https://godoc.org/github.com/awa/go-iap/amazon?status.svg)](https://godoc.org/github.com/awa/go-iap/amazon) 15 | * Huawei HMS: [![GoDoc](https://godoc.org/github.com/awa/go-iap/hms?status.svg)](https://godoc.org/github.com/awa/go-iap/hms) 16 | * Microsoft Store: [![GoDoc](https://godoc.org/github.com/awa/go-iap/microsoftstore?status.svg)](https://godoc.org/github.com/awa/go-iap/microsoftstore) 17 | 18 | 19 | # Installation 20 | ``` 21 | go get github.com/awa/go-iap/appstore 22 | go get github.com/awa/go-iap/playstore 23 | go get github.com/awa/go-iap/amazon 24 | go get github.com/awa/go-iap/hms 25 | go get github.com/awa/go-iap/microsoftstore 26 | ``` 27 | 28 | 29 | # Quick Start 30 | 31 | ### In App Purchase (via App Store) 32 | 33 | ```go 34 | import( 35 | "github.com/awa/go-iap/appstore" 36 | ) 37 | 38 | func main() { 39 | client := appstore.New() 40 | req := appstore.IAPRequest{ 41 | ReceiptData: "your receipt data encoded by base64", 42 | } 43 | resp := &appstore.IAPResponse{} 44 | ctx := context.Background() 45 | err := client.Verify(ctx, req, resp) 46 | } 47 | ``` 48 | 49 | **Note**: The [verifyReceipt](https://developer.apple.com/documentation/appstorereceipts/verifyreceipt) API has been deprecated as of `5 Jun 2023`. Please use [App Store Server API](#in-app-store-server-api) instead. 50 | 51 | ### In App Billing (via GooglePlay) 52 | 53 | ```go 54 | import( 55 | "github.com/awa/go-iap/playstore" 56 | ) 57 | 58 | func main() { 59 | // You need to prepare a public key for your Android app's in app billing 60 | // at https://console.developers.google.com. 61 | jsonKey, err := ioutil.ReadFile("jsonKey.json") 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | 66 | client := playstore.New(jsonKey) 67 | ctx := context.Background() 68 | resp, err := client.VerifySubscription(ctx, "package", "subscriptionID", "purchaseToken") 69 | } 70 | ``` 71 | 72 | ### In App Purchase (via Amazon App Store) 73 | 74 | ```go 75 | import( 76 | "github.com/awa/go-iap/amazon" 77 | ) 78 | 79 | func main() { 80 | client := amazon.New("developerSecret") 81 | 82 | ctx := context.Background() 83 | resp, err := client.Verify(ctx, "userID", "receiptID") 84 | } 85 | ``` 86 | 87 | ### In App Purchase (via Huawei Mobile Services) 88 | 89 | ```go 90 | import( 91 | "github.com/awa/go-iap/hms" 92 | ) 93 | 94 | func main() { 95 | // If "orderSiteURL" and/or "subscriptionSiteURL" are empty, 96 | // they will be default to AppTouch German. 97 | // Please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-References-V5/api-common-statement-0000001050986127-V5 for details. 98 | client := hms.New("clientID", "clientSecret", "orderSiteURL", "subscriptionSiteURL") 99 | ctx := context.Background() 100 | resp, err := client.VerifySubscription(ctx, "purchaseToken", "subscriptionID", 1) 101 | } 102 | ``` 103 | 104 | ### In App Store Server API 105 | 106 | **Note** 107 | - The App Store Server API differentiates between a sandbox and a production environment based on the base URL: 108 | - Use https://api.storekit.itunes.apple.com/ for the production environment. 109 | - Use https://api.storekit-sandbox.itunes.apple.com/ for the sandbox environment. 110 | - If you're unsure about the environment, follow these steps: 111 | - Initiate a call to the endpoint using the production URL. If the call is successful, the transaction identifier is associated with the production environment. 112 | - If you encounter an error code `4040010`, indicating a `TransactionIdNotFoundError`, make a call to the endpoint using the sandbox URL. 113 | - If this call is successful, the transaction identifier is associated with the sandbox environment. If the call fails with the same error code, the transaction identifier doesn't exist in either environment. 114 | 115 | - GetTransactionInfo 116 | 117 | ```go 118 | import( 119 | "github.com/awa/go-iap/appstore/api" 120 | ) 121 | 122 | // For generate key file and download it, please refer to https://developer.apple.com/documentation/appstoreserverapi/creating_api_keys_to_use_with_the_app_store_server_api 123 | const ACCOUNTPRIVATEKEY = ` 124 | -----BEGIN PRIVATE KEY----- 125 | FAKEACCOUNTKEYBASE64FORMAT 126 | -----END PRIVATE KEY----- 127 | ` 128 | func main() { 129 | c := &api.StoreConfig{ 130 | KeyContent: []byte(ACCOUNTPRIVATEKEY), // Loads a .p8 certificate 131 | KeyID: "FAKEKEYID", // Your private key ID from App Store Connect (Ex: 2X9R4HXF34) 132 | BundleID: "fake.bundle.id", // Your app’s bundle ID 133 | Issuer: "xxxxx-xx-xx-xx-xxxxxxxxxx",// Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a") 134 | Sandbox: false, // default is Production 135 | } 136 | transactionId := "FAKETRANSACTIONID" 137 | a := api.NewStoreClient(c) 138 | ctx := context.Background() 139 | response, err := a.GetTransactionInfo(ctx, transactionId) 140 | 141 | transantion, err := a.ParseSignedTransaction(response.SignedTransactionInfo) 142 | if err != nil { 143 | // error handling 144 | } 145 | 146 | if transaction.TransactionId == transactionId { 147 | // the transaction is valid 148 | } 149 | } 150 | ``` 151 | 152 | - GetTransactionHistory 153 | 154 | ```go 155 | import( 156 | "github.com/awa/go-iap/appstore/api" 157 | ) 158 | 159 | // For generate key file and download it, please refer to https://developer.apple.com/documentation/appstoreserverapi/creating_api_keys_to_use_with_the_app_store_server_api 160 | const ACCOUNTPRIVATEKEY = ` 161 | -----BEGIN PRIVATE KEY----- 162 | FAKEACCOUNTKEYBASE64FORMAT 163 | -----END PRIVATE KEY----- 164 | ` 165 | func main() { 166 | c := &api.StoreConfig{ 167 | KeyContent: []byte(ACCOUNTPRIVATEKEY), // Loads a .p8 certificate 168 | KeyID: "FAKEKEYID", // Your private key ID from App Store Connect (Ex: 2X9R4HXF34) 169 | BundleID: "fake.bundle.id", // Your app’s bundle ID 170 | Issuer: "xxxxx-xx-xx-xx-xxxxxxxxxx",// Your issuer ID from App Store Connect (Users and Access > Integrations > In-App Purchase)(Ex: "57246542-96fe-1a63-e053-0824d011072a") 171 | Sandbox: false, // default is Production 172 | } 173 | originalTransactionId := "FAKETRANSACTIONID" 174 | a := api.NewStoreClient(c) 175 | query := &url.Values{} 176 | query.Set("productType", "AUTO_RENEWABLE") 177 | query.Set("productType", "NON_CONSUMABLE") 178 | ctx := context.Background() 179 | responses, err := a.GetTransactionHistory(ctx, originalTransactionId, query) 180 | 181 | for _, response := range responses { 182 | transantions, err := a.ParseSignedTransactions(response.SignedTransactions) 183 | } 184 | } 185 | ``` 186 | - Error handling 187 | - handler error per [apple store server api error](https://developer.apple.com/documentation/appstoreserverapi/error_codes) document 188 | - [error definition](./appstore/api/error.go) 189 | 190 | 191 | ### Parse Notification from App Store 192 | 193 | ```go 194 | import ( 195 | "github.com/awa/go-iap/appstore" 196 | "github.com/golang-jwt/jwt/v5" 197 | ) 198 | 199 | func main() { 200 | tokenStr := "SignedRenewalInfo Encode String" // or SignedTransactionInfo string 201 | token := jwt.Token{} 202 | client := appstore.New() 203 | err := client.ParseNotificationV2(tokenStr, &token) 204 | 205 | claims, ok := token.Claims.(jwt.MapClaims) 206 | for key, val := range claims { 207 | fmt.Printf("Key: %v, value: %v\n", key, val) // key value of SignedRenewalInfo 208 | } 209 | } 210 | ``` 211 | 212 | # ToDo 213 | - [x] Validator for In App Purchase Receipt (AppStore) 214 | - [x] Validator for Subscription token (GooglePlay) 215 | - [x] Validator for Purchase Product token (GooglePlay) 216 | - [ ] More Tests 217 | 218 | 219 | # Support 220 | 221 | ### In App Purchase 222 | This validator supports the receipt type for iOS7 or above. 223 | 224 | ### In App Billing 225 | This validator uses [Version 3 API](http://developer.android.com/google/play/billing/api.html). 226 | 227 | ### In App Purchase (Amazon) 228 | This validator uses [RVS for IAP v2.0](https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/verifying-receipts-in-iap-2.0). 229 | 230 | ### In App Purchase (HMS) 231 | This validator uses [Version 2 API](https://developer.huawei.com/consumer/en/doc/HMSCore-References-V5/api-common-statement-0000001050986127-V5). 232 | 233 | ### In App Store Server API 234 | This validator uses [Version 1.0+](https://developer.apple.com/documentation/appstoreserverapi) 235 | 236 | ### In App Purchase (Microsoft Store) 237 | This validator uses [Version 1.0](https://learn.microsoft.com/en-us/windows/uwp/monetize/view-and-grant-products-from-a-services) 238 | 239 | # License 240 | go-iap is licensed under the MIT. 241 | -------------------------------------------------------------------------------- /amazon/notification.go: -------------------------------------------------------------------------------- 1 | package amazon 2 | 3 | // NotificationType is type 4 | // https://developer.amazon.com/docs/in-app-purchasing/rtn-example.html 5 | type NotificationType string 6 | 7 | const ( 8 | NotificationTypeSubscription = "SUBSCRIPTION_PURCHASED" 9 | NotificationTypeConsumable = "CONSUMABLE_PURCHASED" 10 | NotificationTypeEntitlement = "ENTITLEMENT_PURCHASED" 11 | ) 12 | 13 | // Notification is struct for amazon notification 14 | type Notification struct { 15 | Type string `json:"Type"` 16 | MessageId string `json:"MessageId"` 17 | TopicArn string `json:"TopicArn"` 18 | Message string `json:"Message"` 19 | Timestamp string `json:"Timestamp"` 20 | SignatureVersion string `json:"SignatureVersion"` 21 | Signature string `json:"Signature"` 22 | SigningCertURL string `json:"SigningCertURL"` 23 | UnsubscribeURL string `json:"UnsubscribeURL"` 24 | } 25 | 26 | // NotificationMessage is struct for Message field of Notification 27 | type NotificationMessage struct { 28 | AppPackageName string `json:"appPackageName"` 29 | NotificationType NotificationType `json:"notificationType"` 30 | AppUserId string `json:"appUserId"` 31 | ReceiptId string `json:"receiptId"` 32 | RelatedReceipts struct{} `json:"relatedReceipts"` 33 | Timestamp int64 `json:"timestamp"` 34 | BetaProductTransaction bool `json:"betaProductTransaction"` 35 | } 36 | -------------------------------------------------------------------------------- /amazon/validator.go: -------------------------------------------------------------------------------- 1 | package amazon 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "time" 11 | ) 12 | 13 | const ( 14 | // SandboxURL is the endpoint for amazon appstore RVS Cloud Sandbox environment. 15 | SandboxURL string = "https://appstore-sdk.amazon.com/sandbox" 16 | // ProductionURL is the endpoint for production environment. 17 | ProductionURL string = "https://appstore-sdk.amazon.com" 18 | ) 19 | 20 | func getSandboxURL() string { 21 | url := os.Getenv("IAP_SANDBOX_URL") 22 | if url == "" { 23 | url = SandboxURL 24 | } 25 | return url 26 | } 27 | 28 | type PromotionType string 29 | 30 | const ( 31 | IntroductoryPrice PromotionType = "Introductory Price - All customers" 32 | PromotionalPrice PromotionType = "Promotional Price - Lapsed customers" 33 | ) 34 | 35 | type PromotionStatus string 36 | 37 | const ( 38 | Queued PromotionStatus = "Queued" 39 | InProgress PromotionStatus = "InProgress" 40 | Completed PromotionStatus = "Completed" 41 | ) 42 | 43 | type Promotion struct { 44 | PromotionType PromotionType `json:"promotionType"` 45 | PromotionStatus PromotionStatus `json:"promotionStatus"` 46 | } 47 | 48 | // The IAPResponse type has the response properties 49 | type IAPResponse struct { 50 | ReceiptID string `json:"receiptId"` 51 | ProductType string `json:"productType"` 52 | ProductID string `json:"productId"` 53 | PurchaseDate int64 `json:"purchaseDate"` 54 | RenewalDate int64 `json:"renewalDate"` 55 | CancelDate int64 `json:"cancelDate"` 56 | TestTransaction bool `json:"testTransaction"` 57 | BetaProduct bool `json:"betaProduct"` 58 | ParentProductID string `json:"parentProductId"` 59 | Quantity int64 `json:"quantity"` 60 | Term string `json:"term"` 61 | TermSku string `json:"termSku"` 62 | FreeTrialEndDate *int64 `json:"freeTrialEndDate"` 63 | AutoRenewing bool `json:"autoRenewing"` 64 | CancelReason *int8 `json:"cancelReason"` 65 | GracePeriodEndDate *int64 `json:"gracePeriodEndDate"` 66 | Promotions []Promotion `json:"promotions"` 67 | FulfillmentDate *int64 `json:"fulfillmentDate"` 68 | FulfillmentResult string `json:"fulfillmentResult"` 69 | PurchaseMetadataMap map[string]string `json:"purchaseMetadataMap"` 70 | } 71 | 72 | // The IAPResponseError typs has error message and status. 73 | type IAPResponseError struct { 74 | Message string `json:"message"` 75 | Status bool `json:"status"` 76 | } 77 | 78 | // IAPClient is an interface to call validation API in Amazon App Store 79 | type IAPClient interface { 80 | Verify(context.Context, string, string) (IAPResponse, error) 81 | } 82 | 83 | // Client implements IAPClient 84 | type Client struct { 85 | URL string 86 | Secret string 87 | httpCli *http.Client 88 | } 89 | 90 | // New creates a client object 91 | func New(secret string) *Client { 92 | client := &Client{ 93 | URL: getSandboxURL(), 94 | Secret: secret, 95 | httpCli: &http.Client{ 96 | Timeout: 10 * time.Second, 97 | }, 98 | } 99 | if os.Getenv("IAP_ENVIRONMENT") == "production" { 100 | client.URL = ProductionURL 101 | } 102 | 103 | return client 104 | } 105 | 106 | // NewWithClient creates a client with a custom client. 107 | func NewWithClient(secret string, cli *http.Client) *Client { 108 | client := &Client{ 109 | URL: getSandboxURL(), 110 | Secret: secret, 111 | httpCli: cli, 112 | } 113 | if os.Getenv("IAP_ENVIRONMENT") == "production" { 114 | client.URL = ProductionURL 115 | } 116 | 117 | return client 118 | } 119 | 120 | // Verify sends receipts and gets validation result 121 | func (c *Client) Verify(ctx context.Context, userID string, receiptID string) (IAPResponse, error) { 122 | result := IAPResponse{} 123 | url := fmt.Sprintf("%v/version/1.0/verifyReceiptId/developer/%v/user/%v/receiptId/%v", c.URL, c.Secret, userID, receiptID) 124 | req, err := http.NewRequest("GET", url, nil) 125 | if err != nil { 126 | return result, err 127 | } 128 | req = req.WithContext(ctx) 129 | 130 | resp, err := c.httpCli.Do(req) 131 | if err != nil { 132 | return result, err 133 | } 134 | defer resp.Body.Close() 135 | 136 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 137 | responseError := IAPResponseError{} 138 | err = json.NewDecoder(resp.Body).Decode(&responseError) 139 | if err != nil { 140 | return result, err 141 | } 142 | return result, errors.New(responseError.Message) 143 | } 144 | 145 | err = json.NewDecoder(resp.Body).Decode(&result) 146 | 147 | return result, err 148 | } 149 | -------------------------------------------------------------------------------- /amazon/validator_test.go: -------------------------------------------------------------------------------- 1 | package amazon 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "reflect" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestHandle497Error(t *testing.T) { 16 | t.Parallel() 17 | var expected, actual error 18 | server, client := testTools( 19 | 497, 20 | "{\"message\":\"Purchase token/app user mismatch\",\"status\":false}", 21 | ) 22 | defer server.Close() 23 | 24 | // status 400 25 | expected = errors.New("Purchase token/app user mismatch") 26 | _, actual = client.Verify( 27 | context.Background(), 28 | "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", 29 | "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", 30 | ) 31 | if !reflect.DeepEqual(actual, expected) { 32 | t.Errorf("got %v\nwant %v", actual, expected) 33 | } 34 | } 35 | 36 | func TestHandle400Error(t *testing.T) { 37 | t.Parallel() 38 | var expected, actual error 39 | 40 | server, client := testTools( 41 | 400, 42 | "{\"message\":\"Failed to parse receipt Id\",\"status\":false}", 43 | ) 44 | defer server.Close() 45 | 46 | // status 400 47 | expected = errors.New("Failed to parse receipt Id") 48 | _, actual = client.Verify( 49 | context.Background(), 50 | "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", 51 | "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", 52 | ) 53 | if !reflect.DeepEqual(actual, expected) { 54 | t.Errorf("got %v\nwant %v", actual, expected) 55 | } 56 | } 57 | 58 | func TestNew(t *testing.T) { 59 | expected := &Client{ 60 | URL: SandboxURL, 61 | Secret: "developerSecret", 62 | httpCli: &http.Client{ 63 | Timeout: 10 * time.Second, 64 | }, 65 | } 66 | 67 | actual := New("developerSecret") 68 | if !reflect.DeepEqual(actual, expected) { 69 | t.Errorf("got %v\nwant %v", actual, expected) 70 | } 71 | } 72 | 73 | func TestNewWithEnvironment(t *testing.T) { 74 | expected := &Client{ 75 | URL: ProductionURL, 76 | Secret: "developerSecret", 77 | httpCli: &http.Client{ 78 | Timeout: 10 * time.Second, 79 | }, 80 | } 81 | 82 | os.Setenv("IAP_ENVIRONMENT", "production") 83 | actual := New("developerSecret") 84 | os.Clearenv() 85 | 86 | if !reflect.DeepEqual(actual, expected) { 87 | t.Errorf("got %v\nwant %v", actual, expected) 88 | } 89 | } 90 | 91 | func TestNewWithClient(t *testing.T) { 92 | expected := &Client{ 93 | URL: ProductionURL, 94 | Secret: "developerSecret", 95 | httpCli: &http.Client{ 96 | Timeout: time.Second * 2, 97 | }, 98 | } 99 | os.Setenv("IAP_ENVIRONMENT", "production") 100 | 101 | cli := &http.Client{ 102 | Timeout: time.Second * 2, 103 | } 104 | actual := NewWithClient("developerSecret", cli) 105 | if !reflect.DeepEqual(actual, expected) { 106 | t.Errorf("got %v\nwant %v", actual, expected) 107 | } 108 | } 109 | 110 | func TestVerifySubscription(t *testing.T) { 111 | t.Parallel() 112 | server, client := testTools( 113 | 200, 114 | "{\"purchaseDate\":1558424877035,\"receiptId\":\"q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ\",\"productId\":\"com.amazon.iapsamplev2.expansion_set_3\",\"parentProductId\":null,\"productType\":\"SUBSCRIPTION\",\"renewalDate\":1561103277035,\"quantity\":1,\"betaProduct\":false,\"testTransaction\":true,\"term\":\"1 Week\",\"termSku\":\"sub1-weekly\", \"freeTrialEndDate\":1561104377023, \"AutoRenewing\":false, \"GracePeriodEndDate\":1561104377013}", 115 | ) 116 | defer server.Close() 117 | 118 | freeTrialEndDate := int64(1561104377023) 119 | gracePeriodEndDate := int64(1561104377013) 120 | 121 | expected := IAPResponse{ 122 | ReceiptID: "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", 123 | ProductType: "SUBSCRIPTION", 124 | ProductID: "com.amazon.iapsamplev2.expansion_set_3", 125 | PurchaseDate: 1558424877035, 126 | RenewalDate: 1561103277035, 127 | CancelDate: 0, 128 | TestTransaction: true, 129 | Quantity: 1, 130 | Term: "1 Week", 131 | TermSku: "sub1-weekly", 132 | FreeTrialEndDate: &freeTrialEndDate, 133 | AutoRenewing: false, 134 | GracePeriodEndDate: &gracePeriodEndDate, 135 | } 136 | 137 | actual, _ := client.Verify( 138 | context.Background(), 139 | "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", 140 | "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", 141 | ) 142 | if !reflect.DeepEqual(actual, expected) { 143 | t.Errorf("got %v\nwant %v", actual, expected) 144 | } 145 | } 146 | 147 | func TestVerifyEntitled(t *testing.T) { 148 | t.Parallel() 149 | server, client := testTools( 150 | 200, 151 | "{\"purchaseDate\":1402008634018,\"receiptId\":\"q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ\",\"productId\":\"com.amazon.iapsamplev2.expansion_set_3\",\"parentProductId\":null,\"productType\":\"ENTITLED\",\"cancelDate\":null,\"quantity\":1,\"betaProduct\":false,\"testTransaction\":true}", 152 | ) 153 | defer server.Close() 154 | 155 | expected := IAPResponse{ 156 | ReceiptID: "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", 157 | ProductType: "ENTITLED", 158 | ProductID: "com.amazon.iapsamplev2.expansion_set_3", 159 | PurchaseDate: 1402008634018, 160 | CancelDate: 0, 161 | TestTransaction: true, 162 | Quantity: 1, 163 | } 164 | 165 | actual, _ := client.Verify( 166 | context.Background(), 167 | "99FD_DL23EMhrOGDnur9-ulvqomrSg6qyLPSD3CFE=", 168 | "q1YqVrJSSs7P1UvMTazKz9PLTCwoTswtyEktM9JLrShIzCvOzM-LL04tiTdW0lFKASo2NDEwMjCwMDM2MTC0AIqVAsUsLd1c4l18jIxdfTOK_N1d8kqLLHVLc8oK83OLgtPNCit9AoJdjJ3dXG2BGkqUrAxrAQ", 169 | ) 170 | if !reflect.DeepEqual(actual, expected) { 171 | t.Errorf("got %v\nwant %v", actual, expected) 172 | } 173 | } 174 | 175 | func TestVerifyTimeout(t *testing.T) { 176 | t.Parallel() 177 | server, client := testTools(http.StatusGatewayTimeout, "{\"message\": \"timeout response\"}") 178 | defer server.Close() 179 | 180 | ctx := context.Background() 181 | _, actual := client.Verify(ctx, "timeout", "timeout") 182 | if actual == nil { 183 | t.Error("expected error, got nil") 184 | } 185 | if actual.Error() != "timeout response" { 186 | t.Errorf("got %v\nwant %v", actual, "timeout response") 187 | } 188 | } 189 | 190 | func testTools(code int, body string) (*httptest.Server, *Client) { 191 | 192 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 193 | w.WriteHeader(code) 194 | w.Header().Set("Content-Type", "application/json") 195 | fmt.Fprintln(w, body) 196 | })) 197 | 198 | client := &Client{URL: server.URL, Secret: "developerSecret", httpCli: &http.Client{Timeout: 2 * time.Second}} 199 | return server, client 200 | } 201 | -------------------------------------------------------------------------------- /appstore/api/cert.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "strings" 9 | ) 10 | 11 | // rootPEM is generated through `openssl x509 -inform der -in AppleRootCA-G3.cer -out apple_root.pem` 12 | const rootPEM = ` 13 | -----BEGIN CERTIFICATE----- 14 | MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS 15 | QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u 16 | IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN 17 | MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS 18 | b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y 19 | aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 20 | AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf 21 | TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 22 | IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr 23 | MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA 24 | MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 25 | at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM 26 | 6BgD56KyKA== 27 | -----END CERTIFICATE----- 28 | ` 29 | 30 | type Cert struct { 31 | } 32 | 33 | func (c *Cert) extractCertByIndex(tokenStr string, index int) ([]byte, error) { 34 | if index > 2 { 35 | return nil, errors.New("invalid index") 36 | } 37 | 38 | tokenArr := strings.Split(tokenStr, ".") 39 | headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0]) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | type Header struct { 45 | Alg string `json:"alg"` 46 | X5c []string `json:"x5c"` 47 | } 48 | var header Header 49 | err = json.Unmarshal(headerByte, &header) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | certByte, err := base64.StdEncoding.DecodeString(header.X5c[index]) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | return certByte, nil 60 | } 61 | 62 | func (c *Cert) verifyCert(rootCert, intermediaCert, leafCert *x509.Certificate) error { 63 | roots := x509.NewCertPool() 64 | ok := roots.AppendCertsFromPEM([]byte(rootPEM)) 65 | if !ok { 66 | return errors.New("failed to parse root certificate") 67 | } 68 | 69 | intermedia := x509.NewCertPool() 70 | intermedia.AddCert(intermediaCert) 71 | 72 | opts := x509.VerifyOptions{ 73 | Roots: roots, 74 | Intermediates: intermedia, 75 | } 76 | _, err := rootCert.Verify(opts) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | _, err = leafCert.Verify(opts) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /appstore/api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | ) 9 | 10 | type Error struct { 11 | // Only errorCode and errorMessage are returned by App Store Server API. 12 | errorCode int 13 | errorMessage string 14 | 15 | // retryAfter is the number of seconds after which the client can retry the request. 16 | // This field is only set to the `Retry-After` header if you receive the HTTP 429 error, that informs you when you can next send a request. 17 | retryAfter int64 18 | } 19 | 20 | func newError(errorCode int, errorMessage string) *Error { 21 | return &Error{ 22 | errorCode: errorCode, 23 | errorMessage: errorMessage, 24 | } 25 | } 26 | 27 | type appStoreAPIErrorResp struct { 28 | ErrorCode int `json:"errorCode"` 29 | ErrorMessage string `json:"errorMessage"` 30 | } 31 | 32 | func newAppStoreAPIError(b []byte, hd http.Header) (*Error, bool) { 33 | if len(b) == 0 { 34 | return nil, false 35 | } 36 | var rErr appStoreAPIErrorResp 37 | if err := json.Unmarshal(b, &rErr); err != nil { 38 | return nil, false 39 | } 40 | if rErr.ErrorCode == 0 { 41 | return nil, false 42 | } 43 | if rErr.ErrorCode == 4290000 { 44 | retryAfter, err := strconv.ParseInt(hd.Get("Retry-After"), 10, 64) 45 | if err == nil { 46 | return &Error{errorCode: rErr.ErrorCode, errorMessage: rErr.ErrorMessage, retryAfter: retryAfter}, true 47 | } 48 | } 49 | return &Error{errorCode: rErr.ErrorCode, errorMessage: rErr.ErrorMessage}, true 50 | } 51 | 52 | func newErrorFromJSON(b []byte) (*Error, bool) { 53 | if len(b) == 0 { 54 | return nil, false 55 | } 56 | var rErr appStoreAPIErrorResp 57 | if err := json.Unmarshal(b, &rErr); err != nil { 58 | return nil, false 59 | } 60 | if rErr.ErrorCode == 0 { 61 | return nil, false 62 | } 63 | return &Error{errorCode: rErr.ErrorCode, errorMessage: rErr.ErrorMessage}, true 64 | } 65 | 66 | func (e *Error) Error() string { 67 | return fmt.Sprintf("errorCode: %d, errorMessage: %s", e.errorCode, e.errorMessage) 68 | } 69 | 70 | func (e *Error) As(target interface{}) bool { 71 | if targetErr, ok := target.(*Error); ok { 72 | *targetErr = *e 73 | return true 74 | } 75 | return false 76 | } 77 | 78 | func (e *Error) Is(target error) bool { 79 | if other, ok := target.(*Error); ok && other.errorCode == e.errorCode { 80 | return true 81 | } 82 | return false 83 | } 84 | 85 | func (e *Error) ErrorCode() int { 86 | return e.errorCode 87 | } 88 | 89 | func (e *Error) ErrorMessage() string { 90 | return e.errorMessage 91 | } 92 | 93 | func (e *Error) RetryAfter() int64 { 94 | return e.retryAfter 95 | } 96 | 97 | func (e *Error) Retryable() bool { 98 | // NOTE: 99 | // RateLimitExceededError[1] could also be considered as a retryable error. 100 | // But limits are enforced on an hourly basis[2], so you should handle exceeded rate limits gracefully instead of retrying immediately. 101 | // Refs: 102 | // [1] https://developer.apple.com/documentation/appstoreserverapi/ratelimitexceedederror 103 | // [2] https://developer.apple.com/documentation/appstoreserverapi/identifying_rate_limits 104 | switch e.errorCode { 105 | case 4040002, 4040004, 5000001, 4040006: 106 | return true 107 | default: 108 | return false 109 | } 110 | } 111 | 112 | // All Error lists in https://developer.apple.com/documentation/appstoreserverapi/error_codes. 113 | var ( 114 | // Retryable errors 115 | AccountNotFoundRetryableError = newError(4040002, "Account not found. Please try again.") 116 | AppNotFoundRetryableError = newError(4040004, "App not found. Please try again.") 117 | GeneralInternalRetryableError = newError(5000001, "An unknown error occurred. Please try again.") 118 | OriginalTransactionIdNotFoundRetryableError = newError(4040006, "Original transaction id not found. Please try again.") 119 | // Errors 120 | AccountNotFoundError = newError(4040001, "Account not found.") 121 | AppNotFoundError = newError(4040003, "App not found.") 122 | FamilySharedSubscriptionExtensionIneligibleError = newError(4030007, "Subscriptions that users obtain through Family Sharing can't get a renewal date extension directly.") 123 | GeneralInternalError = newError(5000000, "An unknown error occurred.") 124 | GeneralBadRequestError = newError(4000000, "Bad request.") 125 | InvalidAppIdentifierError = newError(4000002, "Invalid request app identifier.") 126 | InvalidEmptyStorefrontCountryCodeListError = newError(4000027, "Invalid request. If provided, the list of storefront country codes must not be empty.") 127 | InvalidExtendByDaysError = newError(4000009, "Invalid extend by days value.") 128 | InvalidExtendReasonCodeError = newError(4000010, "Invalid extend reason code.") 129 | InvalidOriginalTransactionIdError = newError(4000008, "Invalid original transaction id.") 130 | InvalidRequestIdentifierError = newError(4000011, "Invalid request identifier.") 131 | InvalidRequestRevisionError = newError(4000005, "Invalid request revision.") 132 | InvalidRevokedError = newError(4000030, "Invalid request. The revoked parameter is invalid.") 133 | InvalidStatusError = newError(4000031, "Invalid request. The status parameter is invalid.") 134 | InvalidStorefrontCountryCodeError = newError(4000028, "Invalid request. A storefront country code was invalid.") 135 | InvalidTransactionIdError = newError(4000006, "Invalid transaction id.") 136 | OriginalTransactionIdNotFoundError = newError(4040005, "Original transaction id not found.") 137 | RateLimitExceededError = newError(4290000, "Rate limit exceeded.") 138 | StatusRequestNotFoundError = newError(4040009, "The server didn't find a subscription-renewal-date extension request for this requestIdentifier and productId combination.") 139 | SubscriptionExtensionIneligibleError = newError(4030004, "Forbidden - subscription state ineligible for extension.") 140 | SubscriptionMaxExtensionError = newError(4030005, "Forbidden - subscription has reached maximum extension count.") 141 | TransactionIdNotFoundError = newError(4040010, "Transaction id not found.") 142 | // Notification test and history errors 143 | InvalidEndDateError = newError(4000016, "Invalid request. The end date is not a timestamp value represented in milliseconds.") 144 | InvalidNotificationTypeError = newError(4000018, "Invalid request. The notification type or subtype is invalid.") 145 | InvalidPaginationTokenError = newError(4000014, "Invalid request. The pagination token is invalid.") 146 | InvalidStartDateError = newError(4000015, "Invalid request. The start date is not a timestamp value represented in milliseconds.") 147 | InvalidTestNotificationTokenError = newError(4000020, "Invalid request. The test notification token is invalid.") 148 | InvalidInAppOwnershipTypeError = newError(4000026, "Invalid request. The in-app ownership type parameter is invalid.") 149 | InvalidProductIdError = newError(4000023, "Invalid request. The product id parameter is invalid.") 150 | InvalidProductTypeError = newError(4000022, "Invalid request. The product type parameter is invalid.") 151 | InvalidSortError = newError(4000021, "Invalid request. The sort parameter is invalid.") 152 | InvalidSubscriptionGroupIdentifierError = newError(4000024, "Invalid request. The subscription group identifier parameter is invalid.") 153 | MultipleFiltersSuppliedError = newError(4000019, "Invalid request. Supply either a transaction id or a notification type, but not both.") 154 | PaginationTokenExpiredError = newError(4000017, "Invalid request. The pagination token is expired.") 155 | ServerNotificationURLNotFoundError = newError(4040007, "No App Store Server Notification URL found for provided app. Check that a URL is configured in App Store Connect for this environment.") 156 | StartDateAfterEndDateError = newError(4000013, "Invalid request. The end date precedes the start date or the dates are the same.") 157 | StartDateTooFarInPastError = newError(4000012, "Invalid request. The start date is earlier than the allowed start date.") 158 | TestNotificationNotFoundError = newError(4040008, "Either the test notification token is expired or the notification and status are not yet available.") 159 | InvalidAccountTenureError = newError(4000032, "Invalid request. The account tenure field is invalid.") 160 | InvalidAppAccountTokenError = newError(4000033, "Invalid request. The app account token field must contain a valid UUID or an empty string.") 161 | InvalidConsumptionStatusError = newError(4000034, "Invalid request. The consumption status field is invalid.") 162 | InvalidCustomerConsentedError = newError(4000035, "Invalid request. The customer consented field is required and must indicate the customer consented") 163 | InvalidDeliveryStatusError = newError(4000036, "Invalid request. The delivery status field is invalid") 164 | InvalidLifetimeDollarsPurchasedError = newError(4000037, "Invalid request. The lifetime dollars purchased field is invalid") 165 | InvalidLifetimeDollarsRefundedError = newError(4000038, "Invalid request. The lifetime dollars refunded field is invalid") 166 | InvalidPlatformError = newError(4000039, "Invalid request. The platform field is invalid") 167 | InvalidPlayTimeError = newError(4000040, "Invalid request. The playtime field is invalid") 168 | InvalidSampleContentProvidedError = newError(4000041, "Invalid request. The sample content provided field is invalid") 169 | InvalidUserStatusError = newError(4000042, "Invalid request. The user status field is invalid") 170 | InvalidTransactionNotConsumableError = newError(4000043, "Invalid request. The transaction id parameter must represent a consumable in-app purchase") 171 | InvalidTransactionTypeNotSupportedError = newError(4000047, "Invalid request. The transaction id doesn't represent a supported in-app purchase type") 172 | AppTransactionIdNotSupportedError = newError(4000048, "Invalid request. Invalid request. App transactions aren't supported by this endpoint") 173 | ) 174 | -------------------------------------------------------------------------------- /appstore/api/error_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestError_As(t *testing.T) { 12 | tests := []struct { 13 | SrcError error 14 | ExpectedAs bool // Check If SrcError can be 'As' to Error. 15 | }{ 16 | {SrcError: AccountNotFoundError, ExpectedAs: true}, 17 | {SrcError: AppNotFoundError, ExpectedAs: true}, 18 | {SrcError: fmt.Errorf("custom error"), ExpectedAs: false}, 19 | {SrcError: fmt.Errorf("wrapping: %w", AccountNotFoundError), ExpectedAs: true}, 20 | {SrcError: errors.Unwrap(fmt.Errorf("wrapping: %w", AccountNotFoundError)), ExpectedAs: true}, 21 | } 22 | 23 | for _, test := range tests { 24 | var apiErr *Error 25 | as := errors.As(test.SrcError, &apiErr) 26 | assert.Equal(t, test.ExpectedAs, as) 27 | if test.ExpectedAs { 28 | assert.NotZero(t, apiErr.errorCode) 29 | assert.NotZero(t, apiErr.errorMessage) 30 | } else { 31 | assert.Nil(t, apiErr) 32 | } 33 | } 34 | 35 | } 36 | 37 | func TestError_Is(t *testing.T) { 38 | tests := []struct { 39 | ErrBytes []byte 40 | TargetError error 41 | ExpectedIs bool // Check if error (constructed by ErrBytes) Is TargetError Or not. 42 | }{ 43 | {ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: AccountNotFoundError, ExpectedIs: true}, 44 | {ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: AppNotFoundError, ExpectedIs: false}, 45 | {ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: fmt.Errorf("custom error"), ExpectedIs: false}, 46 | {ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: fmt.Errorf("wrapping: %w", AccountNotFoundError), ExpectedIs: false}, 47 | {ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: errors.Unwrap(fmt.Errorf("wrapping: %w", AccountNotFoundError)), ExpectedIs: true}, 48 | } 49 | for _, test := range tests { 50 | err, ok := newErrorFromJSON(test.ErrBytes) 51 | assert.True(t, ok) 52 | assert.Equal(t, test.ExpectedIs, errors.Is(err, test.TargetError)) 53 | } 54 | } 55 | 56 | func TestError_Is2(t *testing.T) { 57 | tests := []struct { 58 | SrcError error 59 | TargetError error 60 | ExpectedIs bool // Check if SrcError is TargetError or not. 61 | }{ 62 | {SrcError: AccountNotFoundError, TargetError: AccountNotFoundError, ExpectedIs: true}, 63 | {SrcError: AppNotFoundError, TargetError: AccountNotFoundError, ExpectedIs: false}, 64 | {SrcError: fmt.Errorf("custom error"), TargetError: AccountNotFoundError, ExpectedIs: false}, 65 | {SrcError: fmt.Errorf("wrapping: %w", AccountNotFoundError), TargetError: AccountNotFoundError, ExpectedIs: true}, 66 | {SrcError: errors.Unwrap(fmt.Errorf("wrapping: %w", AccountNotFoundError)), TargetError: AccountNotFoundError, ExpectedIs: true}, 67 | } 68 | for _, test := range tests { 69 | assert.Equal(t, test.ExpectedIs, errors.Is(test.SrcError, test.TargetError)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /appstore/api/model.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/awa/go-iap/appstore" 5 | "github.com/golang-jwt/jwt/v5" 6 | ) 7 | 8 | // OrderLookupResponse https://developer.apple.com/documentation/appstoreserverapi/orderlookupresponse 9 | type OrderLookupResponse struct { 10 | Status int `json:"status"` 11 | SignedTransactions []string `json:"signedTransactions"` 12 | } 13 | 14 | type Environment string 15 | 16 | // Environment https://developer.apple.com/documentation/appstoreserverapi/environment 17 | const ( 18 | Sandbox Environment = "Sandbox" 19 | Production Environment = "Production" 20 | ) 21 | 22 | // HistoryResponse https://developer.apple.com/documentation/appstoreserverapi/historyresponse 23 | type HistoryResponse struct { 24 | AppAppleId int64 `json:"appAppleId"` 25 | BundleId string `json:"bundleId"` 26 | Environment Environment `json:"environment"` 27 | HasMore bool `json:"hasMore"` 28 | Revision string `json:"revision"` 29 | SignedTransactions []string `json:"signedTransactions"` 30 | } 31 | 32 | // TransactionInfoResponse https://developer.apple.com/documentation/appstoreserverapi/transactioninforesponse 33 | type TransactionInfoResponse struct { 34 | SignedTransactionInfo string `json:"signedTransactionInfo"` 35 | } 36 | 37 | // RefundLookupResponse same as the RefundHistoryResponse https://developer.apple.com/documentation/appstoreserverapi/refundhistoryresponse 38 | type RefundLookupResponse struct { 39 | HasMore bool `json:"hasMore"` 40 | Revision string `json:"revision"` 41 | SignedTransactions []string `json:"signedTransactions"` 42 | } 43 | 44 | // StatusResponse https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses 45 | type StatusResponse struct { 46 | Environment Environment `json:"environment"` 47 | AppAppleId int64 `json:"appAppleId"` 48 | BundleId string `json:"bundleId"` 49 | Data []SubscriptionGroupIdentifierItem `json:"data"` 50 | } 51 | 52 | type SubscriptionGroupIdentifierItem struct { 53 | SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"` 54 | LastTransactions []LastTransactionsItem `json:"lastTransactions"` 55 | } 56 | 57 | type LastTransactionsItem struct { 58 | OriginalTransactionId string `json:"originalTransactionId"` 59 | Status AutoRenewSubscriptionStatus `json:"status"` 60 | SignedRenewalInfo string `json:"signedRenewalInfo"` 61 | SignedTransactionInfo string `json:"signedTransactionInfo"` 62 | } 63 | 64 | // MassExtendRenewalDateRequest https://developer.apple.com/documentation/appstoreserverapi/massextendrenewaldaterequest 65 | type MassExtendRenewalDateRequest struct { 66 | RequestIdentifier string `json:"requestIdentifier"` 67 | ExtendByDays int32 `json:"extendByDays"` 68 | ExtendReasonCode int32 `json:"extendReasonCode"` 69 | ProductId string `json:"productId"` 70 | StorefrontCountryCodes []string `json:"storefrontCountryCodes"` 71 | } 72 | 73 | // ConsumptionRequestBody https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest 74 | type ConsumptionRequestBody struct { 75 | AccountTenure int32 `json:"accountTenure"` 76 | AppAccountToken string `json:"appAccountToken"` 77 | ConsumptionStatus int32 `json:"consumptionStatus"` 78 | CustomerConsented bool `json:"customerConsented"` 79 | DeliveryStatus int32 `json:"deliveryStatus"` 80 | LifetimeDollarsPurchased int32 `json:"lifetimeDollarsPurchased"` 81 | LifetimeDollarsRefunded int32 `json:"lifetimeDollarsRefunded"` 82 | Platform int32 `json:"platform"` 83 | PlayTime int32 `json:"playTime"` 84 | SampleContentProvided bool `json:"sampleContentProvided"` 85 | UserStatus int32 `json:"userStatus"` 86 | RefundPreference int32 `json:"refundPreference"` 87 | } 88 | 89 | // Verify that JWSRenewalInfoDecodedPayload implements jwt.Claims 90 | var _ jwt.Claims = JWSRenewalInfoDecodedPayload{} 91 | 92 | // JWSRenewalInfoDecodedPayload https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfodecodedpayload 93 | type JWSRenewalInfoDecodedPayload struct { 94 | AppAccountToken string `json:"appAccountToken,omitempty"` 95 | AppTransactionId string `json:"appTransactionId,omitempty"` 96 | AutoRenewProductId string `json:"autoRenewProductId"` 97 | AutoRenewStatus AutoRenewStatus `json:"autoRenewStatus"` 98 | Environment Environment `json:"environment"` 99 | ExpirationIntent int32 `json:"expirationIntent"` 100 | GracePeriodExpiresDate int64 `json:"gracePeriodExpiresDate"` 101 | IsInBillingRetryPeriod *bool `json:"isInBillingRetryPeriod"` 102 | OfferIdentifier string `json:"offerIdentifier"` 103 | OfferType int32 `json:"offerType"` 104 | OfferPeriod string `json:"offerPeriod"` 105 | OriginalTransactionId string `json:"originalTransactionId"` 106 | PriceIncreaseStatus *int32 `json:"priceIncreaseStatus"` 107 | ProductId string `json:"productId"` 108 | RecentSubscriptionStartDate int64 `json:"recentSubscriptionStartDate"` 109 | RenewalDate int64 `json:"renewalDate"` 110 | SignedDate int64 `json:"signedDate"` 111 | RenewalPrice int64 `json:"renewalPrice,omitempty"` 112 | Currency string `json:"currency,omitempty"` 113 | OfferDiscountType OfferDiscountType `json:"offerDiscountType,omitempty"` 114 | EligibleWinBackOfferIds []string `json:"eligibleWinBackOfferIds,omitempty"` 115 | } 116 | 117 | // GetAudience implements jwt.Claims. 118 | func (J JWSRenewalInfoDecodedPayload) GetAudience() (jwt.ClaimStrings, error) { 119 | return nil, nil 120 | } 121 | 122 | // GetExpirationTime implements jwt.Claims. 123 | func (J JWSRenewalInfoDecodedPayload) GetExpirationTime() (*jwt.NumericDate, error) { 124 | return nil, nil 125 | } 126 | 127 | // GetIssuedAt implements jwt.Claims. 128 | func (J JWSRenewalInfoDecodedPayload) GetIssuedAt() (*jwt.NumericDate, error) { 129 | return nil, nil 130 | } 131 | 132 | // GetIssuer implements jwt.Claims. 133 | func (J JWSRenewalInfoDecodedPayload) GetIssuer() (string, error) { 134 | return "", nil 135 | } 136 | 137 | // GetNotBefore implements jwt.Claims. 138 | func (J JWSRenewalInfoDecodedPayload) GetNotBefore() (*jwt.NumericDate, error) { 139 | return nil, nil 140 | } 141 | 142 | // GetSubject implements jwt.Claims. 143 | func (J JWSRenewalInfoDecodedPayload) GetSubject() (string, error) { 144 | return "", nil 145 | } 146 | 147 | // JWSDecodedHeader https://developer.apple.com/documentation/appstoreserverapi/jwsdecodedheader 148 | type JWSDecodedHeader struct { 149 | Alg string `json:"alg,omitempty"` 150 | Kid string `json:"kid,omitempty"` 151 | X5C []string `json:"x5c,omitempty"` 152 | } 153 | 154 | // TransactionReason indicates the cause of a purchase transaction, 155 | // https://developer.apple.com/documentation/appstoreservernotifications/transactionreason 156 | type TransactionReason string 157 | 158 | const ( 159 | TransactionReasonPurchase = "PURCHASE" 160 | TransactionReasonRenewal = "RENEWAL" 161 | ) 162 | 163 | // IAPType https://developer.apple.com/documentation/appstoreserverapi/type 164 | type IAPType string 165 | 166 | const ( 167 | AutoRenewable IAPType = "Auto-Renewable Subscription" 168 | NonConsumable IAPType = "Non-Consumable" 169 | Consumable IAPType = "Consumable" 170 | NonRenewable IAPType = "Non-Renewing Subscription" 171 | ) 172 | 173 | type OfferDiscountType string 174 | 175 | const ( 176 | OfferDiscountTypeFreeTrial OfferDiscountType = "FREE_TRIAL" 177 | OfferDiscountTypePayAsYouGo OfferDiscountType = "PAY_AS_YOU_GO" 178 | OfferDiscountTypePayUpFront OfferDiscountType = "PAY_UP_FRONT" 179 | ) 180 | 181 | // Verify that JWSTransaction implements jwt.Claims 182 | var _ jwt.Claims = JWSTransaction{} 183 | 184 | // JWSTransaction https://developer.apple.com/documentation/appstoreserverapi/jwstransaction 185 | type JWSTransaction struct { 186 | AppTransactionId string `json:"appTransactionId,omitempty"` 187 | TransactionID string `json:"transactionId,omitempty"` 188 | OriginalTransactionId string `json:"originalTransactionId,omitempty"` 189 | WebOrderLineItemId string `json:"webOrderLineItemId,omitempty"` 190 | BundleID string `json:"bundleId,omitempty"` 191 | ProductID string `json:"productId,omitempty"` 192 | SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier,omitempty"` 193 | PurchaseDate int64 `json:"purchaseDate,omitempty"` 194 | OriginalPurchaseDate int64 `json:"originalPurchaseDate,omitempty"` 195 | ExpiresDate int64 `json:"expiresDate,omitempty"` 196 | Quantity int32 `json:"quantity,omitempty"` 197 | Type IAPType `json:"type,omitempty"` 198 | AppAccountToken string `json:"appAccountToken,omitempty"` 199 | InAppOwnershipType string `json:"inAppOwnershipType,omitempty"` 200 | SignedDate int64 `json:"signedDate,omitempty"` 201 | OfferType int32 `json:"offerType,omitempty"` 202 | OfferPeriod string `json:"offerPeriod,omitempty"` 203 | OfferIdentifier string `json:"offerIdentifier,omitempty"` 204 | RevocationDate int64 `json:"revocationDate,omitempty"` 205 | RevocationReason *int32 `json:"revocationReason,omitempty"` 206 | IsUpgraded bool `json:"isUpgraded,omitempty"` 207 | Storefront string `json:"storefront,omitempty"` 208 | StorefrontId string `json:"storefrontId,omitempty"` 209 | TransactionReason TransactionReason `json:"transactionReason,omitempty"` 210 | Environment Environment `json:"environment,omitempty"` 211 | Price int64 `json:"price,omitempty"` 212 | Currency string `json:"currency,omitempty"` 213 | OfferDiscountType OfferDiscountType `json:"offerDiscountType,omitempty"` 214 | } 215 | 216 | // GetAudience implements jwt.Claims. 217 | func (J JWSTransaction) GetAudience() (jwt.ClaimStrings, error) { 218 | return nil, nil 219 | } 220 | 221 | // GetExpirationTime implements jwt.Claims. 222 | func (J JWSTransaction) GetExpirationTime() (*jwt.NumericDate, error) { 223 | return nil, nil 224 | } 225 | 226 | // GetIssuedAt implements jwt.Claims. 227 | func (J JWSTransaction) GetIssuedAt() (*jwt.NumericDate, error) { 228 | return nil, nil 229 | } 230 | 231 | // GetIssuer implements jwt.Claims. 232 | func (J JWSTransaction) GetIssuer() (string, error) { 233 | return "", nil 234 | } 235 | 236 | // GetNotBefore implements jwt.Claims. 237 | func (J JWSTransaction) GetNotBefore() (*jwt.NumericDate, error) { 238 | return nil, nil 239 | } 240 | 241 | // GetSubject implements jwt.Claims. 242 | func (J JWSTransaction) GetSubject() (string, error) { 243 | return "", nil 244 | } 245 | 246 | // https://developer.apple.com/documentation/appstoreserverapi/extendreasoncode 247 | type ExtendReasonCode int32 248 | 249 | const ( 250 | UndeclaredExtendReasonCode = iota 251 | CustomerSatisfaction 252 | OtherReasons 253 | ServiceIssueOrOutage 254 | ) 255 | 256 | // ExtendRenewalDateRequest https://developer.apple.com/documentation/appstoreserverapi/extendrenewaldaterequest 257 | type ExtendRenewalDateRequest struct { 258 | ExtendByDays int32 `json:"extendByDays"` 259 | ExtendReasonCode ExtendReasonCode `json:"extendReasonCode"` 260 | RequestIdentifier string `json:"requestIdentifier"` 261 | } 262 | 263 | // MassExtendRenewalDateStatusResponse https://developer.apple.com/documentation/appstoreserverapi/massextendrenewaldatestatusresponse 264 | type MassExtendRenewalDateStatusResponse struct { 265 | RequestIdentifier string `json:"requestIdentifier"` 266 | Complete bool `json:"complete"` 267 | CompleteDate int64 `json:"completeDate,omitempty"` 268 | FailedCount int64 `json:"failedCount,omitempty"` 269 | SucceededCount int64 `json:"succeededCount,omitempty"` 270 | } 271 | 272 | // NotificationHistoryRequest https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryrequest 273 | type NotificationHistoryRequest struct { 274 | StartDate int64 `json:"startDate"` 275 | EndDate int64 `json:"endDate"` 276 | NotificationType appstore.NotificationTypeV2 `json:"notificationType,omitempty"` 277 | NotificationSubtype appstore.SubtypeV2 `json:"notificationSubtype,omitempty"` 278 | OnlyFailures bool `json:"onlyFailures"` 279 | TransactionId string `json:"transactionId,omitempty"` 280 | // Use transactionId instead. 281 | // Deprecated. 282 | OriginalTransactionId string `json:"originalTransactionId,omitempty"` 283 | } 284 | 285 | // NotificationHistoryResponses https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryresponse 286 | type NotificationHistoryResponses struct { 287 | HasMore bool `json:"hasMore"` 288 | PaginationToken string `json:"paginationToken"` 289 | NotificationHistory []NotificationHistoryResponseItem `json:"notificationHistory"` 290 | } 291 | 292 | // NotificationHistoryResponseItem https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryresponseitem 293 | type NotificationHistoryResponseItem struct { 294 | SignedPayload string `json:"signedPayload"` 295 | FirstSendAttemptResult FirstSendAttemptResult `json:"firstSendAttemptResult"` 296 | SendAttempts []SendAttemptItem `json:"sendAttempts"` 297 | } 298 | 299 | // SendAttemptItem https://developer.apple.com/documentation/appstoreserverapi/sendattemptitem 300 | type SendAttemptItem struct { 301 | AttemptDate int64 `json:"attemptDate"` 302 | SendAttemptResult FirstSendAttemptResult `json:"sendAttemptResult"` 303 | } 304 | 305 | // https://developer.apple.com/documentation/appstoreserverapi/firstsendattemptresult 306 | type FirstSendAttemptResult string 307 | 308 | const ( 309 | FirstSendAttemptResultSuccess FirstSendAttemptResult = "SUCCESS" 310 | FirstSendAttemptResultCircularRedirect FirstSendAttemptResult = "CIRCULAR_REDIRECT" 311 | FirstSendAttemptResultInvalidResponse FirstSendAttemptResult = "INVALID_RESPONSE" 312 | FirstSendAttemptResultNoResponse FirstSendAttemptResult = "NO_RESPONSE" 313 | FirstSendAttemptResultOther FirstSendAttemptResult = "OTHER" 314 | FirstSendAttemptResultPrematureClose FirstSendAttemptResult = "PREMATURE_CLOSE" 315 | FirstSendAttemptResultSocketIssue FirstSendAttemptResult = "SOCKET_ISSUE" 316 | FirstSendAttemptResultTimedOut FirstSendAttemptResult = "TIMED_OUT" 317 | FirstSendAttemptResultTlsIssue FirstSendAttemptResult = "TLS_ISSUE" 318 | FirstSendAttemptResultUnsupportedCharset FirstSendAttemptResult = "UNSUPPORTED_CHARSET" 319 | FirstSendAttemptResultUnsupportedHTTPRESPONSECODE FirstSendAttemptResult = "UNSUCCESSFUL_HTTP_RESPONSE_CODE" 320 | ) 321 | 322 | // SendTestNotificationResponse https://developer.apple.com/documentation/appstoreserverapi/sendtestnotificationresponse 323 | type SendTestNotificationResponse struct { 324 | TestNotificationToken string `json:"testNotificationToken"` 325 | } 326 | 327 | type ( 328 | AutoRenewSubscriptionStatus int32 329 | AutoRenewStatus int32 330 | ) 331 | 332 | const ( 333 | SubscriptionActive AutoRenewSubscriptionStatus = 1 334 | SubscriptionExpired AutoRenewSubscriptionStatus = 2 335 | SubscriptionRetryPeriod AutoRenewSubscriptionStatus = 3 336 | SubscriptionGracePeriod AutoRenewSubscriptionStatus = 4 337 | SubscriptionRevoked AutoRenewSubscriptionStatus = 5 338 | 339 | AutoRenewStatusOff AutoRenewStatus = 0 340 | AutoRenewStatusOn AutoRenewStatus = 1 341 | ) 342 | -------------------------------------------------------------------------------- /appstore/api/token.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "errors" 8 | "sync" 9 | "time" 10 | 11 | "github.com/golang-jwt/jwt/v5" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // Authorize Tokens For App Store Server API Request 16 | // Doc: https://developer.apple.com/documentation/appstoreserverapi/generating_tokens_for_api_requests 17 | var ( 18 | ErrAuthKeyInvalidPem = errors.New("token: AuthKey must be a valid .p8 PEM file") 19 | ErrAuthKeyInvalidType = errors.New("token: AuthKey must be of type ecdsa.PrivateKey") 20 | ) 21 | 22 | // Token represents an Apple Provider Authentication Token (JSON Web Token). 23 | type Token struct { 24 | sync.Mutex 25 | 26 | KeyContent []byte // Loads a .p8 certificate 27 | KeyID string // Your private key ID from App Store Connect (Ex: 2X9R4HXF34) 28 | BundleID string // Your app’s bundle ID 29 | Issuer string // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a") 30 | Sandbox bool // default is Production 31 | IssuedAtFunc func() int64 // The token’s creation time func. Default is current timestamp. 32 | ExpiredAtFunc func() int64 // The token’s expiration time func. 33 | 34 | // internal variables 35 | AuthKey *ecdsa.PrivateKey // .p8 private key 36 | Bearer string // Authorized bearer token 37 | ExpiredAt int64 // The token’s expiration time, in UNIX time 38 | } 39 | 40 | func (t *Token) WithConfig(c *StoreConfig) { 41 | t.KeyContent = append(t.KeyContent[:0:0], c.KeyContent...) 42 | t.KeyID = c.KeyID 43 | t.BundleID = c.BundleID 44 | t.Issuer = c.Issuer 45 | t.Sandbox = c.Sandbox 46 | t.IssuedAtFunc = c.TokenIssuedAtFunc 47 | t.ExpiredAtFunc = c.TokenExpiredAtFunc 48 | } 49 | 50 | // GenerateIfExpired checks to see if the token is about to expire and generates a new token. 51 | func (t *Token) GenerateIfExpired() (string, error) { 52 | t.Lock() 53 | defer t.Unlock() 54 | 55 | if t.Expired() || t.Bearer == "" { 56 | err := t.Generate() 57 | if err != nil { 58 | return "", err 59 | } 60 | } 61 | 62 | return t.Bearer, nil 63 | } 64 | 65 | // Expired checks to see if the token has expired. 66 | func (t *Token) Expired() bool { 67 | return time.Now().Unix() >= t.ExpiredAt 68 | } 69 | 70 | // Generate creates a new token. 71 | func (t *Token) Generate() error { 72 | key, err := t.passKeyFromByte(t.KeyContent) 73 | if err != nil { 74 | return err 75 | } 76 | t.AuthKey = key 77 | 78 | now := time.Now() 79 | issuedAt := now.Unix() 80 | if t.IssuedAtFunc != nil { 81 | issuedAt = t.IssuedAtFunc() 82 | } 83 | expiredAt := now.Add(time.Duration(1) * time.Hour).Unix() 84 | if t.ExpiredAtFunc != nil { 85 | expiredAt = t.ExpiredAtFunc() 86 | } 87 | jwtToken := &jwt.Token{ 88 | Header: map[string]interface{}{ 89 | "alg": "ES256", 90 | "kid": t.KeyID, 91 | "typ": "JWT", 92 | }, 93 | 94 | Claims: jwt.MapClaims{ 95 | "iss": t.Issuer, 96 | "iat": issuedAt, 97 | "exp": expiredAt, 98 | "aud": "appstoreconnect-v1", 99 | "nonce": uuid.New(), 100 | "bid": t.BundleID, 101 | }, 102 | Method: jwt.SigningMethodES256, 103 | } 104 | 105 | bearer, err := jwtToken.SignedString(t.AuthKey) 106 | if err != nil { 107 | return err 108 | } 109 | t.ExpiredAt = expiredAt 110 | t.Bearer = bearer 111 | 112 | return nil 113 | } 114 | 115 | // passKeyFromByte loads a .p8 certificate from an in memory byte array and returns an *ecdsa.PrivateKey. 116 | func (t *Token) passKeyFromByte(bytes []byte) (*ecdsa.PrivateKey, error) { 117 | block, _ := pem.Decode(bytes) 118 | if block == nil { 119 | return nil, ErrAuthKeyInvalidPem 120 | } 121 | 122 | key, err := x509.ParsePKCS8PrivateKey(block.Bytes) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | switch pk := key.(type) { 128 | case *ecdsa.PrivateKey: 129 | return pk, nil 130 | default: 131 | return nil, ErrAuthKeyInvalidType 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /appstore/api/validator.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // IAPAPIClient is an interface to call validation API in App Store Server API 9 | type IAPAPIClient interface { 10 | Verify(ctx context.Context, transactionId string) (*TransactionInfoResponse, error) 11 | } 12 | 13 | type APIClient struct { 14 | productionCli *StoreClient 15 | sandboxCli *StoreClient 16 | } 17 | 18 | func NewAPIClient(config StoreConfig) *APIClient { 19 | prodConf := config 20 | prodConf.Sandbox = false 21 | sandboxConf := config 22 | sandboxConf.Sandbox = true 23 | return &APIClient{productionCli: NewStoreClient(&prodConf), sandboxCli: NewStoreClient(&sandboxConf)} 24 | } 25 | 26 | func (c *APIClient) Verify(ctx context.Context, transactionId string) (*TransactionInfoResponse, error) { 27 | result, err := c.productionCli.GetTransactionInfo(ctx, transactionId) 28 | if err != nil && errors.Is(err, TransactionIdNotFoundError) { 29 | result, err = c.sandboxCli.GetTransactionInfo(ctx, transactionId) 30 | } 31 | return result, err 32 | } 33 | -------------------------------------------------------------------------------- /appstore/cert.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "strings" 11 | ) 12 | 13 | // rootPEM is generated through `openssl x509 -inform der -in AppleRootCA-G3.cer -out apple_root.pem` 14 | const rootPEM = ` 15 | -----BEGIN CERTIFICATE----- 16 | MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS 17 | QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u 18 | IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN 19 | MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS 20 | b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y 21 | aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 22 | AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf 23 | TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 24 | IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr 25 | MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA 26 | MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 27 | at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM 28 | 6BgD56KyKA== 29 | -----END CERTIFICATE----- 30 | ` 31 | 32 | type Cert struct{} 33 | 34 | // ExtractCertByIndex extracts the certificate from the token string by index. 35 | func (c *Cert) extractCertByIndex(tokenStr string, index int) ([]byte, error) { 36 | if index > 2 { 37 | return nil, errors.New("invalid index") 38 | } 39 | 40 | tokenArr := strings.Split(tokenStr, ".") 41 | headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0]) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | type Header struct { 47 | Alg string `json:"alg"` 48 | X5c []string `json:"x5c"` 49 | } 50 | var header Header 51 | err = json.Unmarshal(headerByte, &header) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if len(header.X5c) <= 0 || index >= len(header.X5c) { 56 | return nil, errors.New("failed to extract cert from x5c header, possible unauthorised request detected") 57 | } 58 | certByte, err := base64.StdEncoding.DecodeString(header.X5c[index]) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return certByte, nil 64 | } 65 | 66 | // VerifyCert verifies the certificate chain. 67 | func (c *Cert) verifyCert(rootCert, intermediaCert, leafCert *x509.Certificate) error { 68 | roots := x509.NewCertPool() 69 | ok := roots.AppendCertsFromPEM([]byte(rootPEM)) 70 | if !ok { 71 | return errors.New("failed to parse root certificate") 72 | } 73 | 74 | intermedia := x509.NewCertPool() 75 | intermedia.AddCert(intermediaCert) 76 | 77 | opts := x509.VerifyOptions{ 78 | Roots: roots, 79 | Intermediates: intermedia, 80 | } 81 | _, err := rootCert.Verify(opts) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | _, err = leafCert.Verify(opts) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (c *Cert) ExtractPublicKeyFromToken(token string) (*ecdsa.PublicKey, error) { 95 | rootCertBytes, err := c.extractCertByIndex(token, 2) 96 | if err != nil { 97 | return nil, err 98 | } 99 | rootCert, err := x509.ParseCertificate(rootCertBytes) 100 | if err != nil { 101 | return nil, fmt.Errorf("appstore failed to parse root certificate") 102 | } 103 | 104 | intermediaCertBytes, err := c.extractCertByIndex(token, 1) 105 | if err != nil { 106 | return nil, err 107 | } 108 | intermediaCert, err := x509.ParseCertificate(intermediaCertBytes) 109 | if err != nil { 110 | return nil, fmt.Errorf("appstore failed to parse intermediate certificate") 111 | } 112 | 113 | leafCertBytes, err := c.extractCertByIndex(token, 0) 114 | if err != nil { 115 | return nil, err 116 | } 117 | leafCert, err := x509.ParseCertificate(leafCertBytes) 118 | if err != nil { 119 | return nil, fmt.Errorf("appstore failed to parse leaf certificate") 120 | } 121 | if err = c.verifyCert(rootCert, intermediaCert, leafCert); err != nil { 122 | return nil, err 123 | } 124 | 125 | switch pk := leafCert.PublicKey.(type) { 126 | case *ecdsa.PublicKey: 127 | return pk, nil 128 | default: 129 | return nil, errors.New("appstore public key must be of type ecdsa.PublicKey") 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /appstore/mocks/appstore.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/awa/go-iap/appstore (interfaces: IAPClient) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=mocks/appstore.go -package=mocks github.com/awa/go-iap/appstore IAPClient 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | appstore "github.com/awa/go-iap/appstore" 17 | jwt "github.com/golang-jwt/jwt/v5" 18 | gomock "go.uber.org/mock/gomock" 19 | ) 20 | 21 | // MockIAPClient is a mock of IAPClient interface. 22 | type MockIAPClient struct { 23 | ctrl *gomock.Controller 24 | recorder *MockIAPClientMockRecorder 25 | isgomock struct{} 26 | } 27 | 28 | // MockIAPClientMockRecorder is the mock recorder for MockIAPClient. 29 | type MockIAPClientMockRecorder struct { 30 | mock *MockIAPClient 31 | } 32 | 33 | // NewMockIAPClient creates a new mock instance. 34 | func NewMockIAPClient(ctrl *gomock.Controller) *MockIAPClient { 35 | mock := &MockIAPClient{ctrl: ctrl} 36 | mock.recorder = &MockIAPClientMockRecorder{mock} 37 | return mock 38 | } 39 | 40 | // EXPECT returns an object that allows the caller to indicate expected use. 41 | func (m *MockIAPClient) EXPECT() *MockIAPClientMockRecorder { 42 | return m.recorder 43 | } 44 | 45 | // ParseNotificationV2 mocks base method. 46 | func (m *MockIAPClient) ParseNotificationV2(tokenStr string, result *jwt.Token) error { 47 | m.ctrl.T.Helper() 48 | ret := m.ctrl.Call(m, "ParseNotificationV2", tokenStr, result) 49 | ret0, _ := ret[0].(error) 50 | return ret0 51 | } 52 | 53 | // ParseNotificationV2 indicates an expected call of ParseNotificationV2. 54 | func (mr *MockIAPClientMockRecorder) ParseNotificationV2(tokenStr, result any) *gomock.Call { 55 | mr.mock.ctrl.T.Helper() 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseNotificationV2", reflect.TypeOf((*MockIAPClient)(nil).ParseNotificationV2), tokenStr, result) 57 | } 58 | 59 | // ParseNotificationV2WithClaim mocks base method. 60 | func (m *MockIAPClient) ParseNotificationV2WithClaim(tokenStr string, result jwt.Claims) error { 61 | m.ctrl.T.Helper() 62 | ret := m.ctrl.Call(m, "ParseNotificationV2WithClaim", tokenStr, result) 63 | ret0, _ := ret[0].(error) 64 | return ret0 65 | } 66 | 67 | // ParseNotificationV2WithClaim indicates an expected call of ParseNotificationV2WithClaim. 68 | func (mr *MockIAPClientMockRecorder) ParseNotificationV2WithClaim(tokenStr, result any) *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseNotificationV2WithClaim", reflect.TypeOf((*MockIAPClient)(nil).ParseNotificationV2WithClaim), tokenStr, result) 71 | } 72 | 73 | // Verify mocks base method. 74 | func (m *MockIAPClient) Verify(ctx context.Context, reqBody appstore.IAPRequest, resp any) error { 75 | m.ctrl.T.Helper() 76 | ret := m.ctrl.Call(m, "Verify", ctx, reqBody, resp) 77 | ret0, _ := ret[0].(error) 78 | return ret0 79 | } 80 | 81 | // Verify indicates an expected call of Verify. 82 | func (mr *MockIAPClientMockRecorder) Verify(ctx, reqBody, resp any) *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockIAPClient)(nil).Verify), ctx, reqBody, resp) 85 | } 86 | 87 | // VerifyWithStatus mocks base method. 88 | func (m *MockIAPClient) VerifyWithStatus(ctx context.Context, reqBody appstore.IAPRequest, resp any) (int, error) { 89 | m.ctrl.T.Helper() 90 | ret := m.ctrl.Call(m, "VerifyWithStatus", ctx, reqBody, resp) 91 | ret0, _ := ret[0].(int) 92 | ret1, _ := ret[1].(error) 93 | return ret0, ret1 94 | } 95 | 96 | // VerifyWithStatus indicates an expected call of VerifyWithStatus. 97 | func (mr *MockIAPClientMockRecorder) VerifyWithStatus(ctx, reqBody, resp any) *gomock.Call { 98 | mr.mock.ctrl.T.Helper() 99 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyWithStatus", reflect.TypeOf((*MockIAPClient)(nil).VerifyWithStatus), ctx, reqBody, resp) 100 | } 101 | -------------------------------------------------------------------------------- /appstore/mocks/store.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/awa/go-iap/appstore/api (interfaces: StoreAPIClient) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=../mocks/store.go -package=mocks github.com/awa/go-iap/appstore/api StoreAPIClient 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | io "io" 15 | url "net/url" 16 | reflect "reflect" 17 | time "time" 18 | 19 | api "github.com/awa/go-iap/appstore/api" 20 | gomock "go.uber.org/mock/gomock" 21 | ) 22 | 23 | // MockStoreAPIClient is a mock of StoreAPIClient interface. 24 | type MockStoreAPIClient struct { 25 | ctrl *gomock.Controller 26 | recorder *MockStoreAPIClientMockRecorder 27 | isgomock struct{} 28 | } 29 | 30 | // MockStoreAPIClientMockRecorder is the mock recorder for MockStoreAPIClient. 31 | type MockStoreAPIClientMockRecorder struct { 32 | mock *MockStoreAPIClient 33 | } 34 | 35 | // NewMockStoreAPIClient creates a new mock instance. 36 | func NewMockStoreAPIClient(ctrl *gomock.Controller) *MockStoreAPIClient { 37 | mock := &MockStoreAPIClient{ctrl: ctrl} 38 | mock.recorder = &MockStoreAPIClientMockRecorder{mock} 39 | return mock 40 | } 41 | 42 | // EXPECT returns an object that allows the caller to indicate expected use. 43 | func (m *MockStoreAPIClient) EXPECT() *MockStoreAPIClientMockRecorder { 44 | return m.recorder 45 | } 46 | 47 | // Do mocks base method. 48 | func (m *MockStoreAPIClient) Do(ctx context.Context, method, url string, body io.Reader) (int, []byte, error) { 49 | m.ctrl.T.Helper() 50 | ret := m.ctrl.Call(m, "Do", ctx, method, url, body) 51 | ret0, _ := ret[0].(int) 52 | ret1, _ := ret[1].([]byte) 53 | ret2, _ := ret[2].(error) 54 | return ret0, ret1, ret2 55 | } 56 | 57 | // Do indicates an expected call of Do. 58 | func (mr *MockStoreAPIClientMockRecorder) Do(ctx, method, url, body any) *gomock.Call { 59 | mr.mock.ctrl.T.Helper() 60 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockStoreAPIClient)(nil).Do), ctx, method, url, body) 61 | } 62 | 63 | // ExtendSubscriptionRenewalDate mocks base method. 64 | func (m *MockStoreAPIClient) ExtendSubscriptionRenewalDate(ctx context.Context, originalTransactionId string, body api.ExtendRenewalDateRequest) (int, error) { 65 | m.ctrl.T.Helper() 66 | ret := m.ctrl.Call(m, "ExtendSubscriptionRenewalDate", ctx, originalTransactionId, body) 67 | ret0, _ := ret[0].(int) 68 | ret1, _ := ret[1].(error) 69 | return ret0, ret1 70 | } 71 | 72 | // ExtendSubscriptionRenewalDate indicates an expected call of ExtendSubscriptionRenewalDate. 73 | func (mr *MockStoreAPIClientMockRecorder) ExtendSubscriptionRenewalDate(ctx, originalTransactionId, body any) *gomock.Call { 74 | mr.mock.ctrl.T.Helper() 75 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendSubscriptionRenewalDate", reflect.TypeOf((*MockStoreAPIClient)(nil).ExtendSubscriptionRenewalDate), ctx, originalTransactionId, body) 76 | } 77 | 78 | // ExtendSubscriptionRenewalDateForAll mocks base method. 79 | func (m *MockStoreAPIClient) ExtendSubscriptionRenewalDateForAll(ctx context.Context, body api.MassExtendRenewalDateRequest) (int, error) { 80 | m.ctrl.T.Helper() 81 | ret := m.ctrl.Call(m, "ExtendSubscriptionRenewalDateForAll", ctx, body) 82 | ret0, _ := ret[0].(int) 83 | ret1, _ := ret[1].(error) 84 | return ret0, ret1 85 | } 86 | 87 | // ExtendSubscriptionRenewalDateForAll indicates an expected call of ExtendSubscriptionRenewalDateForAll. 88 | func (mr *MockStoreAPIClientMockRecorder) ExtendSubscriptionRenewalDateForAll(ctx, body any) *gomock.Call { 89 | mr.mock.ctrl.T.Helper() 90 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendSubscriptionRenewalDateForAll", reflect.TypeOf((*MockStoreAPIClient)(nil).ExtendSubscriptionRenewalDateForAll), ctx, body) 91 | } 92 | 93 | // GetALLSubscriptionStatuses mocks base method. 94 | func (m *MockStoreAPIClient) GetALLSubscriptionStatuses(ctx context.Context, originalTransactionId string, query *url.Values) (*api.StatusResponse, error) { 95 | m.ctrl.T.Helper() 96 | ret := m.ctrl.Call(m, "GetALLSubscriptionStatuses", ctx, originalTransactionId, query) 97 | ret0, _ := ret[0].(*api.StatusResponse) 98 | ret1, _ := ret[1].(error) 99 | return ret0, ret1 100 | } 101 | 102 | // GetALLSubscriptionStatuses indicates an expected call of GetALLSubscriptionStatuses. 103 | func (mr *MockStoreAPIClientMockRecorder) GetALLSubscriptionStatuses(ctx, originalTransactionId, query any) *gomock.Call { 104 | mr.mock.ctrl.T.Helper() 105 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetALLSubscriptionStatuses", reflect.TypeOf((*MockStoreAPIClient)(nil).GetALLSubscriptionStatuses), ctx, originalTransactionId, query) 106 | } 107 | 108 | // GetAllNotificationHistory mocks base method. 109 | func (m *MockStoreAPIClient) GetAllNotificationHistory(ctx context.Context, body api.NotificationHistoryRequest, duration time.Duration) ([]api.NotificationHistoryResponseItem, error) { 110 | m.ctrl.T.Helper() 111 | ret := m.ctrl.Call(m, "GetAllNotificationHistory", ctx, body, duration) 112 | ret0, _ := ret[0].([]api.NotificationHistoryResponseItem) 113 | ret1, _ := ret[1].(error) 114 | return ret0, ret1 115 | } 116 | 117 | // GetAllNotificationHistory indicates an expected call of GetAllNotificationHistory. 118 | func (mr *MockStoreAPIClientMockRecorder) GetAllNotificationHistory(ctx, body, duration any) *gomock.Call { 119 | mr.mock.ctrl.T.Helper() 120 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllNotificationHistory", reflect.TypeOf((*MockStoreAPIClient)(nil).GetAllNotificationHistory), ctx, body, duration) 121 | } 122 | 123 | // GetNotificationHistory mocks base method. 124 | func (m *MockStoreAPIClient) GetNotificationHistory(ctx context.Context, body api.NotificationHistoryRequest, paginationToken string) (*api.NotificationHistoryResponses, error) { 125 | m.ctrl.T.Helper() 126 | ret := m.ctrl.Call(m, "GetNotificationHistory", ctx, body, paginationToken) 127 | ret0, _ := ret[0].(*api.NotificationHistoryResponses) 128 | ret1, _ := ret[1].(error) 129 | return ret0, ret1 130 | } 131 | 132 | // GetNotificationHistory indicates an expected call of GetNotificationHistory. 133 | func (mr *MockStoreAPIClientMockRecorder) GetNotificationHistory(ctx, body, paginationToken any) *gomock.Call { 134 | mr.mock.ctrl.T.Helper() 135 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationHistory", reflect.TypeOf((*MockStoreAPIClient)(nil).GetNotificationHistory), ctx, body, paginationToken) 136 | } 137 | 138 | // GetRefundHistory mocks base method. 139 | func (m *MockStoreAPIClient) GetRefundHistory(ctx context.Context, originalTransactionId string) ([]*api.RefundLookupResponse, error) { 140 | m.ctrl.T.Helper() 141 | ret := m.ctrl.Call(m, "GetRefundHistory", ctx, originalTransactionId) 142 | ret0, _ := ret[0].([]*api.RefundLookupResponse) 143 | ret1, _ := ret[1].(error) 144 | return ret0, ret1 145 | } 146 | 147 | // GetRefundHistory indicates an expected call of GetRefundHistory. 148 | func (mr *MockStoreAPIClientMockRecorder) GetRefundHistory(ctx, originalTransactionId any) *gomock.Call { 149 | mr.mock.ctrl.T.Helper() 150 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRefundHistory", reflect.TypeOf((*MockStoreAPIClient)(nil).GetRefundHistory), ctx, originalTransactionId) 151 | } 152 | 153 | // GetSubscriptionRenewalDataStatus mocks base method. 154 | func (m *MockStoreAPIClient) GetSubscriptionRenewalDataStatus(ctx context.Context, productId, requestIdentifier string) (int, *api.MassExtendRenewalDateStatusResponse, error) { 155 | m.ctrl.T.Helper() 156 | ret := m.ctrl.Call(m, "GetSubscriptionRenewalDataStatus", ctx, productId, requestIdentifier) 157 | ret0, _ := ret[0].(int) 158 | ret1, _ := ret[1].(*api.MassExtendRenewalDateStatusResponse) 159 | ret2, _ := ret[2].(error) 160 | return ret0, ret1, ret2 161 | } 162 | 163 | // GetSubscriptionRenewalDataStatus indicates an expected call of GetSubscriptionRenewalDataStatus. 164 | func (mr *MockStoreAPIClientMockRecorder) GetSubscriptionRenewalDataStatus(ctx, productId, requestIdentifier any) *gomock.Call { 165 | mr.mock.ctrl.T.Helper() 166 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscriptionRenewalDataStatus", reflect.TypeOf((*MockStoreAPIClient)(nil).GetSubscriptionRenewalDataStatus), ctx, productId, requestIdentifier) 167 | } 168 | 169 | // GetTestNotificationStatus mocks base method. 170 | func (m *MockStoreAPIClient) GetTestNotificationStatus(ctx context.Context, testNotificationToken string) (int, []byte, error) { 171 | m.ctrl.T.Helper() 172 | ret := m.ctrl.Call(m, "GetTestNotificationStatus", ctx, testNotificationToken) 173 | ret0, _ := ret[0].(int) 174 | ret1, _ := ret[1].([]byte) 175 | ret2, _ := ret[2].(error) 176 | return ret0, ret1, ret2 177 | } 178 | 179 | // GetTestNotificationStatus indicates an expected call of GetTestNotificationStatus. 180 | func (mr *MockStoreAPIClientMockRecorder) GetTestNotificationStatus(ctx, testNotificationToken any) *gomock.Call { 181 | mr.mock.ctrl.T.Helper() 182 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTestNotificationStatus", reflect.TypeOf((*MockStoreAPIClient)(nil).GetTestNotificationStatus), ctx, testNotificationToken) 183 | } 184 | 185 | // GetTransactionHistory mocks base method. 186 | func (m *MockStoreAPIClient) GetTransactionHistory(ctx context.Context, originalTransactionId string, query *url.Values) ([]*api.HistoryResponse, error) { 187 | m.ctrl.T.Helper() 188 | ret := m.ctrl.Call(m, "GetTransactionHistory", ctx, originalTransactionId, query) 189 | ret0, _ := ret[0].([]*api.HistoryResponse) 190 | ret1, _ := ret[1].(error) 191 | return ret0, ret1 192 | } 193 | 194 | // GetTransactionHistory indicates an expected call of GetTransactionHistory. 195 | func (mr *MockStoreAPIClientMockRecorder) GetTransactionHistory(ctx, originalTransactionId, query any) *gomock.Call { 196 | mr.mock.ctrl.T.Helper() 197 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactionHistory", reflect.TypeOf((*MockStoreAPIClient)(nil).GetTransactionHistory), ctx, originalTransactionId, query) 198 | } 199 | 200 | // GetTransactionInfo mocks base method. 201 | func (m *MockStoreAPIClient) GetTransactionInfo(ctx context.Context, transactionId string) (*api.TransactionInfoResponse, error) { 202 | m.ctrl.T.Helper() 203 | ret := m.ctrl.Call(m, "GetTransactionInfo", ctx, transactionId) 204 | ret0, _ := ret[0].(*api.TransactionInfoResponse) 205 | ret1, _ := ret[1].(error) 206 | return ret0, ret1 207 | } 208 | 209 | // GetTransactionInfo indicates an expected call of GetTransactionInfo. 210 | func (mr *MockStoreAPIClientMockRecorder) GetTransactionInfo(ctx, transactionId any) *gomock.Call { 211 | mr.mock.ctrl.T.Helper() 212 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactionInfo", reflect.TypeOf((*MockStoreAPIClient)(nil).GetTransactionInfo), ctx, transactionId) 213 | } 214 | 215 | // LookupOrderID mocks base method. 216 | func (m *MockStoreAPIClient) LookupOrderID(ctx context.Context, orderId string) (*api.OrderLookupResponse, error) { 217 | m.ctrl.T.Helper() 218 | ret := m.ctrl.Call(m, "LookupOrderID", ctx, orderId) 219 | ret0, _ := ret[0].(*api.OrderLookupResponse) 220 | ret1, _ := ret[1].(error) 221 | return ret0, ret1 222 | } 223 | 224 | // LookupOrderID indicates an expected call of LookupOrderID. 225 | func (mr *MockStoreAPIClientMockRecorder) LookupOrderID(ctx, orderId any) *gomock.Call { 226 | mr.mock.ctrl.T.Helper() 227 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupOrderID", reflect.TypeOf((*MockStoreAPIClient)(nil).LookupOrderID), ctx, orderId) 228 | } 229 | 230 | // ParseJWSEncodeString mocks base method. 231 | func (m *MockStoreAPIClient) ParseJWSEncodeString(jwsEncode string) (any, error) { 232 | m.ctrl.T.Helper() 233 | ret := m.ctrl.Call(m, "ParseJWSEncodeString", jwsEncode) 234 | ret0, _ := ret[0].(any) 235 | ret1, _ := ret[1].(error) 236 | return ret0, ret1 237 | } 238 | 239 | // ParseJWSEncodeString indicates an expected call of ParseJWSEncodeString. 240 | func (mr *MockStoreAPIClientMockRecorder) ParseJWSEncodeString(jwsEncode any) *gomock.Call { 241 | mr.mock.ctrl.T.Helper() 242 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseJWSEncodeString", reflect.TypeOf((*MockStoreAPIClient)(nil).ParseJWSEncodeString), jwsEncode) 243 | } 244 | 245 | // ParseSignedTransaction mocks base method. 246 | func (m *MockStoreAPIClient) ParseSignedTransaction(transaction string) (*api.JWSTransaction, error) { 247 | m.ctrl.T.Helper() 248 | ret := m.ctrl.Call(m, "ParseSignedTransaction", transaction) 249 | ret0, _ := ret[0].(*api.JWSTransaction) 250 | ret1, _ := ret[1].(error) 251 | return ret0, ret1 252 | } 253 | 254 | // ParseSignedTransaction indicates an expected call of ParseSignedTransaction. 255 | func (mr *MockStoreAPIClientMockRecorder) ParseSignedTransaction(transaction any) *gomock.Call { 256 | mr.mock.ctrl.T.Helper() 257 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseSignedTransaction", reflect.TypeOf((*MockStoreAPIClient)(nil).ParseSignedTransaction), transaction) 258 | } 259 | 260 | // ParseSignedTransactions mocks base method. 261 | func (m *MockStoreAPIClient) ParseSignedTransactions(transactions []string) ([]*api.JWSTransaction, error) { 262 | m.ctrl.T.Helper() 263 | ret := m.ctrl.Call(m, "ParseSignedTransactions", transactions) 264 | ret0, _ := ret[0].([]*api.JWSTransaction) 265 | ret1, _ := ret[1].(error) 266 | return ret0, ret1 267 | } 268 | 269 | // ParseSignedTransactions indicates an expected call of ParseSignedTransactions. 270 | func (mr *MockStoreAPIClientMockRecorder) ParseSignedTransactions(transactions any) *gomock.Call { 271 | mr.mock.ctrl.T.Helper() 272 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseSignedTransactions", reflect.TypeOf((*MockStoreAPIClient)(nil).ParseSignedTransactions), transactions) 273 | } 274 | 275 | // SendConsumptionInfo mocks base method. 276 | func (m *MockStoreAPIClient) SendConsumptionInfo(ctx context.Context, originalTransactionId string, body api.ConsumptionRequestBody) (int, error) { 277 | m.ctrl.T.Helper() 278 | ret := m.ctrl.Call(m, "SendConsumptionInfo", ctx, originalTransactionId, body) 279 | ret0, _ := ret[0].(int) 280 | ret1, _ := ret[1].(error) 281 | return ret0, ret1 282 | } 283 | 284 | // SendConsumptionInfo indicates an expected call of SendConsumptionInfo. 285 | func (mr *MockStoreAPIClientMockRecorder) SendConsumptionInfo(ctx, originalTransactionId, body any) *gomock.Call { 286 | mr.mock.ctrl.T.Helper() 287 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendConsumptionInfo", reflect.TypeOf((*MockStoreAPIClient)(nil).SendConsumptionInfo), ctx, originalTransactionId, body) 288 | } 289 | 290 | // SendRequestTestNotification mocks base method. 291 | func (m *MockStoreAPIClient) SendRequestTestNotification(ctx context.Context) (int, []byte, error) { 292 | m.ctrl.T.Helper() 293 | ret := m.ctrl.Call(m, "SendRequestTestNotification", ctx) 294 | ret0, _ := ret[0].(int) 295 | ret1, _ := ret[1].([]byte) 296 | ret2, _ := ret[2].(error) 297 | return ret0, ret1, ret2 298 | } 299 | 300 | // SendRequestTestNotification indicates an expected call of SendRequestTestNotification. 301 | func (mr *MockStoreAPIClientMockRecorder) SendRequestTestNotification(ctx any) *gomock.Call { 302 | mr.mock.ctrl.T.Helper() 303 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendRequestTestNotification", reflect.TypeOf((*MockStoreAPIClient)(nil).SendRequestTestNotification), ctx) 304 | } 305 | -------------------------------------------------------------------------------- /appstore/model.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import "encoding/json" 4 | 5 | type NumericString string 6 | 7 | func (n *NumericString) UnmarshalJSON(b []byte) error { 8 | var number json.Number 9 | if err := json.Unmarshal(b, &number); err != nil { 10 | return err 11 | } 12 | *n = NumericString(number.String()) 13 | return nil 14 | } 15 | 16 | // Environment is alias 17 | type Environment string 18 | 19 | // list of Environment 20 | const ( 21 | Sandbox Environment = "Sandbox" 22 | Production Environment = "Production" 23 | ) 24 | 25 | type ( 26 | // IAPRequest is struct 27 | // https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html 28 | // The IAPRequest type has the request parameter 29 | IAPRequest struct { 30 | ReceiptData string `json:"receipt-data"` 31 | // Only used for receipts that contain auto-renewable subscriptions. 32 | Password string `json:"password,omitempty"` 33 | // Only used for iOS7 style app receipts that contain auto-renewable or non-renewing subscriptions. 34 | // If value is true, response includes only the latest renewal transaction for any subscriptions. 35 | ExcludeOldTransactions bool `json:"exclude-old-transactions"` 36 | } 37 | 38 | // The ReceiptCreationDate type indicates the date when the app receipt was created. 39 | ReceiptCreationDate struct { 40 | CreationDate string `json:"receipt_creation_date"` 41 | CreationDateMS string `json:"receipt_creation_date_ms"` 42 | CreationDatePST string `json:"receipt_creation_date_pst"` 43 | } 44 | 45 | // The RequestDate type indicates the date and time that the request was sent 46 | RequestDate struct { 47 | RequestDate string `json:"request_date"` 48 | RequestDateMS string `json:"request_date_ms"` 49 | RequestDatePST string `json:"request_date_pst"` 50 | } 51 | 52 | // The PurchaseDate type indicates the date and time that the item was purchased 53 | PurchaseDate struct { 54 | PurchaseDate string `json:"purchase_date"` 55 | PurchaseDateMS string `json:"purchase_date_ms"` 56 | PurchaseDatePST string `json:"purchase_date_pst"` 57 | } 58 | 59 | // The OriginalPurchaseDate type indicates the beginning of the subscription period 60 | OriginalPurchaseDate struct { 61 | OriginalPurchaseDate string `json:"original_purchase_date"` 62 | OriginalPurchaseDateMS string `json:"original_purchase_date_ms"` 63 | OriginalPurchaseDatePST string `json:"original_purchase_date_pst"` 64 | } 65 | 66 | // The PreorderDate type indicates the date and time that the pre-order 67 | PreorderDate struct { 68 | PreorderDate string `json:"preorder_date"` 69 | PreorderDateMS string `json:"preorder_date_ms"` 70 | PreorderDatePST string `json:"preorder_date_pst"` 71 | } 72 | 73 | // The ExpiresDate type indicates the expiration date for the subscription 74 | ExpiresDate struct { 75 | ExpiresDate string `json:"expires_date,omitempty"` 76 | ExpiresDateMS string `json:"expires_date_ms,omitempty"` 77 | ExpiresDatePST string `json:"expires_date_pst,omitempty"` 78 | ExpiresDateFormatted string `json:"expires_date_formatted,omitempty"` 79 | ExpiresDateFormattedPST string `json:"expires_date_formatted_pst,omitempty"` 80 | } 81 | 82 | // The CancellationDate type indicates the time and date of the cancellation by Apple customer support 83 | CancellationDate struct { 84 | CancellationDate string `json:"cancellation_date,omitempty"` 85 | CancellationDateMS string `json:"cancellation_date_ms,omitempty"` 86 | CancellationDatePST string `json:"cancellation_date_pst,omitempty"` 87 | } 88 | 89 | // The GracePeriodDate type indicates the grace period date for the subscription 90 | GracePeriodDate struct { 91 | GracePeriodDate string `json:"grace_period_expires_date,omitempty"` 92 | GracePeriodDateMS string `json:"grace_period_expires_date_ms,omitempty"` 93 | GracePeriodDatePST string `json:"grace_period_expires_date_pst,omitempty"` 94 | } 95 | 96 | // AutoRenewStatusChangeDate type indicates the auto renew status change date 97 | AutoRenewStatusChangeDate struct { 98 | AutoRenewStatusChangeDate string `json:"auto_renew_status_change_date"` 99 | AutoRenewStatusChangeDateMS string `json:"auto_renew_status_change_date_ms"` 100 | AutoRenewStatusChangeDatePST string `json:"auto_renew_status_change_date_pst"` 101 | } 102 | 103 | // The InApp type has the receipt attributes 104 | InApp struct { 105 | Quantity string `json:"quantity"` 106 | ProductID string `json:"product_id"` 107 | TransactionID string `json:"transaction_id"` 108 | OriginalTransactionID NumericString `json:"original_transaction_id,omitempty"` 109 | WebOrderLineItemID string `json:"web_order_line_item_id,omitempty"` 110 | PromotionalOfferID string `json:"promotional_offer_id"` 111 | SubscriptionGroupIdentifier string `json:"subscription_group_identifier"` 112 | OfferCodeRefName string `json:"offer_code_ref_name,omitempty"` 113 | AppAccountToken string `json:"app_account_token,omitempty"` 114 | 115 | IsTrialPeriod string `json:"is_trial_period"` 116 | IsInIntroOfferPeriod string `json:"is_in_intro_offer_period,omitempty"` 117 | IsUpgraded string `json:"is_upgraded,omitempty"` 118 | 119 | ExpiresDate 120 | 121 | PurchaseDate 122 | OriginalPurchaseDate 123 | 124 | CancellationDate 125 | CancellationReason string `json:"cancellation_reason,omitempty"` 126 | 127 | InAppOwnershipType string `json:"in_app_ownership_type,omitempty"` 128 | } 129 | 130 | // The Receipt type has whole data of receipt 131 | Receipt struct { 132 | ReceiptType string `json:"receipt_type"` 133 | AdamID int64 `json:"adam_id"` 134 | AppItemID NumericString `json:"app_item_id"` 135 | BundleID string `json:"bundle_id"` 136 | ApplicationVersion string `json:"application_version"` 137 | DownloadID int64 `json:"download_id"` 138 | VersionExternalIdentifier NumericString `json:"version_external_identifier"` 139 | OriginalApplicationVersion string `json:"original_application_version"` 140 | InApp []InApp `json:"in_app"` 141 | ReceiptCreationDate 142 | RequestDate 143 | OriginalPurchaseDate 144 | PreorderDate 145 | ExpiresDate 146 | } 147 | 148 | // PendingRenewalInfo is struct 149 | // A pending renewal may refer to a renewal that is scheduled in the future or a renewal that failed in the past for some reason. 150 | // https://developer.apple.com/documentation/appstoreservernotifications/unified_receipt/pending_renewal_info 151 | PendingRenewalInfo struct { 152 | SubscriptionExpirationIntent string `json:"expiration_intent"` 153 | SubscriptionAutoRenewProductID string `json:"auto_renew_product_id"` 154 | SubscriptionRetryFlag string `json:"is_in_billing_retry_period"` 155 | SubscriptionAutoRenewStatus string `json:"auto_renew_status"` 156 | SubscriptionPriceConsentStatus string `json:"price_consent_status"` 157 | ProductID string `json:"product_id"` 158 | OriginalTransactionID string `json:"original_transaction_id"` 159 | OfferCodeRefName string `json:"offer_code_ref_name,omitempty"` 160 | PromotionalOfferID string `json:"promotional_offer_id,omitempty"` 161 | PriceIncreaseStatus string `json:"price_increase_status,omitempty"` 162 | 163 | GracePeriodDate 164 | } 165 | 166 | // The IAPResponse type has the response properties 167 | // We defined each field by the current IAP response, but some fields are not mentioned 168 | // in the following Apple's document; 169 | // https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html 170 | // If you get other types or fields from the IAP response, you should use the struct you defined. 171 | IAPResponse struct { 172 | Status int `json:"status"` 173 | Environment Environment `json:"environment"` 174 | Receipt Receipt `json:"receipt"` 175 | LatestReceiptInfo []InApp `json:"latest_receipt_info,omitempty"` 176 | LatestReceipt string `json:"latest_receipt,omitempty"` 177 | PendingRenewalInfo []PendingRenewalInfo `json:"pending_renewal_info,omitempty"` 178 | IsRetryable bool `json:"is_retryable,omitempty"` 179 | } 180 | 181 | // StatusResponse is struct 182 | // The HttpStatusResponse struct contains the status code returned by the store 183 | // Used as a workaround to detect when to hit the production appstore or sandbox appstore regardless of receipt type 184 | StatusResponse struct { 185 | Status int `json:"status"` 186 | } 187 | 188 | // IAPResponseForIOS6 is iOS 6 style receipt schema. 189 | IAPResponseForIOS6 struct { 190 | AutoRenewProductID string `json:"auto_renew_product_id"` 191 | AutoRenewStatus int `json:"auto_renew_status"` 192 | CancellationReason string `json:"cancellation_reason,omitempty"` 193 | ExpirationIntent string `json:"expiration_intent,omitempty"` 194 | IsInBillingRetryPeriod string `json:"is_in_billing_retry_period,omitempty"` 195 | Receipt ReceiptForIOS6 `json:"receipt"` 196 | LatestExpiredReceiptInfo ReceiptForIOS6 `json:"latest_expired_receipt_info"` 197 | LatestReceipt string `json:"latest_receipt"` 198 | LatestReceiptInfo ReceiptForIOS6 `json:"latest_receipt_info"` 199 | Status int `json:"status"` 200 | } 201 | 202 | // ReceiptForIOS6 is struct 203 | ReceiptForIOS6 struct { 204 | AppItemID NumericString `json:"app_item_id"` 205 | BID string `json:"bid"` 206 | BVRS string `json:"bvrs"` 207 | CancellationDate 208 | ExpiresDate 209 | IsTrialPeriod string `json:"is_trial_period"` 210 | IsInIntroOfferPeriod string `json:"is_in_intro_offer_period"` 211 | ItemID string `json:"item_id"` 212 | ProductID string `json:"product_id"` 213 | PurchaseDate 214 | OriginalTransactionID NumericString `json:"original_transaction_id,omitempty"` 215 | OriginalPurchaseDate 216 | Quantity string `json:"quantity"` 217 | TransactionID string `json:"transaction_id"` 218 | UniqueIdentifier string `json:"unique_identifier"` 219 | UniqueVendorIdentifier string `json:"unique_vendor_identifier"` 220 | VersionExternalIdentifier NumericString `json:"version_external_identifier,omitempty"` 221 | WebOrderLineItemID string `json:"web_order_line_item_id"` 222 | } 223 | ) 224 | -------------------------------------------------------------------------------- /appstore/model_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func TestNumericString_UnmarshalJSON(t *testing.T) { 11 | type foo struct { 12 | ID NumericString 13 | } 14 | 15 | tests := []struct { 16 | name string 17 | in []byte 18 | err error 19 | out foo 20 | }{ 21 | { 22 | name: "string case", 23 | in: []byte("{\"ID\":\"8080\"}"), 24 | err: nil, 25 | out: foo{ID: "8080"}, 26 | }, 27 | { 28 | name: "number case", 29 | in: []byte("{\"ID\":8080}"), 30 | err: nil, 31 | out: foo{ID: "8080"}, 32 | }, 33 | { 34 | name: "object case", 35 | in: []byte("{\"ID\":{\"Num\": 8080}}"), 36 | err: errors.New("json: cannot unmarshal object into Go struct field foo.ID of type json.Number"), 37 | out: foo{}, 38 | }, 39 | } 40 | 41 | for _, v := range tests { 42 | t.Run(v.name, func(t *testing.T) { 43 | out := foo{} 44 | err := json.Unmarshal(v.in, &out) 45 | 46 | if err != nil { 47 | if err.Error() != v.err.Error() { 48 | t.Errorf("input: %s, get: %s, want: %s\n", v.in, err, v.err) 49 | } 50 | return 51 | } 52 | 53 | if !reflect.DeepEqual(out, v.out) { 54 | t.Errorf("input: %s, get: %v, want: %v\n", v.in, out, v.out) 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /appstore/notification.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | // NotificationType is type 4 | // https://developer.apple.com/documentation/appstoreservernotifications/notification_type 5 | // https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html#//apple_ref/doc/uid/TP40008267-CH7-SW16 6 | // Deprecated 7 | type NotificationType string 8 | 9 | // list of NotificationType 10 | const ( 11 | // Initial purchase of the subscription. 12 | NotificationTypeInitialBuy NotificationType = "INITIAL_BUY" 13 | // Subscription was canceled by Apple customer support. 14 | NotificationTypeCancel NotificationType = "CANCEL" 15 | // Automatic renewal was successful for an expired subscription. 16 | // Deprecated: DID_RECOVER should be used instead of RENEWAL 17 | NotificationTypeRenewal NotificationType = "RENEWAL" 18 | // Expired subscription recovered through a billing retry. 19 | NotificationTypeDidRecover NotificationType = "DID_RECOVER" 20 | // Customer renewed a subscription interactively after it lapsed. 21 | NotificationTypeInteractiveRenewal NotificationType = "INTERACTIVE_RENEWAL" 22 | // Customer changed the plan that takes affect at the next subscription renewal. Current active plan is not affected. 23 | NotificationTypeDidChangeRenewalPreference NotificationType = "DID_CHANGE_RENEWAL_PREF" 24 | // Customer changed the subscription renewal status. Current active plan is not affected. 25 | NotificationTypeDidChangeRenewalStatus NotificationType = "DID_CHANGE_RENEWAL_STATUS" 26 | // Subscription failed to renew due to a billing issue. 27 | NotificationTypeDidFailToRenew NotificationType = "DID_FAIL_TO_RENEW" 28 | // AppleCare successfully refunded the transaction for a consumable, non-consumable, or a non-renewing subscription. 29 | NotificationTypeRefund NotificationType = "REFUND" 30 | // App Store has started asking the customer to consent to your app’s subscription price increase. 31 | NotificationTypePriceIncreaseConsent NotificationType = "PRICE_INCREASE_CONSENT" 32 | // Customer’s subscription has successfully auto-renewed for a new transaction period. 33 | NotificationTypeDidRenew NotificationType = "DID_RENEW" 34 | // Customer's in-app purchase through Family Sharing is no longer available through sharing. 35 | NotificationTypeDidRevoke NotificationType = "REVOKE" 36 | // Indicates that the customer initiated a refund request for a consumable in-app purchase, and the App Store is requesting that you provide consumption data. 37 | NotificationTypeConsumptionRequest NotificationType = "CONSUMPTION_REQUEST" 38 | ) 39 | 40 | // NotificationEnvironment is type 41 | type NotificationEnvironment string 42 | 43 | // list of NotificationEnvironment 44 | const ( 45 | NotificationSandbox NotificationEnvironment = "Sandbox" 46 | NotificationProduction NotificationEnvironment = "PROD" 47 | ) 48 | 49 | // NotificationExpiresDate is struct 50 | type NotificationExpiresDate struct { 51 | ExpiresDateMS string `json:"expires_date"` 52 | ExpiresDateUTC string `json:"expires_date_formatted"` 53 | ExpiresDatePST string `json:"expires_date_formatted_pst"` 54 | } 55 | 56 | // NotificationReceipt is struct 57 | type NotificationReceipt struct { 58 | UniqueIdentifier string `json:"unique_identifier"` 59 | AppItemID string `json:"app_item_id"` 60 | Quantity string `json:"quantity"` 61 | VersionExternalIdentifier string `json:"version_external_identifier"` 62 | UniqueVendorIdentifier string `json:"unique_vendor_identifier"` 63 | WebOrderLineItemID string `json:"web_order_line_item_id"` 64 | ItemID string `json:"item_id"` 65 | ProductID string `json:"product_id"` 66 | BID string `json:"bid"` 67 | BVRS string `json:"bvrs"` 68 | TransactionID string `json:"transaction_id"` 69 | OriginalTransactionID NumericString `json:"original_transaction_id,omitempty"` 70 | IsTrialPeriod string `json:"is_trial_period"` 71 | IsInIntroOfferPeriod string `json:"is_in_intro_offer_period"` 72 | 73 | PurchaseDate 74 | OriginalPurchaseDate 75 | NotificationExpiresDate 76 | CancellationDate 77 | } 78 | 79 | // NotificationUnifiedReceipt is struct 80 | type NotificationUnifiedReceipt struct { 81 | Status int `json:"status"` 82 | Environment Environment `json:"environment"` 83 | LatestReceipt string `json:"latest_receipt"` 84 | LatestReceiptInfo []InApp `json:"latest_receipt_info"` 85 | PendingRenewalInfo []PendingRenewalInfo `json:"pending_renewal_info,omitempty"` 86 | } 87 | 88 | // SubscriptionNotification is struct for 89 | // https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv1 90 | // Deprecated 91 | type SubscriptionNotification struct { 92 | Environment NotificationEnvironment `json:"environment"` 93 | NotificationType NotificationType `json:"notification_type"` 94 | 95 | // Not show in raw notify body 96 | Password string `json:"password"` 97 | OriginalTransactionID NumericString `json:"original_transaction_id,omitempty"` 98 | AutoRenewAdamID string `json:"auto_renew_adam_id"` 99 | 100 | // The primary key for identifying a subscription purchase. 101 | // Posted only if the notification_type is CANCEL. 102 | WebOrderLineItemID string `json:"web_order_line_item_id"` 103 | 104 | // This is the same as the Subscription Expiration Intent in the receipt. 105 | // Posted only if notification_type is RENEWAL or INTERACTIVE_RENEWAL. 106 | ExpirationIntent NumericString `json:"expiration_intent"` 107 | 108 | // Auto renew info 109 | AutoRenewStatus string `json:"auto_renew_status"` // false or true 110 | AutoRenewProductID string `json:"auto_renew_product_id"` 111 | AutoRenewStatusChangeDate 112 | 113 | // Posted if the notification_type is RENEWAL or INTERACTIVE_RENEWAL, and only if the renewal is successful. 114 | // Posted also if the notification_type is INITIAL_BUY. 115 | // Not posted for notification_type CANCEL. 116 | // Deprecated: use UnifiedReceipt.LatestReceipt instead. See details: https://developer.apple.com/documentation/appstoreservernotifications/ . 117 | LatestReceipt string `json:"latest_receipt"` 118 | // Deprecated: use UnifiedReceipt.LatestReceiptInfo instead. See details: https://developer.apple.com/documentation/appstoreservernotifications/ . 119 | LatestReceiptInfo NotificationReceipt `json:"latest_receipt_info"` 120 | 121 | // In the new notifications above properties latest_receipt, latest_receipt_info are moved under this one 122 | UnifiedReceipt NotificationUnifiedReceipt `json:"unified_receipt"` 123 | 124 | // Posted only if the notification_type is RENEWAL or CANCEL or if renewal failed and subscription expired. 125 | // Deprecated: see details: https://developer.apple.com/documentation/appstoreservernotifications/ . 126 | LatestExpiredReceipt string `json:"latest_expired_receipt"` 127 | // Deprecated: see details: https://developer.apple.com/documentation/appstoreservernotifications/ . 128 | LatestExpiredReceiptInfo NotificationReceipt `json:"latest_expired_receipt_info"` 129 | 130 | // BID is the app bundle ID 131 | BID string `json:"bid,omitempty"` 132 | // BVRS is the app bundle version 133 | BVRS string `json:"bvrs,omitempty"` 134 | 135 | // Posted only if the notification_type is CANCEL. 136 | CancellationDate 137 | } 138 | -------------------------------------------------------------------------------- /appstore/notification_v2.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import "github.com/golang-jwt/jwt/v5" 4 | 5 | // NotificationTypeV2 is type 6 | type NotificationTypeV2 string 7 | 8 | // list of notificationType 9 | // https://developer.apple.com/documentation/appstoreservernotifications/notificationtype 10 | const ( 11 | NotificationTypeV2ConsumptionRequest NotificationTypeV2 = "CONSUMPTION_REQUEST" 12 | NotificationTypeV2DidChangeRenewalPref NotificationTypeV2 = "DID_CHANGE_RENEWAL_PREF" 13 | NotificationTypeV2DidChangeRenewalStatus NotificationTypeV2 = "DID_CHANGE_RENEWAL_STATUS" 14 | NotificationTypeV2DidFailToRenew NotificationTypeV2 = "DID_FAIL_TO_RENEW" 15 | NotificationTypeV2DidRenew NotificationTypeV2 = "DID_RENEW" 16 | NotificationTypeV2Expired NotificationTypeV2 = "EXPIRED" 17 | NotificationTypeV2ExternalPurchaseToken NotificationTypeV2 = "EXTERNAL_PURCHASE_TOKEN" 18 | NotificationTypeV2GracePeriodExpired NotificationTypeV2 = "GRACE_PERIOD_EXPIRED" 19 | NotificationTypeV2OfferRedeemed NotificationTypeV2 = "OFFER_REDEEMED" 20 | NotificationTypeV2OneTimeCharge NotificationTypeV2 = "ONE_TIME_CHARGE" 21 | NotificationTypeV2PriceIncrease NotificationTypeV2 = "PRICE_INCREASE" 22 | NotificationTypeV2Refund NotificationTypeV2 = "REFUND" 23 | NotificationTypeV2RefundDeclined NotificationTypeV2 = "REFUND_DECLINED" 24 | NotificationTypeV2RefundReversed NotificationTypeV2 = "REFUND_REVERSED" 25 | NotificationTypeV2RenewalExtended NotificationTypeV2 = "RENEWAL_EXTENDED" 26 | NotificationTypeV2RenewalExtension NotificationTypeV2 = "RENEWAL_EXTENSION" 27 | NotificationTypeV2Revoke NotificationTypeV2 = "REVOKE" 28 | NotificationTypeV2Subscribed NotificationTypeV2 = "SUBSCRIBED" 29 | NotificationTypeV2Test NotificationTypeV2 = "TEST" 30 | ) 31 | 32 | // SubtypeV2 is type 33 | type SubtypeV2 string 34 | 35 | // list of subtypes 36 | // https://developer.apple.com/documentation/appstoreservernotifications/subtype 37 | const ( 38 | SubTypeV2Accepted = "ACCEPTED" 39 | SubTypeV2AutoRenewDisabled = "AUTO_RENEW_DISABLED" 40 | SubTypeV2AutoRenewEnabled = "AUTO_RENEW_ENABLED" 41 | SubTypeV2BillingRecovery = "BILLING_RECOVERY" 42 | SubTypeV2BillingRetry = "BILLING_RETRY" 43 | SubTypeV2Downgrade = "DOWNGRADE" 44 | SubTypeV2Failure = "FAILURE" 45 | SubTypeV2GracePeriod = "GRACE_PERIOD" 46 | SubTypeV2InitialBuy = "INITIAL_BUY" 47 | SubTypeV2Pending = "PENDING" 48 | SubTypeV2PriceIncrease = "PRICE_INCREASE" 49 | SubTypeV2ProductNotForSale = "PRODUCT_NOT_FOR_SALE" 50 | SubTypeV2Resubscribe = "RESUBSCRIBE" 51 | SubTypeV2Summary = "SUMMARY" 52 | SubTypeV2Upgrade = "UPGRADE" 53 | SubTypeV2Unreported = "UNREPORTED" 54 | SubTypeV2Voluntary = "VOLUNTARY" 55 | ) 56 | 57 | type AutoRenewStatus int 58 | 59 | const ( 60 | Off AutoRenewStatus = iota 61 | On 62 | ) 63 | 64 | type ExpirationIntent int 65 | 66 | const ( 67 | CustomerCancelled ExpirationIntent = iota + 1 68 | BillingError 69 | NoPriceChangeConsent 70 | ProductUnavailable 71 | ) 72 | 73 | type OfferType int 74 | 75 | const ( 76 | IntroductoryOffer OfferType = iota + 1 77 | PromotionalOffer 78 | SubscriptionOfferCode 79 | ) 80 | 81 | type PriceIncreaseStatus int 82 | 83 | const ( 84 | CustomerNotYetConsented PriceIncreaseStatus = iota 85 | CustomerConsented 86 | ) 87 | 88 | type RevocationReason int 89 | 90 | const ( 91 | OtherReason RevocationReason = iota 92 | AppIssue 93 | ) 94 | 95 | type IAPType string 96 | 97 | const ( 98 | AutoRenewable IAPType = "Auto-Renewable Subscription" 99 | NonConsumable IAPType = "Non-Consumable" 100 | Consumable IAPType = "Consumable" 101 | NonRenewable IAPType = "Non-Renewing Subscription" 102 | ) 103 | 104 | // AutoRenewableSubscriptionStatus status value is current as of the signedDate in the decoded payload, SubscriptionNotificationV2DecodedPayload. 105 | // https://developer.apple.com/documentation/appstoreservernotifications/status 106 | type AutoRenewableSubscriptionStatus int32 107 | 108 | const ( 109 | AutoRenewableSubscriptionStatusActive = iota + 1 110 | AutoRenewableSubscriptionStatusExpired 111 | AutoRenewableSubscriptionStatusBillingRetryPeriod 112 | AutoRenewableSubscriptionStatusBillingGracePeriod 113 | AutoRenewableSubscriptionStatusRevoked 114 | ) 115 | 116 | // TransactionReason indicates the cause of a purchase transaction, 117 | // which indicates whether it’s a customer’s purchase or a renewal for an auto-renewable subscription that the system initiates. 118 | // https://developer.apple.com/documentation/appstoreservernotifications/transactionreason 119 | type TransactionReason string 120 | 121 | const ( 122 | TransactionReasonPurchase = "PURCHASE" 123 | TransactionReasonRenewal = "RENEWAL" 124 | ) 125 | 126 | type OfferDiscountType string 127 | 128 | const ( 129 | OfferDiscountTypeFreeTrial OfferDiscountType = "FREE_TRIAL" 130 | OfferDiscountTypePayAsYouGo OfferDiscountType = "PAY_AS_YOU_GO" 131 | OfferDiscountTypePayUpFront OfferDiscountType = "PAY_UP_FRONT" 132 | ) 133 | 134 | type ( 135 | // SubscriptionNotificationV2 is struct for 136 | // https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2 137 | SubscriptionNotificationV2 struct { 138 | SignedPayload SubscriptionNotificationV2SignedPayload `json:"signedPayload"` 139 | } 140 | 141 | // SubscriptionNotificationV2SignedPayload is struct 142 | // https://developer.apple.com/documentation/appstoreservernotifications/signedpayload 143 | SubscriptionNotificationV2SignedPayload struct { 144 | SignedPayload string `json:"signedPayload"` 145 | } 146 | 147 | // SubscriptionNotificationV2DecodedPayload is struct 148 | // https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload 149 | SubscriptionNotificationV2DecodedPayload struct { 150 | NotificationType NotificationTypeV2 `json:"notificationType"` 151 | Subtype SubtypeV2 `json:"subtype"` 152 | NotificationUUID string `json:"notificationUUID"` 153 | NotificationVersion string `json:"version"` 154 | SignedDate int64 `json:"signedDate"` 155 | Data SubscriptionNotificationV2Data `json:"data,omitempty"` 156 | Summary SubscriptionNotificationV2Summary `json:"summary,omitempty"` 157 | jwt.RegisteredClaims 158 | } 159 | 160 | // SubscriptionNotificationV2Summary is struct 161 | // https://developer.apple.com/documentation/appstoreservernotifications/summary 162 | SubscriptionNotificationV2Summary struct { 163 | RequestIdentifier string `json:"requestIdentifier"` 164 | Environment string `json:"environment"` 165 | AppAppleId int64 `json:"appAppleId"` 166 | BundleID string `json:"bundleId"` 167 | ProductID string `json:"productId"` 168 | StorefrontCountryCodes string `json:"storefrontCountryCodes"` 169 | FailedCount int64 `json:"failedCount"` 170 | SucceededCount int64 `json:"succeededCount"` 171 | } 172 | 173 | // SubscriptionNotificationV2Data is struct 174 | // https://developer.apple.com/documentation/appstoreservernotifications/data 175 | SubscriptionNotificationV2Data struct { 176 | AppAppleID int `json:"appAppleId"` 177 | BundleID string `json:"bundleId"` 178 | BundleVersion string `json:"bundleVersion"` 179 | Environment string `json:"environment"` 180 | SignedRenewalInfo JWSRenewalInfo `json:"signedRenewalInfo"` 181 | SignedTransactionInfo JWSTransaction `json:"signedTransactionInfo"` 182 | Status AutoRenewableSubscriptionStatus `json:"status"` 183 | } 184 | 185 | // SubscriptionNotificationV2JWSDecodedHeader is struct 186 | SubscriptionNotificationV2JWSDecodedHeader struct { 187 | Alg string `json:"alg"` 188 | Kid string `json:"kid"` 189 | X5c []string `json:"x5c"` 190 | } 191 | 192 | // JWSRenewalInfo contains the Base64 encoded signed JWS payload of the renewal information 193 | // https://developer.apple.com/documentation/appstoreservernotifications/jwsrenewalinfo 194 | JWSRenewalInfo string 195 | 196 | // JWSTransaction contains the Base64 encoded signed JWS payload of the transaction 197 | // https://developer.apple.com/documentation/appstoreservernotifications/jwstransaction 198 | JWSTransaction string 199 | 200 | // JWSRenewalInfoDecodedPayload contains the decoded renewal information 201 | // https://developer.apple.com/documentation/appstoreservernotifications/jwsrenewalinfodecodedpayload 202 | JWSRenewalInfoDecodedPayload struct { 203 | AutoRenewProductId string `json:"autoRenewProductId"` 204 | AutoRenewStatus AutoRenewStatus `json:"autoRenewStatus"` 205 | Currency string `json:"currency"` 206 | Environment Environment `json:"environment"` 207 | ExpirationIntent ExpirationIntent `json:"expirationIntent"` 208 | GracePeriodExpiresDate int64 `json:"gracePeriodExpiresDate"` 209 | IsInBillingRetryPeriod bool `json:"isInBillingRetryPeriod"` 210 | OfferIdentifier string `json:"offerIdentifier"` 211 | OfferType OfferType `json:"offerType"` 212 | OriginalTransactionId string `json:"originalTransactionId"` 213 | PriceIncreaseStatus PriceIncreaseStatus `json:"priceIncreaseStatus"` 214 | ProductId string `json:"productId"` 215 | RecentSubscriptionStartDate int64 `json:"recentSubscriptionStartDate"` 216 | RenewalDate int64 `json:"renewalDate"` 217 | RenewalPrice int64 `json:"renewalPrice"` 218 | SignedDate int64 `json:"signedDate"` 219 | jwt.RegisteredClaims 220 | } 221 | 222 | // JWSTransactionDecodedPayload contains the decoded transaction information 223 | // https://developer.apple.com/documentation/appstoreservernotifications/jwstransactiondecodedpayload 224 | JWSTransactionDecodedPayload struct { 225 | AppAccountToken string `json:"appAccountToken"` 226 | BundleId string `json:"bundleId"` 227 | Currency string `json:"currency,omitempty"` 228 | Environment Environment `json:"environment"` 229 | ExpiresDate int64 `json:"expiresDate"` 230 | InAppOwnershipType string `json:"inAppOwnershipType"` 231 | IsUpgraded bool `json:"isUpgraded"` 232 | OfferDiscountType OfferDiscountType `json:"offerDiscountType"` 233 | OfferIdentifier string `json:"offerIdentifier"` 234 | OfferType OfferType `json:"offerType"` 235 | OriginalPurchaseDate int64 `json:"originalPurchaseDate"` 236 | OriginalTransactionId string `json:"originalTransactionId"` 237 | Price int64 `json:"price,omitempty"` 238 | ProductId string `json:"productId"` 239 | PurchaseDate int64 `json:"purchaseDate"` 240 | Quantity int64 `json:"quantity"` 241 | RevocationDate int64 `json:"revocationDate"` 242 | RevocationReason RevocationReason `json:"revocationReason"` 243 | SignedDate int64 `json:"signedDate"` 244 | Storefront string `json:"storefront"` 245 | StorefrontId string `json:"storefrontId"` 246 | SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"` 247 | TransactionId string `json:"transactionId"` 248 | TransactionReason TransactionReason `json:"transactionReason"` 249 | IAPtype IAPType `json:"type"` 250 | WebOrderLineItemId string `json:"webOrderLineItemId"` 251 | jwt.RegisteredClaims 252 | } 253 | ) 254 | -------------------------------------------------------------------------------- /appstore/validator.go: -------------------------------------------------------------------------------- 1 | //go:generate mockgen -destination=mocks/appstore.go -package=mocks github.com/awa/go-iap/appstore IAPClient 2 | 3 | package appstore 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "time" 14 | 15 | "github.com/golang-jwt/jwt/v5" 16 | ) 17 | 18 | const ( 19 | // SandboxURL is the endpoint for sandbox environment. 20 | SandboxURL string = "https://sandbox.itunes.apple.com/verifyReceipt" 21 | // ProductionURL is the endpoint for production environment. 22 | ProductionURL string = "https://buy.itunes.apple.com/verifyReceipt" 23 | // ContentType is the request content-type for apple store. 24 | ContentType string = "application/json; charset=utf-8" 25 | ) 26 | 27 | // IAPClient is an interface to call validation API in App Store 28 | type IAPClient interface { 29 | Verify(ctx context.Context, reqBody IAPRequest, resp interface{}) error 30 | VerifyWithStatus(ctx context.Context, reqBody IAPRequest, resp interface{}) (int, error) 31 | ParseNotificationV2(tokenStr string, result *jwt.Token) error 32 | ParseNotificationV2WithClaim(tokenStr string, result jwt.Claims) error 33 | } 34 | 35 | // Client implements IAPClient 36 | type Client struct { 37 | ProductionURL string 38 | SandboxURL string 39 | httpCli *http.Client 40 | } 41 | 42 | // list of errors 43 | var ( 44 | ErrAppStoreServer = errors.New("AppStore server error") 45 | 46 | ErrInvalidJSON = errors.New("The App Store could not read the JSON object you provided.") 47 | ErrInvalidReceiptData = errors.New("The data in the receipt-data property was malformed or missing.") 48 | ErrReceiptUnauthenticated = errors.New("The receipt could not be authenticated.") 49 | ErrInvalidSharedSecret = errors.New("The shared secret you provided does not match the shared secret on file for your account.") 50 | ErrServerUnavailable = errors.New("The receipt server is not currently available.") 51 | ErrReceiptIsForTest = errors.New("This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.") 52 | ErrReceiptIsForProduction = errors.New("This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.") 53 | ErrReceiptUnauthorized = errors.New("This receipt could not be authorized. Treat this the same as if a purchase was never made.") 54 | 55 | ErrInternalDataAccessError = errors.New("Internal data access error.") 56 | ErrUnknown = errors.New("An unknown error occurred") 57 | ) 58 | 59 | // HandleError returns error message by status code 60 | func HandleError(status int) error { 61 | var e error 62 | switch status { 63 | case 0: 64 | return nil 65 | case 21000: 66 | e = ErrInvalidJSON 67 | case 21002: 68 | e = ErrInvalidReceiptData 69 | case 21003: 70 | e = ErrReceiptUnauthenticated 71 | case 21004: 72 | e = ErrInvalidSharedSecret 73 | case 21005: 74 | e = ErrServerUnavailable 75 | case 21007: 76 | e = ErrReceiptIsForTest 77 | case 21008: 78 | e = ErrReceiptIsForProduction 79 | case 21009: 80 | e = ErrInternalDataAccessError 81 | case 21010: 82 | e = ErrReceiptUnauthorized 83 | default: 84 | if status >= 21100 && status <= 21199 { 85 | e = ErrInternalDataAccessError 86 | } else { 87 | e = ErrUnknown 88 | } 89 | } 90 | 91 | return fmt.Errorf("status %d: %w", status, e) 92 | } 93 | 94 | // New creates a client object 95 | func New() *Client { 96 | client := &Client{ 97 | ProductionURL: ProductionURL, 98 | SandboxURL: SandboxURL, 99 | httpCli: &http.Client{ 100 | Timeout: 10 * time.Second, 101 | }, 102 | } 103 | return client 104 | } 105 | 106 | // NewWithClient creates a client with a custom http client. 107 | func NewWithClient(client *http.Client) *Client { 108 | return &Client{ 109 | ProductionURL: ProductionURL, 110 | SandboxURL: SandboxURL, 111 | httpCli: client, 112 | } 113 | } 114 | 115 | // Verify sends receipts and gets validation result 116 | func (c *Client) Verify(ctx context.Context, reqBody IAPRequest, result interface{}) error { 117 | _, err := c.verify(ctx, reqBody, result) 118 | return err 119 | } 120 | 121 | // VerifyWithStatus sends receipts and gets validation result with status code 122 | // If the Apple verification receipt server is unhealthy and responds with an HTTP status code in the 5xx range, that status code will be returned. 123 | func (c *Client) VerifyWithStatus(ctx context.Context, reqBody IAPRequest, result interface{}) (int, error) { 124 | return c.verify(ctx, reqBody, result) 125 | } 126 | 127 | func (c *Client) verify(ctx context.Context, reqBody IAPRequest, result interface{}) (int, error) { 128 | b := new(bytes.Buffer) 129 | if err := json.NewEncoder(b).Encode(reqBody); err != nil { 130 | return 0, err 131 | } 132 | 133 | req, err := http.NewRequest("POST", c.ProductionURL, b) 134 | if err != nil { 135 | return 0, err 136 | } 137 | req.Header.Set("Content-Type", ContentType) 138 | req = req.WithContext(ctx) 139 | resp, err := c.httpCli.Do(req) 140 | if err != nil { 141 | return 0, err 142 | } 143 | defer resp.Body.Close() 144 | if resp.StatusCode >= 500 { 145 | return resp.StatusCode, fmt.Errorf("Received http status code %d from the App Store: %w", resp.StatusCode, ErrAppStoreServer) 146 | } 147 | return c.parseResponse(resp, result, ctx, reqBody) 148 | } 149 | 150 | func (c *Client) parseResponse(resp *http.Response, result interface{}, ctx context.Context, reqBody IAPRequest) (int, error) { 151 | // Read the body now so that we can unmarshal it twice 152 | buf, err := io.ReadAll(resp.Body) 153 | if err != nil { 154 | return 0, err 155 | } 156 | 157 | err = json.Unmarshal(buf, &result) 158 | if err != nil { 159 | return 0, err 160 | } 161 | 162 | // https://developer.apple.com/library/content/technotes/tn2413/_index.html#//apple_ref/doc/uid/DTS40016228-CH1-RECEIPTURL 163 | var r StatusResponse 164 | err = json.Unmarshal(buf, &r) 165 | if err != nil { 166 | return 0, err 167 | } 168 | if r.Status == 21007 { 169 | b := new(bytes.Buffer) 170 | if err := json.NewEncoder(b).Encode(reqBody); err != nil { 171 | return 0, err 172 | } 173 | 174 | req, err := http.NewRequest("POST", c.SandboxURL, b) 175 | if err != nil { 176 | return 0, err 177 | } 178 | req.Header.Set("Content-Type", ContentType) 179 | req = req.WithContext(ctx) 180 | resp, err := c.httpCli.Do(req) 181 | if err != nil { 182 | return 0, err 183 | } 184 | defer resp.Body.Close() 185 | if resp.StatusCode >= 500 { 186 | return resp.StatusCode, fmt.Errorf("Received http status code %d from the App Store Sandbox: %w", resp.StatusCode, ErrAppStoreServer) 187 | } 188 | 189 | return r.Status, json.NewDecoder(resp.Body).Decode(result) 190 | } 191 | 192 | return r.Status, nil 193 | } 194 | 195 | // ParseNotificationV2 parse notification from App Store Server 196 | func (c *Client) ParseNotificationV2(tokenStr string, result *jwt.Token) error { 197 | cert := Cert{} 198 | 199 | token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { 200 | return cert.ExtractPublicKeyFromToken(tokenStr) 201 | }) 202 | if token != nil { 203 | *result = *token 204 | } 205 | return err 206 | } 207 | 208 | // ParseNotificationV2WithClaim parse notification from App Store Server 209 | func (c *Client) ParseNotificationV2WithClaim(tokenStr string, result jwt.Claims) error { 210 | cert := Cert{} 211 | 212 | _, err := jwt.ParseWithClaims(tokenStr, result, func(token *jwt.Token) (interface{}, error) { 213 | return cert.ExtractPublicKeyFromToken(tokenStr) 214 | }) 215 | return err 216 | } 217 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/awa/go-iap 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/golang-jwt/jwt/v5 v5.2.2 7 | github.com/google/uuid v1.6.0 8 | github.com/stretchr/testify v1.10.0 9 | go.uber.org/mock v0.5.0 10 | golang.org/x/oauth2 v0.25.0 11 | google.golang.org/api v0.217.0 12 | ) 13 | 14 | require ( 15 | cloud.google.com/go/auth v0.14.0 // indirect 16 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 17 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/felixge/httpsnoop v1.0.4 // indirect 20 | github.com/go-logr/logr v1.4.2 // indirect 21 | github.com/go-logr/stdr v1.2.2 // indirect 22 | github.com/google/s2a-go v0.1.9 // indirect 23 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 24 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 27 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 28 | go.opentelemetry.io/otel v1.34.0 // indirect 29 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 30 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 31 | golang.org/x/crypto v0.37.0 // indirect 32 | golang.org/x/net v0.39.0 // indirect 33 | golang.org/x/sys v0.32.0 // indirect 34 | golang.org/x/text v0.24.0 // indirect 35 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect 36 | google.golang.org/grpc v1.69.4 // indirect 37 | google.golang.org/protobuf v1.36.3 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM= 2 | cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A= 3 | cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= 4 | cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= 5 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= 6 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 10 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 11 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 12 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 13 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 14 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 15 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 16 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 17 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 18 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 19 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 20 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 21 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 22 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 23 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 24 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 25 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 26 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= 27 | github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= 28 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= 29 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= 30 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 31 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 32 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 33 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 37 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 38 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 39 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 40 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 41 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 42 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= 43 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= 44 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 45 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 46 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 47 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 48 | go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= 49 | go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= 50 | go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= 51 | go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= 52 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 53 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 54 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 55 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 56 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 57 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 58 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 59 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 60 | golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= 61 | golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 62 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 63 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 64 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 65 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 66 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 67 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 68 | google.golang.org/api v0.217.0 h1:GYrUtD289o4zl1AhiTZL0jvQGa2RDLyC+kX1N/lfGOU= 69 | google.golang.org/api v0.217.0/go.mod h1:qMc2E8cBAbQlRypBTBWHklNJlaZZJBwDv81B1Iu8oSI= 70 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= 71 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= 72 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= 73 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= 74 | google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= 75 | google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 76 | google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= 77 | google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 78 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 79 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 80 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 81 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 82 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 83 | -------------------------------------------------------------------------------- /hms/client.go: -------------------------------------------------------------------------------- 1 | package hms 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | // HMS OAuth url 18 | const tokenURL = "https://oauth-login.cloud.huawei.com/oauth2/v3/token" 19 | 20 | // AccessToken expires grace period in seconds. 21 | // The actural ExpiredAt will be substracted with this number to avoid boundray problems. 22 | const accessTokenExpiresGracePeriod = 60 23 | 24 | // global variable to store API AccessToken. 25 | // All clients within an instance share one AccessToken grantee scalebility and to avoid rate limit. 26 | var applicationAccessTokens = make(map[[16]byte]ApplicationAccessToken) 27 | 28 | // lock when writing to applicationAccessTokens map 29 | var applicationAccessTokensLock sync.Mutex 30 | 31 | // ApplicationAccessToken model, received from HMS OAuth API 32 | // https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/open-platform-oauth-0000001050123437#EN-US_TOPIC_0000001050123437__section12493191334711 33 | type ApplicationAccessToken struct { 34 | // App-level access token. 35 | AccessToken string `json:"access_token"` 36 | 37 | // Remaining validity period of an access token, in seconds. 38 | ExpiresIn int64 `json:"expires_in"` 39 | // This value is always Bearer, indicating the type of the returned access token. 40 | // TokenType string `json:"token_type"` 41 | 42 | // Save the timestamp when AccessToken is obtained 43 | ExpiredAt int64 `json:"-"` 44 | 45 | // Request header string 46 | HeaderString string `json:"-"` 47 | } 48 | 49 | // Client implements VerifySignature, VerifyOrder and VerifySubscription methods 50 | type Client struct { 51 | clientID string 52 | clientSecret string 53 | clientIDSecretHash [16]byte 54 | httpCli *http.Client 55 | orderSiteURL string // site URL to request order information 56 | subscriptionSiteURL string // site URL to request subscription information 57 | } 58 | 59 | // New returns client with credentials. 60 | // Required client_id and client_secret which could be acquired from the HMS API Console. 61 | // When user accountFlag is not equals to 1, orderSiteURL/subscriptionSiteURL are the site URLs that will be used to connect to HMS IAP API services. 62 | // If orderSiteURL or subscriptionSiteURL are not set, default to AppTouch Germany site. 63 | // 64 | // Please refer https://developer.huawei.com/consumer/en/doc/start/api-console-guide 65 | // and https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-common-statement-0000001050986127 for details. 66 | func New(clientID, clientSecret, orderSiteURL, subscriptionSiteURL string) *Client { 67 | // Set default order / subscription iap site to AppTouch Germany if it is not provided 68 | if !strings.HasPrefix(orderSiteURL, "http") { 69 | orderSiteURL = "https://orders-at-dre.iap.dbankcloud.com" 70 | } 71 | if !strings.HasPrefix(subscriptionSiteURL, "http") { 72 | subscriptionSiteURL = "https://subscr-at-dre.iap.dbankcloud.com" 73 | } 74 | 75 | // Create http client 76 | return &Client{ 77 | clientID: clientID, 78 | clientSecret: clientSecret, 79 | clientIDSecretHash: md5.Sum([]byte(clientID + clientSecret)), 80 | httpCli: &http.Client{ 81 | Timeout: 10 * time.Second, 82 | }, 83 | orderSiteURL: orderSiteURL, 84 | subscriptionSiteURL: subscriptionSiteURL, 85 | } 86 | } 87 | 88 | // GetApplicationAccessTokenHeader obtain OAuth AccessToken from HMS 89 | // 90 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/atdemo.go#L37 91 | func (c *Client) GetApplicationAccessTokenHeader() (string, error) { 92 | // To complie with the rate limit (1000/5min as of July 24th, 2020) 93 | // new AccessTokens are requested only when it is expired. 94 | // Please refer https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/open-platform-oauth-0000001050123437 for detailes 95 | if applicationAccessTokens[c.clientIDSecretHash].ExpiredAt > time.Now().Unix() { 96 | return applicationAccessTokens[c.clientIDSecretHash].HeaderString, nil 97 | } 98 | 99 | urlValue := url.Values{"grant_type": {"client_credentials"}, "client_secret": {c.clientSecret}, "client_id": {c.clientID}} 100 | resp, err := c.httpCli.PostForm(tokenURL, urlValue) 101 | if err != nil { 102 | return "", err 103 | } 104 | defer resp.Body.Close() 105 | bodyBytes, err := io.ReadAll(resp.Body) 106 | if err != nil { 107 | return "", err 108 | } 109 | var atResponse ApplicationAccessToken 110 | err = json.Unmarshal(bodyBytes, &atResponse) 111 | if err != nil { 112 | return "", err 113 | } 114 | if atResponse.AccessToken != "" { 115 | // update expire time 116 | atResponse.ExpiredAt = atResponse.ExpiresIn + time.Now().Unix() - accessTokenExpiresGracePeriod 117 | // parse request header string 118 | atResponse.HeaderString = fmt.Sprintf( 119 | "Basic %s", 120 | base64.StdEncoding.EncodeToString([]byte( 121 | fmt.Sprintf("APPAT:%s", 122 | atResponse.AccessToken, 123 | ), 124 | )), 125 | ) 126 | // save AccessToken info to global variable 127 | applicationAccessTokensLock.Lock() 128 | applicationAccessTokens[c.clientIDSecretHash] = atResponse 129 | applicationAccessTokensLock.Unlock() 130 | return atResponse.HeaderString, nil 131 | } 132 | return "", errors.New("Get token fail, " + string(bodyBytes)) 133 | } 134 | 135 | // Returns root order URL by flag, prefixing with "https://" 136 | func (c *Client) getRootOrderURLByFlag(flag int64) string { 137 | switch flag { 138 | case 1: 139 | return "https://orders-drcn.iap.cloud.huawei.com.cn" 140 | case 2: 141 | return "https://orders-dre.iap.cloud.huawei.eu" 142 | case 3: 143 | return "https://orders-dra.iap.cloud.huawei.asia" 144 | case 4: 145 | return "https://orders-drru.iap.cloud.huawei.ru" 146 | } 147 | return c.orderSiteURL 148 | } 149 | 150 | // Returns root subscription URL by flag, prefixing with "https://" 151 | func (c *Client) getRootSubscriptionURLByFlag(flag int64) string { 152 | switch flag { 153 | case 1: 154 | return "https://subscr-drcn.iap.cloud.huawei.com.cn" 155 | case 2: 156 | return "https://subscr-dre.iap.cloud.huawei.eu" 157 | case 3: 158 | return "https://subscr-dra.iap.cloud.huawei.asia" 159 | case 4: 160 | return "https://subscr-drru.iap.cloud.huawei.ru" 161 | } 162 | return c.subscriptionSiteURL 163 | } 164 | 165 | // get error based on result code returned from api 166 | func (c *Client) getResponseErrorByCode(code string) error { 167 | switch code { 168 | case "0": 169 | return nil 170 | case "5": 171 | return ErrorResponseInvalidParameter 172 | case "6": 173 | return ErrorResponseCritical 174 | case "8": 175 | return ErrorResponseProductNotBelongToUser 176 | case "9": 177 | return ErrorResponseConsumedProduct 178 | case "11": 179 | return ErrorResponseAbnormalUserAccount 180 | default: 181 | return ErrorResponseUnknown 182 | } 183 | } 184 | 185 | // Errors 186 | 187 | // ErrorResponseUnknown error placeholder for undocumented errors 188 | var ErrorResponseUnknown error = errors.New("Unknown error from API response") 189 | 190 | // ErrorResponseSignatureVerifyFailed failed to verify dataSignature against the response json string. 191 | // https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/verifying-signature-returned-result-0000001050033088 192 | // var ErrorResponseSignatureVerifyFailed error = errors.New("Failed to verify dataSignature against the response json string") 193 | 194 | // ErrorResponseInvalidParameter The parameter passed to the API is invalid. 195 | // This error may also indicate that an agreement is not signed or parameters are not set correctly for the in-app purchase settlement in HUAWEI IAP, or the required permission is not in the list. 196 | // 197 | // Check whether the parameter passed to the API is correctly set. If so, check whether required settings in HUAWEI IAP are correctly configured. 198 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 199 | var ErrorResponseInvalidParameter error = errors.New("The parameter passed to the API is invalid") 200 | 201 | // ErrorResponseCritical A critical error occurs during API operations. 202 | // 203 | // Rectify the fault based on the error information in the response. If the fault persists, contact Huawei technical support. 204 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 205 | var ErrorResponseCritical error = errors.New("A critical error occurs during API operations") 206 | 207 | // ErrorResponseProductNotBelongToUser A user failed to consume or confirm a product because the user does not own the product. 208 | // 209 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 210 | var ErrorResponseProductNotBelongToUser error = errors.New("A user failed to consume or confirm a product because the user does not own the product") 211 | 212 | // ErrorResponseConsumedProduct The product cannot be consumed or confirmed because it has been consumed or confirmed. 213 | // 214 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 215 | var ErrorResponseConsumedProduct error = errors.New("The product cannot be consumed or confirmed because it has been consumed or confirmed") 216 | 217 | // ErrorResponseAbnormalUserAccount The user account is abnormal, for example, the user has been deregistered. 218 | // 219 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 220 | var ErrorResponseAbnormalUserAccount error = errors.New("The user account is abnormal, for example, the user has been deregistered") 221 | -------------------------------------------------------------------------------- /hms/modifier.go: -------------------------------------------------------------------------------- 1 | package hms 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | ) 9 | 10 | // ConfirmPurchases gets subscriptions info with subscriptionId and purchaseToken. 11 | // This API is used to notify the Huawei IAP server to update the delivery status of a consumable after it is successfully delivered. If no notification is sent, the consumable cannot be purchased again. 12 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-purchase-confirm-for-order-service-0000001051066054 13 | func (c *Client) ConfirmPurchases(ctx context.Context, purchaseToken, productID string, accountFlag int64) (success bool, responseMessage string, err error) { 14 | bodyMap := map[string]string{ 15 | "productId": productID, 16 | "purchaseToken": purchaseToken, 17 | } 18 | var resp ModifySubscriptionResponse 19 | success, resp, err = c.modifySubscriptionQuery(ctx, bodyMap, accountFlag, "/applications/v2/purchases/confirm") 20 | responseMessage = resp.ResponseMessage 21 | return 22 | } 23 | 24 | // CancelSubscriptionRenewal Cancel a aubscription from auto-renew when expired. 25 | // Note that this does not cancel the current subscription. 26 | // If you want to revoke a subscription, use Client.RevokeSubscription() instead. 27 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/subscription.go#L54 28 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-cancel-subscription-0000001050746115 29 | func (c *Client) CancelSubscriptionRenewal(ctx context.Context, purchaseToken, subscriptionID string, accountFlag int64) (success bool, responseMessage string, err error) { 30 | bodyMap := map[string]string{ 31 | "subscriptionId": subscriptionID, 32 | "purchaseToken": purchaseToken, 33 | } 34 | var resp ModifySubscriptionResponse 35 | success, resp, err = c.modifySubscriptionQuery(ctx, bodyMap, accountFlag, "/sub/applications/v2/purchases/stop") 36 | responseMessage = resp.ResponseMessage 37 | return 38 | } 39 | 40 | // DelaySubscription extend the current subscription expiration date without chanrging the customer. 41 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/subscription.go#L68 42 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-delayed-settlement-0000001050706082 43 | func (c *Client) DelaySubscription(ctx context.Context, purchaseToken, subscriptionID string, currentExpirationTime, desiredExpirationTime int64, accountFlag int64) (success bool, responseMessage string, newExpirationTime int64, err error) { 44 | bodyMap := map[string]string{ 45 | "subscriptionId": subscriptionID, 46 | "purchaseToken": purchaseToken, 47 | "currentExpirationTime": fmt.Sprintf("%v", currentExpirationTime), 48 | "desiredExpirationTime": fmt.Sprintf("%v", desiredExpirationTime), 49 | } 50 | var resp ModifySubscriptionResponse 51 | success, resp, err = c.modifySubscriptionQuery(ctx, bodyMap, accountFlag, "/sub/applications/v2/purchases/delay") 52 | responseMessage = resp.ResponseMessage 53 | newExpirationTime = resp.NewExpirationTime 54 | return 55 | } 56 | 57 | // RefundSubscription refund a subscription payment. 58 | // Note that this does not cancel the current subscription. 59 | // If you want to revoke a subscription, use Client.RevokeSubscription() instead. 60 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/subscription.go#L84 61 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-refund-subscription-fee-0000001050986131 62 | func (c *Client) RefundSubscription(ctx context.Context, purchaseToken, subscriptionID string, accountFlag int64) (success bool, responseMessage string, err error) { 63 | bodyMap := map[string]string{ 64 | "subscriptionId": subscriptionID, 65 | "purchaseToken": purchaseToken, 66 | } 67 | var resp ModifySubscriptionResponse 68 | success, resp, err = c.modifySubscriptionQuery(ctx, bodyMap, accountFlag, "/sub/applications/v2/purchases/returnFee") 69 | responseMessage = resp.ResponseMessage 70 | return 71 | } 72 | 73 | // RevokeSubscription will revoke and issue a refund on a subscription immediately. 74 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/subscription.go#L99 75 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-unsubscribe-0000001051066056 76 | func (c *Client) RevokeSubscription(ctx context.Context, purchaseToken, subscriptionID string, accountFlag int64) (success bool, responseMessage string, err error) { 77 | bodyMap := map[string]string{ 78 | "subscriptionId": subscriptionID, 79 | "purchaseToken": purchaseToken, 80 | } 81 | var resp ModifySubscriptionResponse 82 | success, resp, err = c.modifySubscriptionQuery(ctx, bodyMap, accountFlag, "/sub/applications/v2/purchases/withdrawal") 83 | responseMessage = resp.ResponseMessage 84 | return 85 | } 86 | 87 | // ModifySubscriptionResponse JSON response from {rootUrl}/sub/applications/v2/purchases/stop|delay|returnFee|withdrawal 88 | type ModifySubscriptionResponse struct { 89 | ResponseCode string `json:"responseCode"` 90 | ResponseMessage string `json:"responseMessage,omitempty"` 91 | NewExpirationTime int64 `json:"newExpirationTime,omitempty"` 92 | } 93 | 94 | // public method to query {rootUrl}/sub/applications/v2/purchases/stop|delay|returnFee|withdrawal 95 | func (c *Client) modifySubscriptionQuery(ctx context.Context, requestBodyMap map[string]string, accountFlag int64, uri string) (success bool, response ModifySubscriptionResponse, err error) { 96 | url := c.getRootSubscriptionURLByFlag(accountFlag) + uri 97 | 98 | bodyBytes, err := c.sendJSONRequest(ctx, url, requestBodyMap) 99 | if err != nil { 100 | return false, response, err 101 | } 102 | 103 | // debug 104 | log.Println("url:", url) 105 | log.Println("request:", requestBodyMap) 106 | log.Printf("%s", bodyBytes) 107 | 108 | if err := json.Unmarshal(bodyBytes, &response); err != nil { 109 | return false, response, err 110 | } 111 | 112 | switch response.ResponseCode { 113 | case "0": 114 | return true, response, nil 115 | default: 116 | return false, response, c.getResponseErrorByCode(response.ResponseCode) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /hms/notification.go: -------------------------------------------------------------------------------- 1 | package hms 2 | 3 | // SubscriptionNotification Request parameters when a developer server is called by HMS API. 4 | // 5 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-notifications-about-subscription-events-0000001050706084 6 | type SubscriptionNotification struct { 7 | // Notification message, which is a JSON string. For details, please refer to statusUpdateNotification. 8 | StatusUpdateNotification string `json:"statusUpdateNotification"` 9 | 10 | // Signature string for the StatusUpdateNotification parameter. The signature algorithm is SHA256withRSA. 11 | // 12 | // After your server receives the signature string, you need to use the public payment key to verify the signature of StatusUpdateNotification in JSON format. 13 | // For details, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/verifying-signature-returned-result-0000001050033088 14 | // 15 | // For details about how to obtain the public key, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/query-payment-info-0000001050166299 16 | NotifycationSignature string `json:"notifycationSignature"` 17 | 18 | // Notification service version, which is set to v2. 19 | Version string `json:"version,omitempty"` 20 | 21 | // Signature algorithm 22 | SignatureAlgorithm string `json:"signatureAlgorithm,omitempty"` 23 | } 24 | 25 | // StatusUpdateNotification JSON content when unmarshal NotificationRequest.StatusUpdateNotification 26 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-notifications-about-subscription-events-0000001050706084#EN-US_TOPIC_0000001050706084__section18290165220716 27 | type StatusUpdateNotification struct { 28 | // Environment for sending a notification. Value could be one of either: 29 | // "PROD": general production environment 30 | // "SandBox": sandbox testing environment 31 | Environment string `json:"environment"` 32 | 33 | // Notification event type. For details, please refer to const NotificationTypeInitialBuy etc. 34 | NotificationType int64 `json:"notificationType"` 35 | 36 | // Subscription ID 37 | SubscriptionID string `json:"subscriptionId"` 38 | 39 | // Subscription token, which matches a unique subscription ID. 40 | PurchaseToken string `json:"purchaseToken"` 41 | 42 | // Timestamp, which is passed only when notificationType is CANCEL(1). 43 | CancellationDate int64 `json:"cancellationDate,omitempty"` 44 | 45 | // Order ID used for payment during subscription renewal. 46 | OrderID string `json:"orderId"` 47 | 48 | // PurchaseToken of the latest receipt, which is passed only when notificationType is INITIAL_BUY(0), RENEWAL(2), or INTERACTIVE_RENEWAL(3) and the renewal is successful. 49 | LatestReceipt string `json:"latestReceipt,omitempty"` 50 | 51 | // Latest receipt, which is a JSON string. This parameter is left empty when notificationType is CANCEL(1). 52 | // For details about the parameters contained, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-data-model-0000001050986133#EN-US_TOPIC_0000001050986133__section264617465219 53 | LatestReceiptInfo string `json:"latestReceiptInfo,omitempty"` 54 | 55 | // Signature string for the LatestReceiptInfo parameter. The signature algorithm is SHA256withRSA. 56 | // 57 | // After your server receives the signature string, you need to use the public payment key to verify the signature of LatestReceiptInfo in JSON format. 58 | // For details, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/verifying-signature-returned-result-00000010500330885 59 | // 60 | // For details about how to obtain the public key, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/query-payment-info-0000001050166299 61 | LatestReceiptInfoSignature string `json:"latestReceiptInfoSignature,omitempty"` 62 | 63 | // Token of the latest expired receipt. This parameter has a value only when NotificationType is RENEWAL(2) or INTERACTIVE_RENEWAL(3). 64 | LatestExpiredReceipt string `json:"latestExpiredReceipt,omitempty"` 65 | 66 | // Latest expired receipt, which is a JSON string. This parameter has a value only when NotificationType is RENEWAL(2) or INTERACTIVE_RENEWAL(3). 67 | LatestExpiredReceiptInfo string `json:"latestExpiredReceiptInfo,omitempty"` 68 | 69 | // Signature string for the LatestExpiredReceiptInfo parameter. The signature algorithm is SHA256withRSA. 70 | // 71 | // After your server receives the signature string, you need to use the public payment key to verify the signature of LatestExpiredReceiptInfo in JSON format. 72 | // For details, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/verifying-signature-returned-result-0000001050033088 73 | // 74 | // For details about how to obtain the public key, please refer to https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/query-payment-info-0000001050166299 75 | LatestExpiredReceiptInfoSignature string `json:"latestExpiredReceiptInfoSignature,omitempty"` 76 | 77 | // Signature algorithm 78 | SignatureAlgorithm string `json:"signatureAlgorithm,omitempty"` 79 | 80 | // Renewal status. Value could be one of either: 81 | // 1: The subscription renewal is normal. 82 | // 0: The user has canceled subscription renewal. 83 | AutoRenewStatus int64 `json:"autoRenewStatus"` 84 | 85 | // Refund order ID. This parameter has a value only when NotificationType is CANCEL(1). 86 | RefundPayOrderID string `json:"refundPayOrderId,omitempty"` 87 | 88 | // Product ID. 89 | ProductID string `json:"productId"` 90 | 91 | // App ID. 92 | ApplicationID string `json:"applicationId,omitempty"` 93 | 94 | // Expiration reason. This parameter has a value only when NotificationType is RENEWAL(2) or INTERACTIVE_RENEWAL(3), and the renewal is successful. 95 | ExpirationIntent int64 `json:"expirationIntent,omitempty"` 96 | } 97 | 98 | // Constants for StatusUpdateNotification.NotificationType 99 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-notifications-about-subscription-events-0000001050706084#EN-US_TOPIC_0000001050706084__section18290165220716 100 | const ( 101 | NotificationTypeInitialBuy int64 = 0 102 | NotificationTypeCancel int64 = 1 103 | NotificationTypeRenewal int64 = 2 104 | NotificationTypeInteractiveRenewal int64 = 3 105 | NotificationTypeNewRenewalPref int64 = 4 106 | NotificationTypeRenewalStopped int64 = 5 107 | NotificationTypeRenewalRestored int64 = 6 108 | NotificationTypeRenewalRecurring int64 = 7 109 | NotificationTypeInGracePeriod int64 = 8 110 | NotificationTypeOnHold int64 = 9 111 | NotificationTypePaused int64 = 10 112 | NotificationTypePausePlanChanged int64 = 11 113 | NotificationTypePriceChangeConfirmed int64 = 12 114 | NotificationTypeDeferred int64 = 13 115 | ) 116 | -------------------------------------------------------------------------------- /hms/notification_v2.go: -------------------------------------------------------------------------------- 1 | package hms 2 | 3 | // SubscriptionNotificationV2 Request parameters when a developer server is called by HMS API. 4 | // 5 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-notifications-about-subscription-events-v2-0000001385268541 6 | type SubscriptionNotificationV2 struct { 7 | // Notification service version, which is set to v2. 8 | Version string `json:"version,omitempty"` 9 | 10 | //Notification type. The value can be: 11 | //ORDER: order 12 | //SUBSCRIPTION: subscription 13 | EventType string `json:"eventType,omitempty"` 14 | 15 | //Timestamp of the time when a notification is sent (in UTC), which is the number of milliseconds from 00:00:00 on January 1, 1970 to the time when the notification is sent. 16 | NotifyTime int64 `json:"notifyTime,omitempty"` 17 | 18 | //App ID. 19 | ApplicationID string `json:"applicationId,omitempty"` 20 | // Content of an order notification, which is returned when eventType is ORDER. 21 | OrderNotification OrderNotification `json:"orderNotification,omitempty"` 22 | 23 | //Content of a subscription notification, which is returned when eventType is SUBSCRIPTION. 24 | SubNotification SubNotification `json:"subNotification,omitempty"` 25 | } 26 | 27 | // OrderNotification JSON content when unmarshal NotificationRequest.OrderNotification 28 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-notifications-about-subscription-events-v2-0000001385268541 29 | type OrderNotification struct { 30 | //Notification service version, which is set to v2. 31 | Version string `json:"version,omitempty"` 32 | 33 | // Notification type. The value can be: 34 | //1: successful payment 35 | //2: successful refund 36 | NotificationType int64 `json:"notificationType"` 37 | 38 | // Subscription token, which matches a unique subscription ID. 39 | PurchaseToken string `json:"purchaseToken"` 40 | 41 | // Product ID. 42 | ProductID string `json:"productId"` 43 | } 44 | 45 | // SubNotification JSON content when unmarshal NotificationRequest.SubNotification 46 | // https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-notifications-about-subscription-events-v2-0000001385268541 47 | type SubNotification struct { 48 | //Notification service version, which is set to v2. 49 | Version string `json:"version,omitempty"` 50 | 51 | //Notification message, in JSON format. For details, please refer to statusUpdateNotification. 52 | StatusUpdateNotification string `json:"statusUpdateNotification"` 53 | 54 | // Signature string of the statusUpdateNotification field. Find the signature algorithm from the value of signatureAlgorithm. 55 | //After your server receives the signature string, you need to use the IAP public key to verify the signature of statusUpdateNotification (in JSON format). For details, please refer to Verifying the Signature in the Returned Result. 56 | //For details about how to obtain the public key, please refer to Querying IAP Information. 57 | NotificationSignature string `json:"notificationSignature"` 58 | 59 | //Signature algorithm. 60 | SignatureAlgorithm string `json:"signatureAlgorithm"` 61 | } 62 | -------------------------------------------------------------------------------- /hms/validator.go: -------------------------------------------------------------------------------- 1 | package hms 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto" 7 | "crypto/rsa" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "encoding/base64" 11 | "encoding/json" 12 | "fmt" 13 | "io" 14 | "net/http" 15 | "time" 16 | ) 17 | 18 | // VerifySignature validate inapp order or subscription data signature. Returns nil if pass. 19 | // 20 | // Document: https://developer.huawei.com/consumer/en/doc/development/HMSCore-Guides/verifying-signature-returned-result-0000001050033088 21 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/demo.go#L60 22 | func VerifySignature(base64EncodedPublicKey string, data string, signature string) (err error) { 23 | publicKeyByte, err := base64.StdEncoding.DecodeString(base64EncodedPublicKey) 24 | if err != nil { 25 | return err 26 | } 27 | pub, err := x509.ParsePKIXPublicKey(publicKeyByte) 28 | if err != nil { 29 | return err 30 | } 31 | hashed := sha256.Sum256([]byte(data)) 32 | signatureByte, err := base64.StdEncoding.DecodeString(signature) 33 | if err != nil { 34 | return err 35 | } 36 | return rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, hashed[:], signatureByte) 37 | } 38 | 39 | // SubscriptionVerifyResponse JSON response after requested {rootUrl}/sub/applications/v2/purchases/get 40 | type SubscriptionVerifyResponse struct { 41 | ResponseCode string `json:"responseCode"` // Response code, if = "0" means succeed, for others see https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 42 | ResponseMessage string `json:"responseMessage,omitempty"` // Response descriptions, especially when error 43 | InappPurchaseData string `json:"inappPurchaseData,omitempty"` // InappPurchaseData JSON string 44 | } 45 | 46 | // VerifySubscription gets subscriptions info with subscriptionId and purchaseToken. 47 | // 48 | // Document: https://developer.huawei.com/consumer/en/doc/development/HMSCore-References/api-subscription-verify-purchase-token-0000001050706080 49 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/subscription.go#L40 50 | func (c *Client) VerifySubscription(ctx context.Context, purchaseToken, subscriptionID string, accountFlag int64) (InAppPurchaseData, error) { 51 | var iap InAppPurchaseData 52 | 53 | dataString, err := c.GetSubscriptionDataString(ctx, purchaseToken, subscriptionID, accountFlag) 54 | if err != nil { 55 | return iap, err 56 | } 57 | 58 | if err := json.Unmarshal([]byte(dataString), &iap); err != nil { 59 | return iap, err 60 | } 61 | 62 | return iap, nil 63 | } 64 | 65 | // GetSubscriptionDataString gets subscriptions response data string. 66 | // 67 | // Document: https://developer.huawei.com/consumer/en/doc/development/HMSCore-References/api-subscription-verify-purchase-token-0000001050706080 68 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/subscription.go#L40 69 | func (c *Client) GetSubscriptionDataString(ctx context.Context, purchaseToken, subscriptionID string, accountFlag int64) (string, error) { 70 | bodyMap := map[string]string{ 71 | "subscriptionId": subscriptionID, 72 | "purchaseToken": purchaseToken, 73 | } 74 | url := c.getRootSubscriptionURLByFlag(accountFlag) + "/sub/applications/v2/purchases/get" 75 | 76 | bodyBytes, err := c.sendJSONRequest(ctx, url, bodyMap) 77 | if err != nil { 78 | // log.Printf("GetSubscriptionDataString(): Encounter error: %s", err) 79 | return "", err 80 | } 81 | 82 | var resp SubscriptionVerifyResponse 83 | if err := json.Unmarshal(bodyBytes, &resp); err != nil { 84 | return "", err 85 | } 86 | if err := c.getResponseErrorByCode(resp.ResponseCode); err != nil { 87 | return "", err 88 | } 89 | 90 | return resp.InappPurchaseData, nil 91 | } 92 | 93 | // OrderVerifyResponse JSON response from {rootUrl}/applications/purchases/tokens/verify 94 | type OrderVerifyResponse struct { 95 | ResponseCode string `json:"responseCode"` // Response code, if = "0" means succeed, for others see https://developer.huawei.com/consumer/en/doc/HMSCore-References/server-error-code-0000001050166248 96 | ResponseMessage string `json:"responseMessage,omitempty"` // Response descriptions, especially when error 97 | PurchaseTokenData string `json:"purchaseTokenData,omitempty"` // InappPurchaseData JSON string 98 | DataSignature string `json:"dataSignature,omitempty"` // Signature to verify PurchaseTokenData string 99 | } 100 | 101 | // VerifyOrder gets order (single item purchase) info with productId and purchaseToken. 102 | // 103 | // Note that this method does not verify the DataSignature, thus security is relied on HTTPS solely. 104 | // 105 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-order-verify-purchase-token-0000001050746113 106 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/order.go#L41 107 | func (c *Client) VerifyOrder(ctx context.Context, purchaseToken, productID string, accountFlag int64) (InAppPurchaseData, error) { 108 | var iap InAppPurchaseData 109 | 110 | dataString, _, err := c.GetOrderDataString(ctx, purchaseToken, productID, accountFlag) 111 | if err != nil { 112 | return iap, err 113 | } 114 | 115 | if err := json.Unmarshal([]byte(dataString), &iap); err != nil { 116 | return iap, err 117 | } 118 | 119 | return iap, nil 120 | } 121 | 122 | // GetOrderDataString gets order (single item purchase) response data as json string and dataSignature 123 | // 124 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-order-verify-purchase-token-0000001050746113 125 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/order.go#L41 126 | func (c *Client) GetOrderDataString(ctx context.Context, purchaseToken, productID string, accountFlag int64) (purchaseTokenData, dataSignature string, err error) { 127 | bodyMap := map[string]string{ 128 | "purchaseToken": purchaseToken, 129 | "productId": productID, 130 | } 131 | url := c.getRootOrderURLByFlag(accountFlag) + "/applications/purchases/tokens/verify" 132 | 133 | bodyBytes, err := c.sendJSONRequest(ctx, url, bodyMap) 134 | if err != nil { 135 | // log.Printf("GetOrderDataString(): Encounter error: %s", err) 136 | return "", "", err 137 | } 138 | 139 | var resp OrderVerifyResponse 140 | if err = json.Unmarshal(bodyBytes, &resp); err != nil { 141 | return "", "", err 142 | } 143 | if err = c.getResponseErrorByCode(resp.ResponseCode); err != nil { 144 | return "", "", err 145 | } 146 | 147 | return resp.PurchaseTokenData, resp.DataSignature, nil 148 | } 149 | 150 | // Helper function to send http json request and get response bodyBytes. 151 | // 152 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/demo.go#L33 153 | func (c *Client) sendJSONRequest(ctx context.Context, url string, bodyMap map[string]string) (bodyBytes []byte, err error) { 154 | bodyString, err := json.Marshal(bodyMap) 155 | if err != nil { 156 | return 157 | } 158 | 159 | req, err := http.NewRequest("POST", url, bytes.NewReader(bodyString)) 160 | if err != nil { 161 | return 162 | } 163 | req = req.WithContext(ctx) 164 | req.Header.Set("Content-Type", "application/json; charset=UTF-8") 165 | atHeader, err := c.GetApplicationAccessTokenHeader() 166 | if err == nil { 167 | req.Header.Set("Authorization", atHeader) 168 | } else { 169 | return 170 | } 171 | 172 | resp, err := c.httpCli.Do(req) 173 | if err != nil { 174 | return 175 | } 176 | defer resp.Body.Close() 177 | 178 | bodyBytes, err = io.ReadAll(resp.Body) 179 | if err != nil { 180 | return 181 | } 182 | return 183 | } 184 | 185 | // GetCanceledOrRefundedPurchases gets all revoked purchases in CanceledPurchaseList{}. 186 | // This method allow fetch over 1000 results regardles the cap implied by HMS API. Though you should still limit maxRows to a certain number to increate preformance. 187 | // 188 | // In case of an error, this method might return some fetch results if maxRows greater than 1000 or equals 0. 189 | // 190 | // Source code originated from https://github.com/HMS-Core/hms-iap-serverdemo/blob/92241f97fed1b68ddeb7cb37ea4ca6e6d33d2a87/demo/order.go#L52 191 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-cancel-or-refund-record-0000001050746117 192 | func (c *Client) GetCanceledOrRefundedPurchases( 193 | // context of request 194 | ctx context.Context, 195 | 196 | // start time timestamp in milliseconds, if =0, will default to 1 month ago. 197 | startAt int64, 198 | 199 | // end time timestamp in milliseconds, if =0, will default to now. 200 | endAt int64, 201 | 202 | // rows to return. default to 1000 if maxRows>1000 or equals to 0. 203 | maxRows int, 204 | 205 | // Token returned in the last query to query the data on the next page. 206 | continuationToken string, 207 | 208 | // Query type. Ignore this parameter when continuationToken is passed. The options are as follows: 209 | // 0: Queries purchase information about consumables and non-consumables. This is the default value. 210 | // 1: Queries all purchase information about consumables, non-consumables, and subscriptions. 211 | productType int64, 212 | 213 | // Account flag to determine which API URL to use. 214 | accountFlag int64, 215 | ) (canceledPurchases []CanceledPurchase, newContinuationToken string, responseCode string, responseMessage string, err error) { 216 | // default values 217 | if maxRows > 1000 || maxRows < 1 { 218 | maxRows = 1000 219 | } 220 | 221 | switch endAt { 222 | case 0: 223 | endAt = time.Now().UnixNano() / 1000000 224 | case startAt: 225 | endAt++ 226 | } 227 | 228 | bodyMap := map[string]string{ 229 | "startAt": fmt.Sprintf("%v", startAt), 230 | "endAt": fmt.Sprintf("%v", endAt), 231 | "maxRows": fmt.Sprintf("%v", maxRows), 232 | "continuationToken": continuationToken, 233 | "type": fmt.Sprintf("%v", productType), 234 | } 235 | 236 | url := c.getRootOrderURLByFlag(accountFlag) + "/applications/v2/purchases/cancelledList" 237 | var bodyBytes []byte 238 | bodyBytes, err = c.sendJSONRequest(ctx, url, bodyMap) 239 | if err != nil { 240 | // log.Printf("GetCanceledOrRefundedPurchases(): Encounter error: %s", err) 241 | return 242 | } 243 | 244 | var cpl CanceledPurchaseList // temporary variable to store api query result 245 | err = json.Unmarshal(bodyBytes, &cpl) 246 | if err != nil { 247 | return canceledPurchases, continuationToken, cpl.ResponseCode, cpl.ResponseMessage, err 248 | } 249 | if cpl.ResponseCode != "0" { 250 | return canceledPurchases, continuationToken, cpl.ResponseCode, cpl.ResponseMessage, c.getResponseErrorByCode(cpl.ResponseCode) 251 | } 252 | 253 | err = json.Unmarshal([]byte(cpl.CancelledPurchaseList), &canceledPurchases) 254 | if err != nil { 255 | return canceledPurchases, continuationToken, cpl.ResponseCode, cpl.ResponseMessage, err 256 | } 257 | 258 | return canceledPurchases, cpl.ContinuationToken, cpl.ResponseCode, cpl.ResponseMessage, nil 259 | } 260 | 261 | // GetMerchantQueryPurchases gets all revoked purchases in OrderInfoList{}. 262 | // This method allow fetch over 1000 results regardles the cap implied by HMS API. Though you should still limit maxRows to a certain number to increate preformance. 263 | // 264 | // In case of an error, this method might return some fetch results if maxRows greater than 1000 or equals 0. 265 | // 266 | // Document: https://developer.huawei.com/consumer/en/doc/HMSCore-References/api-cancel-or-refund-record-0000001050746117 267 | func (c *Client) GetMerchantQueryPurchases( 268 | // context of request 269 | ctx context.Context, 270 | 271 | // start time timestamp in milliseconds, if =0, will default to 1 month ago. 272 | startAt int64, 273 | 274 | // end time timestamp in milliseconds, if =0, will default to now. 275 | endAt int64, 276 | 277 | // Token returned in the last query to query the data on the next page. 278 | continuationToken string, 279 | // Account flag to determine which API URL to use. 280 | accountFlag int64, 281 | ) (canceledPurchases []OrderInfoList, newContinuationToken string, responseCode string, responseMessage string, err error) { 282 | 283 | switch endAt { 284 | case 0: 285 | endAt = time.Now().UnixNano() / 1000000 286 | case startAt: 287 | endAt++ 288 | } 289 | 290 | bodyMap := map[string]string{ 291 | "startAt": fmt.Sprintf("%v", startAt), 292 | "endAt": fmt.Sprintf("%v", endAt), 293 | "continuationToken": continuationToken, 294 | } 295 | 296 | url := c.getRootOrderURLByFlag(accountFlag) + "/applications/v1/merchantQuery" 297 | var bodyBytes []byte 298 | bodyBytes, err = c.sendJSONRequest(ctx, url, bodyMap) 299 | if err != nil { 300 | // log.Printf("GetCanceledOrRefundedPurchases(): Encounter error: %s", err) 301 | return 302 | } 303 | 304 | var cpl MerchantPurchaseList // temporary variable to store api query result 305 | err = json.Unmarshal(bodyBytes, &cpl) 306 | if err != nil { 307 | return canceledPurchases, continuationToken, cpl.ResponseCode, cpl.ResponseMessage, err 308 | } 309 | if cpl.ResponseCode != "0" { 310 | return canceledPurchases, continuationToken, cpl.ResponseCode, cpl.ResponseMessage, c.getResponseErrorByCode(cpl.ResponseCode) 311 | } 312 | canceledPurchases = cpl.OrderInfoList 313 | 314 | return canceledPurchases, cpl.ContinuationToken, cpl.ResponseCode, cpl.ResponseMessage, nil 315 | } 316 | -------------------------------------------------------------------------------- /microsoftstore/model.go: -------------------------------------------------------------------------------- 1 | package microsoftstore 2 | 3 | import "time" 4 | 5 | type UserIdentity struct { 6 | IdentityType string `json:"identityType"` 7 | IdentityValue string `json:"identityValue"` 8 | LocalTicketReference string `json:"localTicketReference"` 9 | } 10 | 11 | type ProductSkuId struct { 12 | ProductId string `json:"productId"` 13 | SkuId string `json:"skuId"` 14 | } 15 | 16 | type ProductType string 17 | 18 | const ( 19 | Application ProductType = "Application" 20 | Durable ProductType = "Durable" 21 | Game ProductType = "Game" 22 | UnmanagedConsumable ProductType = "UnmanagedConsumable" 23 | ) 24 | 25 | type IAPRequest struct { 26 | Beneficiaries []UserIdentity `json:"beneficiaries"` 27 | ContinuationToken string `json:"continuationToken,omitempty"` 28 | MaxPageSize int `json:"maxPageSize,omitempty"` 29 | ModifiedAfter *time.Time `json:"modifiedAfter,omitempty"` 30 | ParentProductId string `json:"parentProductId,omitempty"` 31 | ProductSkuIds []ProductSkuId `json:"productSkuIds,omitempty"` 32 | ProductTypes []ProductType `json:"productTypes"` 33 | ValidityType string `json:"validityType,omitempty"` 34 | } 35 | 36 | type IdentityContractV6 struct { 37 | IdentityType string `json:"identityType"` // Contains the value "pub". 38 | IdentityValue string `json:"identityValue"` // The string value of the publisherUserId from the specified Microsoft Store ID key. 39 | } 40 | 41 | // CollectionItemContractV6 represents an item in the user's collection. 42 | type CollectionItemContractV6 struct { 43 | AcquiredDate time.Time `json:"acquiredDate"` // The date on which the user acquired the item. 44 | CampaignId *string `json:"campaignId,omitempty"` // The campaign ID that was provided at purchase time for this item. 45 | DevOfferId *string `json:"devOfferId,omitempty"` // The offer ID from an in-app purchase. 46 | EndDate time.Time `json:"endDate"` // The end date of the item. 47 | FulfillmentData []string `json:"fulfillmentData,omitempty"` // N/A 48 | InAppOfferToken *string `json:"inAppOfferToken,omitempty"` // The developer-specified product ID string assigned to the item in Partner Center. 49 | ItemId string `json:"itemId"` // An ID that identifies this collection item from other items the user owns. 50 | LocalTicketReference string `json:"localTicketReference"` // The ID of the previously supplied localTicketReference in the request body. 51 | ModifiedDate time.Time `json:"modifiedDate"` // The date this item was last modified. 52 | OrderId *string `json:"orderId,omitempty"` // If present, the order ID of which this item was obtained. 53 | OrderLineItemId *string `json:"orderLineItemId,omitempty"` // If present, the line item of the particular order for which this item was obtained. 54 | OwnershipType string `json:"ownershipType"` // The string "OwnedByBeneficiary". 55 | ProductId string `json:"productId"` // The Store ID for the product in the Microsoft Store catalog. 56 | ProductType string `json:"productType"` // One of the following product types: Application, Durable, UnmanagedConsumable. 57 | PurchasedCountry *string `json:"purchasedCountry,omitempty"` // N/A 58 | Purchaser *IdentityContractV6 `json:"purchaser,omitempty"` // Represents the identity of the purchaser of the item. 59 | Quantity *int `json:"quantity,omitempty"` // The quantity of the item. Currently, this will always be 1. 60 | SkuId string `json:"skuId"` // The Store ID for the product's SKU in the Microsoft Store catalog. 61 | SkuType string `json:"skuType"` // Type of the SKU. Possible values include Trial, Full, and Rental. 62 | StartDate time.Time `json:"startDate"` // The date that the item starts being valid. 63 | Status string `json:"status"` // The status of the item. Possible values include Active, Expired, Revoked, and Banned. 64 | Tags []string `json:"tags"` // N/A 65 | TransactionId string `json:"transactionId"` // The transaction ID as a result of the purchase of this item. 66 | } 67 | 68 | type IAPResponse struct { 69 | ContinuationToken *string `json:"continuationToken,omitempty"` // Token to retrieve remaining products if there are multiple sets. 70 | Items []CollectionItemContractV6 `json:"items,omitempty"` // An array of products for the specified user. 71 | } 72 | -------------------------------------------------------------------------------- /microsoftstore/validator.go: -------------------------------------------------------------------------------- 1 | package microsoftstore 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | ) 13 | 14 | const ( 15 | resource = "https://onestore.microsoft.com" 16 | ) 17 | 18 | // IAPClient is an interface to call validation API in Microsoft Store 19 | type IAPClient interface { 20 | Verify(context.Context, string, string) (IAPResponse, error) 21 | } 22 | 23 | // Client implements IAPClient 24 | type Client struct { 25 | TenantID string 26 | ClientID string 27 | ClientSecret string 28 | httpCli *http.Client 29 | } 30 | 31 | // New creates a client object 32 | func New(tenantId, clientId, secret string) *Client { 33 | client := &Client{ 34 | TenantID: tenantId, 35 | ClientID: clientId, 36 | ClientSecret: secret, 37 | httpCli: &http.Client{ 38 | Timeout: 10 * time.Second, 39 | }, 40 | } 41 | 42 | return client 43 | } 44 | 45 | // Verify sends receipts and gets validation result 46 | func (c *Client) Verify(ctx context.Context, receipt IAPRequest) (IAPResponse, error) { 47 | resp := IAPResponse{} 48 | token, err := c.getAzureADToken(ctx, c.TenantID, c.ClientID, c.ClientSecret, resource) 49 | if err != nil { 50 | return resp, err 51 | } 52 | 53 | return c.query(ctx, token, receipt) 54 | } 55 | 56 | // getAzureADToken obtains an Azure AD access token using client credentials flow 57 | func (c *Client) getAzureADToken(ctx context.Context, tenantID, clientID, clientSecret, resource string) (string, error) { 58 | tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", tenantID) 59 | 60 | data := url.Values{} 61 | data.Set("grant_type", "client_credentials") 62 | data.Set("client_id", clientID) 63 | data.Set("client_secret", clientSecret) 64 | data.Set("resource", resource) 65 | 66 | req, err := http.NewRequest("POST", tokenURL, bytes.NewBufferString(data.Encode())) 67 | if err != nil { 68 | return "", err 69 | } 70 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 71 | req.WithContext(ctx) 72 | 73 | resp, err := c.httpCli.Do(req) 74 | if err != nil { 75 | return "", err 76 | } 77 | defer resp.Body.Close() 78 | 79 | if resp.StatusCode != http.StatusOK { 80 | bodyBytes, _ := io.ReadAll(resp.Body) 81 | return "", fmt.Errorf("failed to obtain token: %s", string(bodyBytes)) 82 | } 83 | 84 | var tokenResponse struct { 85 | AccessToken string `json:"access_token"` 86 | } 87 | err = json.NewDecoder(resp.Body).Decode(&tokenResponse) 88 | if err != nil { 89 | return "", err 90 | } 91 | return tokenResponse.AccessToken, nil 92 | } 93 | 94 | // query sends a query to Microsoft Store API 95 | func (c *Client) query(ctx context.Context, accessToken string, receiptData IAPRequest) (IAPResponse, error) { 96 | queryURL := "https://collections.mp.microsoft.com/v6.0/collections/query" 97 | result := IAPResponse{} 98 | 99 | requestBody, err := json.Marshal(receiptData) 100 | if err != nil { 101 | return result, err 102 | } 103 | 104 | req, err := http.NewRequest("POST", queryURL, bytes.NewBuffer(requestBody)) 105 | if err != nil { 106 | return result, err 107 | } 108 | req.Header.Set("Content-Type", "application/json") 109 | req.Header.Set("Authorization", "Bearer "+accessToken) 110 | req.WithContext(ctx) 111 | 112 | res, err := c.httpCli.Do(req) 113 | if err != nil { 114 | return result, err 115 | } 116 | defer res.Body.Close() 117 | 118 | if res.StatusCode != http.StatusOK { 119 | bodyBytes, _ := io.ReadAll(res.Body) 120 | return result, fmt.Errorf("validation failed: %s", string(bodyBytes)) 121 | } 122 | 123 | err = json.NewDecoder(res.Body).Decode(&result) 124 | return result, err 125 | } 126 | -------------------------------------------------------------------------------- /playstore/mocks/playstore.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/awa/go-iap/playstore (interfaces: IABProduct,IABSubscription,IABSubscriptionV2,IABMonetization) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=mocks/playstore.go -package=mocks github.com/awa/go-iap/playstore IABProduct,IABSubscription,IABSubscriptionV2,IABMonetization 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | androidpublisher "google.golang.org/api/androidpublisher/v3" 18 | ) 19 | 20 | // MockIABProduct is a mock of IABProduct interface. 21 | type MockIABProduct struct { 22 | ctrl *gomock.Controller 23 | recorder *MockIABProductMockRecorder 24 | isgomock struct{} 25 | } 26 | 27 | // MockIABProductMockRecorder is the mock recorder for MockIABProduct. 28 | type MockIABProductMockRecorder struct { 29 | mock *MockIABProduct 30 | } 31 | 32 | // NewMockIABProduct creates a new mock instance. 33 | func NewMockIABProduct(ctrl *gomock.Controller) *MockIABProduct { 34 | mock := &MockIABProduct{ctrl: ctrl} 35 | mock.recorder = &MockIABProductMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockIABProduct) EXPECT() *MockIABProductMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // AcknowledgeProduct mocks base method. 45 | func (m *MockIABProduct) AcknowledgeProduct(arg0 context.Context, arg1, arg2, arg3, arg4 string) error { 46 | m.ctrl.T.Helper() 47 | ret := m.ctrl.Call(m, "AcknowledgeProduct", arg0, arg1, arg2, arg3, arg4) 48 | ret0, _ := ret[0].(error) 49 | return ret0 50 | } 51 | 52 | // AcknowledgeProduct indicates an expected call of AcknowledgeProduct. 53 | func (mr *MockIABProductMockRecorder) AcknowledgeProduct(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcknowledgeProduct", reflect.TypeOf((*MockIABProduct)(nil).AcknowledgeProduct), arg0, arg1, arg2, arg3, arg4) 56 | } 57 | 58 | // ConsumeProduct mocks base method. 59 | func (m *MockIABProduct) ConsumeProduct(arg0 context.Context, arg1, arg2, arg3 string) error { 60 | m.ctrl.T.Helper() 61 | ret := m.ctrl.Call(m, "ConsumeProduct", arg0, arg1, arg2, arg3) 62 | ret0, _ := ret[0].(error) 63 | return ret0 64 | } 65 | 66 | // ConsumeProduct indicates an expected call of ConsumeProduct. 67 | func (mr *MockIABProductMockRecorder) ConsumeProduct(arg0, arg1, arg2, arg3 any) *gomock.Call { 68 | mr.mock.ctrl.T.Helper() 69 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConsumeProduct", reflect.TypeOf((*MockIABProduct)(nil).ConsumeProduct), arg0, arg1, arg2, arg3) 70 | } 71 | 72 | // VerifyProduct mocks base method. 73 | func (m *MockIABProduct) VerifyProduct(arg0 context.Context, arg1, arg2, arg3 string) (*androidpublisher.ProductPurchase, error) { 74 | m.ctrl.T.Helper() 75 | ret := m.ctrl.Call(m, "VerifyProduct", arg0, arg1, arg2, arg3) 76 | ret0, _ := ret[0].(*androidpublisher.ProductPurchase) 77 | ret1, _ := ret[1].(error) 78 | return ret0, ret1 79 | } 80 | 81 | // VerifyProduct indicates an expected call of VerifyProduct. 82 | func (mr *MockIABProductMockRecorder) VerifyProduct(arg0, arg1, arg2, arg3 any) *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyProduct", reflect.TypeOf((*MockIABProduct)(nil).VerifyProduct), arg0, arg1, arg2, arg3) 85 | } 86 | 87 | // MockIABSubscription is a mock of IABSubscription interface. 88 | type MockIABSubscription struct { 89 | ctrl *gomock.Controller 90 | recorder *MockIABSubscriptionMockRecorder 91 | isgomock struct{} 92 | } 93 | 94 | // MockIABSubscriptionMockRecorder is the mock recorder for MockIABSubscription. 95 | type MockIABSubscriptionMockRecorder struct { 96 | mock *MockIABSubscription 97 | } 98 | 99 | // NewMockIABSubscription creates a new mock instance. 100 | func NewMockIABSubscription(ctrl *gomock.Controller) *MockIABSubscription { 101 | mock := &MockIABSubscription{ctrl: ctrl} 102 | mock.recorder = &MockIABSubscriptionMockRecorder{mock} 103 | return mock 104 | } 105 | 106 | // EXPECT returns an object that allows the caller to indicate expected use. 107 | func (m *MockIABSubscription) EXPECT() *MockIABSubscriptionMockRecorder { 108 | return m.recorder 109 | } 110 | 111 | // AcknowledgeSubscription mocks base method. 112 | func (m *MockIABSubscription) AcknowledgeSubscription(arg0 context.Context, arg1, arg2, arg3 string, arg4 *androidpublisher.SubscriptionPurchasesAcknowledgeRequest) error { 113 | m.ctrl.T.Helper() 114 | ret := m.ctrl.Call(m, "AcknowledgeSubscription", arg0, arg1, arg2, arg3, arg4) 115 | ret0, _ := ret[0].(error) 116 | return ret0 117 | } 118 | 119 | // AcknowledgeSubscription indicates an expected call of AcknowledgeSubscription. 120 | func (mr *MockIABSubscriptionMockRecorder) AcknowledgeSubscription(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { 121 | mr.mock.ctrl.T.Helper() 122 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcknowledgeSubscription", reflect.TypeOf((*MockIABSubscription)(nil).AcknowledgeSubscription), arg0, arg1, arg2, arg3, arg4) 123 | } 124 | 125 | // CancelSubscription mocks base method. 126 | func (m *MockIABSubscription) CancelSubscription(arg0 context.Context, arg1, arg2, arg3 string) error { 127 | m.ctrl.T.Helper() 128 | ret := m.ctrl.Call(m, "CancelSubscription", arg0, arg1, arg2, arg3) 129 | ret0, _ := ret[0].(error) 130 | return ret0 131 | } 132 | 133 | // CancelSubscription indicates an expected call of CancelSubscription. 134 | func (mr *MockIABSubscriptionMockRecorder) CancelSubscription(arg0, arg1, arg2, arg3 any) *gomock.Call { 135 | mr.mock.ctrl.T.Helper() 136 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelSubscription", reflect.TypeOf((*MockIABSubscription)(nil).CancelSubscription), arg0, arg1, arg2, arg3) 137 | } 138 | 139 | // DeferSubscription mocks base method. 140 | func (m *MockIABSubscription) DeferSubscription(arg0 context.Context, arg1, arg2, arg3 string, arg4 *androidpublisher.SubscriptionPurchasesDeferRequest) (*androidpublisher.SubscriptionPurchasesDeferResponse, error) { 141 | m.ctrl.T.Helper() 142 | ret := m.ctrl.Call(m, "DeferSubscription", arg0, arg1, arg2, arg3, arg4) 143 | ret0, _ := ret[0].(*androidpublisher.SubscriptionPurchasesDeferResponse) 144 | ret1, _ := ret[1].(error) 145 | return ret0, ret1 146 | } 147 | 148 | // DeferSubscription indicates an expected call of DeferSubscription. 149 | func (mr *MockIABSubscriptionMockRecorder) DeferSubscription(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { 150 | mr.mock.ctrl.T.Helper() 151 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeferSubscription", reflect.TypeOf((*MockIABSubscription)(nil).DeferSubscription), arg0, arg1, arg2, arg3, arg4) 152 | } 153 | 154 | // RefundSubscription mocks base method. 155 | func (m *MockIABSubscription) RefundSubscription(arg0 context.Context, arg1, arg2, arg3 string) error { 156 | m.ctrl.T.Helper() 157 | ret := m.ctrl.Call(m, "RefundSubscription", arg0, arg1, arg2, arg3) 158 | ret0, _ := ret[0].(error) 159 | return ret0 160 | } 161 | 162 | // RefundSubscription indicates an expected call of RefundSubscription. 163 | func (mr *MockIABSubscriptionMockRecorder) RefundSubscription(arg0, arg1, arg2, arg3 any) *gomock.Call { 164 | mr.mock.ctrl.T.Helper() 165 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RefundSubscription", reflect.TypeOf((*MockIABSubscription)(nil).RefundSubscription), arg0, arg1, arg2, arg3) 166 | } 167 | 168 | // RevokeSubscription mocks base method. 169 | func (m *MockIABSubscription) RevokeSubscription(arg0 context.Context, arg1, arg2, arg3 string) error { 170 | m.ctrl.T.Helper() 171 | ret := m.ctrl.Call(m, "RevokeSubscription", arg0, arg1, arg2, arg3) 172 | ret0, _ := ret[0].(error) 173 | return ret0 174 | } 175 | 176 | // RevokeSubscription indicates an expected call of RevokeSubscription. 177 | func (mr *MockIABSubscriptionMockRecorder) RevokeSubscription(arg0, arg1, arg2, arg3 any) *gomock.Call { 178 | mr.mock.ctrl.T.Helper() 179 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeSubscription", reflect.TypeOf((*MockIABSubscription)(nil).RevokeSubscription), arg0, arg1, arg2, arg3) 180 | } 181 | 182 | // VerifySubscription mocks base method. 183 | func (m *MockIABSubscription) VerifySubscription(arg0 context.Context, arg1, arg2, arg3 string) (*androidpublisher.SubscriptionPurchase, error) { 184 | m.ctrl.T.Helper() 185 | ret := m.ctrl.Call(m, "VerifySubscription", arg0, arg1, arg2, arg3) 186 | ret0, _ := ret[0].(*androidpublisher.SubscriptionPurchase) 187 | ret1, _ := ret[1].(error) 188 | return ret0, ret1 189 | } 190 | 191 | // VerifySubscription indicates an expected call of VerifySubscription. 192 | func (mr *MockIABSubscriptionMockRecorder) VerifySubscription(arg0, arg1, arg2, arg3 any) *gomock.Call { 193 | mr.mock.ctrl.T.Helper() 194 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifySubscription", reflect.TypeOf((*MockIABSubscription)(nil).VerifySubscription), arg0, arg1, arg2, arg3) 195 | } 196 | 197 | // MockIABSubscriptionV2 is a mock of IABSubscriptionV2 interface. 198 | type MockIABSubscriptionV2 struct { 199 | ctrl *gomock.Controller 200 | recorder *MockIABSubscriptionV2MockRecorder 201 | isgomock struct{} 202 | } 203 | 204 | // MockIABSubscriptionV2MockRecorder is the mock recorder for MockIABSubscriptionV2. 205 | type MockIABSubscriptionV2MockRecorder struct { 206 | mock *MockIABSubscriptionV2 207 | } 208 | 209 | // NewMockIABSubscriptionV2 creates a new mock instance. 210 | func NewMockIABSubscriptionV2(ctrl *gomock.Controller) *MockIABSubscriptionV2 { 211 | mock := &MockIABSubscriptionV2{ctrl: ctrl} 212 | mock.recorder = &MockIABSubscriptionV2MockRecorder{mock} 213 | return mock 214 | } 215 | 216 | // EXPECT returns an object that allows the caller to indicate expected use. 217 | func (m *MockIABSubscriptionV2) EXPECT() *MockIABSubscriptionV2MockRecorder { 218 | return m.recorder 219 | } 220 | 221 | // RevokeSubscriptionV2 mocks base method. 222 | func (m *MockIABSubscriptionV2) RevokeSubscriptionV2(arg0 context.Context, arg1, arg2 string, arg3 *androidpublisher.RevokeSubscriptionPurchaseRequest) (*androidpublisher.RevokeSubscriptionPurchaseResponse, error) { 223 | m.ctrl.T.Helper() 224 | ret := m.ctrl.Call(m, "RevokeSubscriptionV2", arg0, arg1, arg2, arg3) 225 | ret0, _ := ret[0].(*androidpublisher.RevokeSubscriptionPurchaseResponse) 226 | ret1, _ := ret[1].(error) 227 | return ret0, ret1 228 | } 229 | 230 | // RevokeSubscriptionV2 indicates an expected call of RevokeSubscriptionV2. 231 | func (mr *MockIABSubscriptionV2MockRecorder) RevokeSubscriptionV2(arg0, arg1, arg2, arg3 any) *gomock.Call { 232 | mr.mock.ctrl.T.Helper() 233 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeSubscriptionV2", reflect.TypeOf((*MockIABSubscriptionV2)(nil).RevokeSubscriptionV2), arg0, arg1, arg2, arg3) 234 | } 235 | 236 | // VerifySubscriptionV2 mocks base method. 237 | func (m *MockIABSubscriptionV2) VerifySubscriptionV2(arg0 context.Context, arg1, arg2 string) (*androidpublisher.SubscriptionPurchaseV2, error) { 238 | m.ctrl.T.Helper() 239 | ret := m.ctrl.Call(m, "VerifySubscriptionV2", arg0, arg1, arg2) 240 | ret0, _ := ret[0].(*androidpublisher.SubscriptionPurchaseV2) 241 | ret1, _ := ret[1].(error) 242 | return ret0, ret1 243 | } 244 | 245 | // VerifySubscriptionV2 indicates an expected call of VerifySubscriptionV2. 246 | func (mr *MockIABSubscriptionV2MockRecorder) VerifySubscriptionV2(arg0, arg1, arg2 any) *gomock.Call { 247 | mr.mock.ctrl.T.Helper() 248 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifySubscriptionV2", reflect.TypeOf((*MockIABSubscriptionV2)(nil).VerifySubscriptionV2), arg0, arg1, arg2) 249 | } 250 | 251 | // MockIABMonetization is a mock of IABMonetization interface. 252 | type MockIABMonetization struct { 253 | ctrl *gomock.Controller 254 | recorder *MockIABMonetizationMockRecorder 255 | isgomock struct{} 256 | } 257 | 258 | // MockIABMonetizationMockRecorder is the mock recorder for MockIABMonetization. 259 | type MockIABMonetizationMockRecorder struct { 260 | mock *MockIABMonetization 261 | } 262 | 263 | // NewMockIABMonetization creates a new mock instance. 264 | func NewMockIABMonetization(ctrl *gomock.Controller) *MockIABMonetization { 265 | mock := &MockIABMonetization{ctrl: ctrl} 266 | mock.recorder = &MockIABMonetizationMockRecorder{mock} 267 | return mock 268 | } 269 | 270 | // EXPECT returns an object that allows the caller to indicate expected use. 271 | func (m *MockIABMonetization) EXPECT() *MockIABMonetizationMockRecorder { 272 | return m.recorder 273 | } 274 | 275 | // GetSubscription mocks base method. 276 | func (m *MockIABMonetization) GetSubscription(ctx context.Context, packageName, productID string) (*androidpublisher.Subscription, error) { 277 | m.ctrl.T.Helper() 278 | ret := m.ctrl.Call(m, "GetSubscription", ctx, packageName, productID) 279 | ret0, _ := ret[0].(*androidpublisher.Subscription) 280 | ret1, _ := ret[1].(error) 281 | return ret0, ret1 282 | } 283 | 284 | // GetSubscription indicates an expected call of GetSubscription. 285 | func (mr *MockIABMonetizationMockRecorder) GetSubscription(ctx, packageName, productID any) *gomock.Call { 286 | mr.mock.ctrl.T.Helper() 287 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscription", reflect.TypeOf((*MockIABMonetization)(nil).GetSubscription), ctx, packageName, productID) 288 | } 289 | 290 | // GetSubscriptionOffer mocks base method. 291 | func (m *MockIABMonetization) GetSubscriptionOffer(arg0 context.Context, arg1, arg2, arg3, arg4 string) (*androidpublisher.SubscriptionOffer, error) { 292 | m.ctrl.T.Helper() 293 | ret := m.ctrl.Call(m, "GetSubscriptionOffer", arg0, arg1, arg2, arg3, arg4) 294 | ret0, _ := ret[0].(*androidpublisher.SubscriptionOffer) 295 | ret1, _ := ret[1].(error) 296 | return ret0, ret1 297 | } 298 | 299 | // GetSubscriptionOffer indicates an expected call of GetSubscriptionOffer. 300 | func (mr *MockIABMonetizationMockRecorder) GetSubscriptionOffer(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { 301 | mr.mock.ctrl.T.Helper() 302 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscriptionOffer", reflect.TypeOf((*MockIABMonetization)(nil).GetSubscriptionOffer), arg0, arg1, arg2, arg3, arg4) 303 | } 304 | -------------------------------------------------------------------------------- /playstore/notification.go: -------------------------------------------------------------------------------- 1 | package playstore 2 | 3 | // https://developer.android.com/google/play/billing/rtdn-reference#sub 4 | type SubscriptionNotificationType int 5 | 6 | const ( 7 | SubscriptionNotificationTypeRecovered SubscriptionNotificationType = iota + 1 8 | SubscriptionNotificationTypeRenewed 9 | SubscriptionNotificationTypeCanceled 10 | SubscriptionNotificationTypePurchased 11 | SubscriptionNotificationTypeAccountHold 12 | SubscriptionNotificationTypeGracePeriod 13 | SubscriptionNotificationTypeRestarted 14 | SubscriptionNotificationTypePriceChangeConfirmed 15 | SubscriptionNotificationTypeDeferred 16 | SubscriptionNotificationTypePaused 17 | SubscriptionNotificationTypePauseScheduleChanged 18 | SubscriptionNotificationTypeRevoked 19 | SubscriptionNotificationTypeExpired 20 | SubscriptionNotificationTypePendingPurchaseCancelled = iota + 7 21 | ) 22 | 23 | // https://developer.android.com/google/play/billing/rtdn-reference#one-time 24 | type OneTimeProductNotificationType int 25 | 26 | const ( 27 | OneTimeProductNotificationTypePurchased OneTimeProductNotificationType = iota + 1 28 | OneTimeProductNotificationTypeCanceled 29 | ) 30 | 31 | // https://developer.android.com/google/play/billing/rtdn-reference#voided-purchase 32 | type VoidedPurchaseProductType int 33 | 34 | const ( 35 | VoidedPurchaseProductTypeSubscription = iota + 1 36 | VoidedPurchaseProductTypeOneTime 37 | ) 38 | 39 | type VoidedPurchaseRefundType int 40 | 41 | const ( 42 | VoidedPurchaseRefundTypeFullRefund VoidedPurchaseRefundType = iota + 1 43 | VoidedPurchaseRefundTypePartialRefund 44 | ) 45 | 46 | // DeveloperNotification is sent by a Pub/Sub topic. 47 | // Detailed description is following. 48 | // https://developer.android.com/google/play/billing/rtdn-reference#json_specification 49 | // Depreacated: use DeveloperNotificationV2 instead. 50 | type DeveloperNotification struct { 51 | Version string `json:"version"` 52 | PackageName string `json:"packageName"` 53 | EventTimeMillis string `json:"eventTimeMillis"` 54 | SubscriptionNotification SubscriptionNotification `json:"subscriptionNotification,omitempty"` 55 | OneTimeProductNotification OneTimeProductNotification `json:"oneTimeProductNotification,omitempty"` 56 | VoidedPurchaseNotification VoidedPurchaseNotification `json:"voidedPurchaseNotification,omitempty"` 57 | TestNotification TestNotification `json:"testNotification,omitempty"` 58 | } 59 | 60 | // DeveloperNotificationV2 is sent by a Pub/Sub topic. 61 | // Detailed description is following. 62 | // https://developer.android.com/google/play/billing/rtdn-reference#json_specification 63 | type DeveloperNotificationV2 struct { 64 | Version string `json:"version"` 65 | PackageName string `json:"packageName"` 66 | EventTimeMillis string `json:"eventTimeMillis"` 67 | SubscriptionNotification *SubscriptionNotification `json:"subscriptionNotification,omitempty"` 68 | OneTimeProductNotification *OneTimeProductNotification `json:"oneTimeProductNotification,omitempty"` 69 | VoidedPurchaseNotification *VoidedPurchaseNotification `json:"voidedPurchaseNotification,omitempty"` 70 | TestNotification *TestNotification `json:"testNotification,omitempty"` 71 | } 72 | 73 | // SubscriptionNotification has subscription status as notificationType, token and subscription id 74 | // to confirm status by calling Google Android Publisher API. 75 | type SubscriptionNotification struct { 76 | Version string `json:"version"` 77 | NotificationType SubscriptionNotificationType `json:"notificationType,omitempty"` 78 | PurchaseToken string `json:"purchaseToken,omitempty"` 79 | SubscriptionID string `json:"subscriptionId,omitempty"` 80 | } 81 | 82 | // OneTimeProductNotification has one-time product status as notificationType, token and sku (product id) 83 | // to confirm status by calling Google Android Publisher API. 84 | type OneTimeProductNotification struct { 85 | Version string `json:"version"` 86 | NotificationType OneTimeProductNotificationType `json:"notificationType,omitempty"` 87 | PurchaseToken string `json:"purchaseToken,omitempty"` 88 | SKU string `json:"sku,omitempty"` 89 | } 90 | 91 | // VoidedPurchaseNotification has token, order and product type to locate the right purchase and order. 92 | // To learn how to get additional information about the voided purchase, check out the Google Play Voided Purchases API, 93 | // which is a pull model that provides additional data for voided purchases between a given timestamp. 94 | // https://developer.android.com/google/play/billing/rtdn-reference#voided-purchase 95 | type VoidedPurchaseNotification struct { 96 | PurchaseToken string `json:"purchaseToken"` 97 | OrderID string `json:"orderId"` 98 | ProductType VoidedPurchaseProductType `json:"productType"` 99 | RefundType VoidedPurchaseRefundType `json:"refundType"` 100 | } 101 | 102 | // TestNotification is the test publish that are sent only through the Google Play Developer Console 103 | type TestNotification struct { 104 | Version string `json:"version"` 105 | } 106 | -------------------------------------------------------------------------------- /playstore/testdata/test_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "some-fake-project", 4 | "private_key_id": "fffff3866535e13f3acf0bfed0b23d78bcb00000", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\ni-am-a-dummy-key\n-----END PRIVATE KEY-----\n", 6 | "client_email": "play-api-caller@some-fake-project.iam.gserviceaccount.com", 7 | "client_id": "114683608631111100000", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/play-api-caller%40some-fake-project.iam.gserviceaccount.com" 12 | } -------------------------------------------------------------------------------- /playstore/validator.go: -------------------------------------------------------------------------------- 1 | package playstore 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/rsa" 7 | "crypto/sha1" 8 | "crypto/x509" 9 | "encoding/base64" 10 | "fmt" 11 | "net/http" 12 | "time" 13 | 14 | "google.golang.org/api/option" 15 | 16 | "golang.org/x/oauth2" 17 | "golang.org/x/oauth2/google" 18 | androidpublisher "google.golang.org/api/androidpublisher/v3" 19 | ) 20 | 21 | //go:generate mockgen -destination=mocks/playstore.go -package=mocks github.com/awa/go-iap/playstore IABProduct,IABSubscription,IABSubscriptionV2,IABMonetization 22 | 23 | // The IABProduct type is an interface for product service 24 | type IABProduct interface { 25 | VerifyProduct(context.Context, string, string, string) (*androidpublisher.ProductPurchase, error) 26 | AcknowledgeProduct(context.Context, string, string, string, string) error 27 | ConsumeProduct(context.Context, string, string, string) error 28 | } 29 | 30 | // The IABSubscription type is an interface for subscription service 31 | type IABSubscription interface { 32 | AcknowledgeSubscription(context.Context, string, string, string, *androidpublisher.SubscriptionPurchasesAcknowledgeRequest) error 33 | VerifySubscription(context.Context, string, string, string) (*androidpublisher.SubscriptionPurchase, error) 34 | CancelSubscription(context.Context, string, string, string) error 35 | RefundSubscription(context.Context, string, string, string) error 36 | RevokeSubscription(context.Context, string, string, string) error 37 | DeferSubscription(context.Context, string, string, string, *androidpublisher.SubscriptionPurchasesDeferRequest) (*androidpublisher.SubscriptionPurchasesDeferResponse, error) 38 | } 39 | 40 | // The IABSubscriptionV2 type is an interface for subscriptionV2 service 41 | type IABSubscriptionV2 interface { 42 | VerifySubscriptionV2(context.Context, string, string) (*androidpublisher.SubscriptionPurchaseV2, error) 43 | RevokeSubscriptionV2(context.Context, string, string, *androidpublisher.RevokeSubscriptionPurchaseRequest) (*androidpublisher.RevokeSubscriptionPurchaseResponse, error) 44 | } 45 | 46 | // The IABMonetization type is an interface for monetization service 47 | type IABMonetization interface { 48 | GetSubscription(ctx context.Context, packageName string, productID string) (*androidpublisher.Subscription, error) 49 | GetSubscriptionOffer(context.Context, string, string, string, string) (*androidpublisher.SubscriptionOffer, error) 50 | } 51 | 52 | // The Client type implements VerifySubscription method 53 | type Client struct { 54 | service *androidpublisher.Service 55 | } 56 | 57 | // New returns http client which includes the credentials to access androidpublisher API. 58 | // You should create a service account for your project at 59 | // https://console.developers.google.com and download a JSON key file to set this argument. 60 | func New(jsonKey []byte) (*Client, error) { 61 | c := &http.Client{Timeout: 10 * time.Second} 62 | ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c) 63 | 64 | conf, err := google.JWTConfigFromJSON(jsonKey, androidpublisher.AndroidpublisherScope) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | val := conf.Client(ctx).Transport.(*oauth2.Transport) 70 | _, err = val.Source.Token() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | service, err := androidpublisher.NewService(ctx, option.WithHTTPClient(conf.Client(ctx))) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return &Client{service}, err 81 | } 82 | 83 | // NewWithClient returns http client which includes the custom http client. 84 | func NewWithClient(jsonKey []byte, cli *http.Client) (*Client, error) { 85 | if cli == nil { 86 | return nil, fmt.Errorf("client is nil") 87 | } 88 | 89 | ctx := context.WithValue(context.Background(), oauth2.HTTPClient, cli) 90 | 91 | conf, err := google.JWTConfigFromJSON(jsonKey, androidpublisher.AndroidpublisherScope) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | service, err := androidpublisher.NewService(ctx, option.WithHTTPClient(conf.Client(ctx))) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return &Client{service}, err 102 | } 103 | 104 | // NewDefaultTokenSourceClient returns a client that authenticates using Google Application Default Credentials. 105 | // See https://pkg.go.dev/golang.org/x/oauth2/google#DefaultTokenSource 106 | func NewDefaultTokenSourceClient() (*Client, error) { 107 | ctx := context.Background() 108 | httpClient, err := google.DefaultClient(ctx, androidpublisher.AndroidpublisherScope) 109 | if err != nil { 110 | return nil, err 111 | } 112 | service, err := androidpublisher.NewService(ctx, option.WithHTTPClient(httpClient)) 113 | if err != nil { 114 | return nil, err 115 | } 116 | return &Client{service}, nil 117 | } 118 | 119 | // AcknowledgeSubscription acknowledges a subscription purchase. 120 | func (c *Client) AcknowledgeSubscription( 121 | ctx context.Context, 122 | packageName string, 123 | subscriptionID string, 124 | token string, 125 | req *androidpublisher.SubscriptionPurchasesAcknowledgeRequest, 126 | ) error { 127 | ps := androidpublisher.NewPurchasesSubscriptionsService(c.service) 128 | err := ps.Acknowledge(packageName, subscriptionID, token, req).Context(ctx).Do() 129 | 130 | return err 131 | } 132 | 133 | // VerifySubscription verifies subscription status 134 | // Deprecated 135 | func (c *Client) VerifySubscription( 136 | ctx context.Context, 137 | packageName string, 138 | subscriptionID string, 139 | token string, 140 | ) (*androidpublisher.SubscriptionPurchase, error) { 141 | ps := androidpublisher.NewPurchasesSubscriptionsService(c.service) 142 | result, err := ps.Get(packageName, subscriptionID, token).Context(ctx).Do() 143 | 144 | return result, err 145 | } 146 | 147 | // VerifySubscriptionV2 verifies subscription status 148 | func (c *Client) VerifySubscriptionV2( 149 | ctx context.Context, 150 | packageName string, 151 | token string, 152 | ) (*androidpublisher.SubscriptionPurchaseV2, error) { 153 | ps := androidpublisher.NewPurchasesSubscriptionsv2Service(c.service) 154 | result, err := ps.Get(packageName, token).Context(ctx).Do() 155 | 156 | return result, err 157 | } 158 | 159 | // RevokeSubscriptionV2 verifies subscription status 160 | func (c *Client) RevokeSubscriptionV2( 161 | ctx context.Context, 162 | packageName string, 163 | token string, 164 | req *androidpublisher.RevokeSubscriptionPurchaseRequest, 165 | ) (*androidpublisher.RevokeSubscriptionPurchaseResponse, error) { 166 | ps := androidpublisher.NewPurchasesSubscriptionsv2Service(c.service) 167 | result, err := ps.Revoke(packageName, token, req).Context(ctx).Do() 168 | 169 | return result, err 170 | } 171 | 172 | // VerifyProduct verifies product status 173 | func (c *Client) VerifyProduct( 174 | ctx context.Context, 175 | packageName string, 176 | productID string, 177 | token string, 178 | ) (*androidpublisher.ProductPurchase, error) { 179 | ps := androidpublisher.NewPurchasesProductsService(c.service) 180 | result, err := ps.Get(packageName, productID, token).Context(ctx).Do() 181 | 182 | return result, err 183 | } 184 | 185 | func (c *Client) AcknowledgeProduct(ctx context.Context, packageName, productID, token, developerPayload string) error { 186 | ps := androidpublisher.NewPurchasesProductsService(c.service) 187 | acknowledgeRequest := &androidpublisher.ProductPurchasesAcknowledgeRequest{DeveloperPayload: developerPayload} 188 | err := ps.Acknowledge(packageName, productID, token, acknowledgeRequest).Context(ctx).Do() 189 | 190 | return err 191 | } 192 | 193 | func (c *Client) ConsumeProduct(ctx context.Context, packageName, productID, token string) error { 194 | ps := androidpublisher.NewPurchasesProductsService(c.service) 195 | err := ps.Consume(packageName, productID, token).Context(ctx).Do() 196 | 197 | return err 198 | } 199 | 200 | // CancelSubscription cancels a user's subscription purchase. 201 | func (c *Client) CancelSubscription(ctx context.Context, packageName string, subscriptionID string, token string) error { 202 | ps := androidpublisher.NewPurchasesSubscriptionsService(c.service) 203 | err := ps.Cancel(packageName, subscriptionID, token).Context(ctx).Do() 204 | 205 | return err 206 | } 207 | 208 | // RefundSubscription refunds a user's subscription purchase, but the subscription remains valid 209 | // until its expiration time and it will continue to recur. 210 | func (c *Client) RefundSubscription(ctx context.Context, packageName string, subscriptionID string, token string) error { 211 | ps := androidpublisher.NewPurchasesSubscriptionsService(c.service) 212 | err := ps.Refund(packageName, subscriptionID, token).Context(ctx).Do() 213 | 214 | return err 215 | } 216 | 217 | // RevokeSubscription refunds and immediately revokes a user's subscription purchase. 218 | // Access to the subscription will be terminated immediately and it will stop recurring. 219 | func (c *Client) RevokeSubscription(ctx context.Context, packageName string, subscriptionID string, token string) error { 220 | ps := androidpublisher.NewPurchasesSubscriptionsService(c.service) 221 | err := ps.Revoke(packageName, subscriptionID, token).Context(ctx).Do() 222 | 223 | return err 224 | } 225 | 226 | // DeferSubscription refunds and immediately defers a user's subscription purchase. 227 | // Access to the subscription will be terminated immediately and it will stop recurring. 228 | func (c *Client) DeferSubscription(ctx context.Context, packageName string, subscriptionID string, token string, 229 | req *androidpublisher.SubscriptionPurchasesDeferRequest) (*androidpublisher.SubscriptionPurchasesDeferResponse, error) { 230 | ps := androidpublisher.NewPurchasesSubscriptionsService(c.service) 231 | result, err := ps.Defer(packageName, subscriptionID, token, req).Context(ctx).Do() 232 | 233 | return result, err 234 | } 235 | 236 | // GetSubscription reads a single subscription. 237 | func (c *Client) GetSubscription(ctx context.Context, 238 | packageName string, 239 | productID string, 240 | ) (*androidpublisher.Subscription, error) { 241 | ps := androidpublisher.NewMonetizationSubscriptionsService(c.service) 242 | result, err := ps.Get(packageName, productID).Context(ctx).Do() 243 | 244 | return result, err 245 | } 246 | 247 | // GetSubscriptionOffer reads a single subscription offer. 248 | func (c *Client) GetSubscriptionOffer(ctx context.Context, 249 | packageName string, 250 | productID string, 251 | basePlanID string, 252 | offerID string, 253 | ) (*androidpublisher.SubscriptionOffer, error) { 254 | ps := androidpublisher.NewMonetizationSubscriptionsBasePlansOffersService(c.service) 255 | result, err := ps.Get(packageName, productID, basePlanID, offerID).Context(ctx).Do() 256 | 257 | return result, err 258 | } 259 | 260 | type VoidedPurchaseType int64 261 | 262 | const ( 263 | VoidedPurchaseTypeWithoutSubscription VoidedPurchaseType = 0 264 | VoidedPurchaseTypeWithSubscription VoidedPurchaseType = 1 265 | ) 266 | 267 | // VoidedPurchases list of orders that are associated with purchases that a user has voided 268 | // Quotas: 269 | // 1. 6000 queries per day. (The day begins and ends at midnight Pacific Time.) 270 | // 2. 30 queries during any 30-second period. 271 | func (c *Client) VoidedPurchases( 272 | ctx context.Context, 273 | packageName string, 274 | startTime int64, 275 | endTime int64, 276 | maxResult int64, 277 | token string, 278 | startIndex int64, 279 | productType VoidedPurchaseType, 280 | ) (*androidpublisher.VoidedPurchasesListResponse, error) { 281 | ps := androidpublisher.NewPurchasesVoidedpurchasesService(c.service) 282 | 283 | call := ps.List(packageName).StartTime(startTime).EndTime(endTime).Type(int64(productType)).MaxResults(maxResult).Context(ctx) 284 | if token == "" && startIndex == 0 { 285 | return call.Do() 286 | } else if token != "" && startIndex == 0 { 287 | return call.Token(token).Do() 288 | } else if token != "" && startIndex != 0 { 289 | return call.StartIndex(startIndex).Token(token).Do() 290 | } else { 291 | return call.StartIndex(startIndex).Do() 292 | } 293 | } 294 | 295 | // VerifySignature verifies in app billing signature. 296 | // You need to prepare a public key for your Android app's in app billing 297 | // at https://play.google.com/apps/publish/ 298 | func VerifySignature(base64EncodedPublicKey string, receipt []byte, signature string) (isValid bool, err error) { 299 | // prepare public key 300 | decodedPublicKey, err := base64.StdEncoding.DecodeString(base64EncodedPublicKey) 301 | if err != nil { 302 | return false, fmt.Errorf("failed to decode public key") 303 | } 304 | publicKeyInterface, err := x509.ParsePKIXPublicKey(decodedPublicKey) 305 | if err != nil { 306 | return false, fmt.Errorf("failed to parse public key") 307 | } 308 | publicKey, _ := publicKeyInterface.(*rsa.PublicKey) 309 | 310 | // generate hash value from receipt 311 | hasher := sha1.New() 312 | hasher.Write(receipt) 313 | hashedReceipt := hasher.Sum(nil) 314 | 315 | // decode signature 316 | decodedSignature, err := base64.StdEncoding.DecodeString(signature) 317 | if err != nil { 318 | return false, fmt.Errorf("failed to decode signature") 319 | } 320 | 321 | // verify 322 | if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA1, hashedReceipt, decodedSignature); err != nil { 323 | return false, nil 324 | } 325 | 326 | return true, nil 327 | } 328 | --------------------------------------------------------------------------------