Skip to content

Instantly share code, notes, and snippets.

@while-loop
Created October 30, 2021 04:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save while-loop/53807702379605bfc23680a0b45a2043 to your computer and use it in GitHub Desktop.
Save while-loop/53807702379605bfc23680a0b45a2043 to your computer and use it in GitHub Desktop.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/mhemmings/revenuecat"
"github.com/while-loop/dasbudget-api/internal/app"
"github.com/while-loop/dasbudget-api/internal/auth"
"github.com/while-loop/dasbudget-api/internal/config"
"github.com/while-loop/dasbudget-api/internal/config/dsub"
"github.com/while-loop/dasbudget-api/internal/log"
_ "github.com/while-loop/dasbudget-api/internal/provider/monzo"
_ "github.com/while-loop/dasbudget-api/internal/provider/mx"
_ "github.com/while-loop/dasbudget-api/internal/provider/plaid"
"google.golang.org/api/androidpublisher/v3"
"google.golang.org/api/option"
"io"
"io/ioutil"
"net/http"
"os"
"time"
)
var (
appleAppID = ""
appleSubGroupID = "subscription group ID (family group ID)"
appleHost = "https://appstoreconnect.apple.com"
appleClient = &http.Client{Timeout: 30 * time.Second}
appleCookie = `copy from chrome inspect`
priceTiers = map[float64]string{
3.99: "8",
4.49: "9",
4.99: "10",
5.49: "11",
5.99: "12",
6.49: "13",
6.99: "14",
7.49: "15",
7.99: "16",
8.49: "17",
8.99: "18",
9.49: "19",
9.99: "20",
10.49: "21",
10.99: "22",
11.49: "23",
39.99: "70",
44.99: "75",
49.99: "80",
54.99: "85",
59.99: "90",
64.99: "95",
69.99: "100",
74.99: "105",
79.99: "110",
84.99: "115",
89.99: "120",
94.99: "125",
99.99: "130",
104.99: "135",
109.99: "140",
114.99: "145",
}
priceReqs = map[string]*SetPriceReq{}
CreateSubscriptionUrl = "%s/WebObjects/iTunesConnect.woa/ra/apps/%s/iaps"
SetPricingUrl = "%s/WebObjects/iTunesConnect.woa/ra/apps/%s/iaps/%s/pricing/subscriptions"
GetPricingUrl = "%s/WebObjects/iTunesConnect.woa/ra/apps/%s/iaps/%s/pricing/equalize/USD/%s"
GetIAPsUrl = "%s/WebObjects/iTunesConnect.woa/ra/apps/%s/iaps"
GetIAPUrl = "%s/WebObjects/iTunesConnect.woa/ra/apps/%s/iaps/%s"
)
func main() {
noerr := func(err error) {
if err != nil {
panic(err)
}
}
ctx := context.Background()
noerr(doIOS(ctx, noerr))
}
func doIOS(ctx context.Context, noerr func(e error)) error {
var appleRsp *AppleResp
appleIAPs, err := getIAPs(ctx)
if err != nil {
return err
}
fmt.Println(len(appleIAPs), "Apple Products")
for _, iap := range appleIAPs {
fmt.Println(iap.VendorId)
}
p := dsub.Product{
ID: "test_db_399_1m_1acc_standard_30d0_ios",
OfferingID: "default",
Tier: dsub.EntStandard,
AccountLimit: 4,
Accounts: dsub.Ent4Account,
Frequency: dsub.FreqMonthly,
Platform: "ios",
Price: 3.99,
Sandbox: true,
}
log.Infof("creating sub %s", p.ID)
req, err := createSub(ctx, p, noerr, appleRsp)
log.Infof("finding sub ID %s", p.ID)
iap, err := waitForIAP(ctx, p.ID)
noerr(err)
log.Infof("uploading image to sub %s %s", iap.ProductID.Value, iap.AdamID)
noerr(uploadScreenshot(ctx, iap))
log.Infof("getting prices for sub %s %s", p.ID, iap.AdamID)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf(SetPricingUrl, appleHost, appleAppID, iap.AdamID), bod(pricingBody(iap.AdamID, p.Price)))
noerr(err)
log.Infof("setting prices for sub %s", p.ID)
noerr(do(ctx, req, &appleRsp))
mustSucceed(appleRsp, "failed to set price for sku %s %v", p.ID, p.Price)
log.Infof("prices set for sub %s", p.ID)
// todo, set introductory price
return nil
}
func createSub(ctx context.Context, p dsub.Product, noerr func(e error), appleRsp *AppleResp) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf(CreateSubscriptionUrl, appleHost, appleAppID), bod(CreateSubscriptionReq{
SectionErrorKeys: []interface{}{},
SectionInfoKeys: []interface{}{},
SectionWarningKeys: []interface{}{},
FamilyID: appleSubGroupID,
AddOnType: "recurring",
IsNewsSubscription: false,
IsReplaced: false,
ReplacementAdamID: nil,
ReferenceName: ValueObj{
Value: p.ID,
IsEditable: true,
IsRequired: true,
MaxLength: 64,
MinLength: 2,
},
ProductID: ValueObj{
Value: p.ID,
IsEditable: true,
IsRequired: true,
MaxLength: 100,
MinLength: 2,
},
ClearedForSale: ValueObj{
Value: true,
IsEditable: true,
IsRequired: false,
},
PricingDurationType: ValueObj{
Value: p.AppleDuration(), // "1m", "1y"
IsEditable: true,
IsRequired: false,
},
PricingIntervals: []interface{}{},
FreeTrialDurationType: ValueObj{
Value: nil,
IsEditable: true,
IsRequired: false,
},
BonusPeriodDurationType: ValueObj{
Value: nil,
IsEditable: true,
IsRequired: false,
},
MissingRequiredPrivacyPolicyData: false,
MissingRequiredFamilyDetail: false,
FamilyShareable: nil,
Versions: []Version{
{
ID: nil,
Details: ValueObj{
Value: []ValueObj{
{
Value: VersionDetails{
Name: ValueObj{
Value: p.Title(), //friendly subscription name
IsEditable: true,
IsRequired: true,
MaxLength: 30,
MinLength: 2,
},
Description: ValueObj{
Value: p.Description(), //sub description
IsEditable: true,
IsRequired: true,
MaxLength: 45,
MinLength: 1,
},
LocaleCode: "en-US",
},
IsEditable: false,
IsRequired: false,
ErrorKeys: nil,
IsDeletable: true,
},
},
IsEditable: true,
IsRequired: false,
},
ContentHosting: nil,
ContentHostingData: nil,
ReviewNotes: ValueObj{
Value: "DAS Budget provides 2 types of subscriptions in order to fully use the app.\n" +
"The first is the Standard tier that grants basic and features needed to use the app in its full form. That includes:\n" +
"- Ability to link their bank accounts\n" +
"- Ability to get 1 to 6 updates a day\n" +
"\n" +
"\n" +
"Premium subscription is the higher tier and unlocks better quality of life features for the user including:\n" +
"- Ability to fetch new data from bank accounts on-demand\n" +
"- Ability to link their credit card transactions into their budgets\n" +
"- Ability to round up transactions to the nearest dollar amount and deposit that into goals\n",
IsEditable: true,
IsRequired: false,
MaxLength: 4000,
MinLength: 2,
},
ReviewScreenshot: ValueObj{
Value: RevScreenValue{
Size: 0,
Width: 0,
Height: 0,
Checksum: "",
AssetToken: "",
SortOrder: 0,
OriginalFileName: "",
Type: "",
URL: "",
ThumbNailURL: "",
},
IsEditable: true,
IsRequired: false,
},
Merch: MerchField{
Images: []MerchImage{
{
ID: nil,
Status: nil,
Image: ValueObj{
Value: ImgValue{
AssetToken: nil,
OriginalFileName: nil,
Width: nil,
Height: nil,
Checksum: nil,
},
IsEditable: true,
IsRequired: false,
},
},
},
ShowByDefault: true,
IsActive: false,
},
Status: nil,
CanBeSubmitted: false,
},
},
}))
noerr(err)
noerr(do(ctx, req, &appleRsp))
mustSucceed(appleRsp, "failed to create sub %s %v", p.ID, p.Price)
return err
}
func uploadScreenshot(ctx context.Context, iap *CreateSubscriptionReq) error {
specs := upload(ctx)
time.Sleep(5 * time.Second)
iap.Versions[0].ReviewScreenshot = ValueObj{
Value: RevScreenValue{
Size: specs.Length,
Width: specs.Width,
Height: specs.Height,
Checksum: specs.Md5,
AssetToken: specs.Token,
OriginalFileName: "IMG_0059.PNG", // name of img
Type: "MZPFT.SortedD22ScreenShot",
URL: "",
ThumbNailURL: "",
SortOrder: 0,
},
IsEditable: true,
IsRequired: false,
}
var appleRsp *AppleResp
req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf(GetIAPUrl, appleHost, appleAppID, iap.AdamID), bod(iap))
if err != nil {
return err
}
if err = do(ctx, req, &appleRsp); err != nil {
return err
}
mustSucceed(appleRsp, "failed to upload sub image %s %v", iap.ProductID, iap.AdamID)
return nil
}
func upload(ctx context.Context) *UploadImageResp {
p := "/home/anthony/Downloads/IMG_0059.PNG" // path to file
f, err := os.Open(p)
if err != nil {
panic(fmt.Sprintf("failed to open screenshot %s: %v", p, err))
}
defer f.Close()
rbs, err := ioutil.ReadAll(f)
if err != nil {
panic(fmt.Sprintf("failed to open screenshot %s: %v", p, err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://du-itc.itunes.apple.com/upload/image", bytes.NewReader(rbs))
if err != nil {
panic(fmt.Sprintf("failed to create screenshot post: %v", err))
}
req.Header.Add("Accept", "application/json, text/plain, */*")
req.Header.Add("Content-Type", "image/png")
req.Header.Add("Content-length", fmt.Sprint(len(rbs)))
req.Header.Add("X-Apple-Upload-Referrer", fmt.Sprintf("https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/%s/addons/%s", appleAppID, "APPLE SUB ID"))
req.Header.Add("Referrer", "https://appstoreconnect.apple.com/")
req.Header.Add("X-Apple-Request-UUID", uuid.New().String())
req.Header.Add("X-Apple-Upload-AppleId", appleAppID)
req.Header.Add("X-Apple-Upload-itctoken", "COPY FROM CHROME INSPECT")
req.Header.Add("X-Apple-Upload-ContentProviderId", "COPY FROM CHROME INSPECT")
req.Header.Add("X-Original-Filename", "IMG_0059.PNG") // todo, get file name here
req.Header.Add("X-Apple-Upload-Validation-RuleSets", "MZPFT.SortedD22ScreenShot")
req.Header.Add("Connection", "keep-alive")
req.Header.Add("cookie", appleCookie)
rsp, err := appleClient.Do(req)
if err != nil {
panic(fmt.Sprintf("failed to do screenshot post: %v", err))
}
defer rsp.Body.Close()
bs, err := ioutil.ReadAll(rsp.Body)
if err != nil {
panic(fmt.Sprintf("failed to do read all screenshot post: %v", err))
}
if rsp.StatusCode != 201 {
panic(fmt.Sprintf("failed to do not 201 screenshot post %s", string(bs)))
}
var resp *UploadImageResp
if err = json.Unmarshal(bs, &resp); err != nil {
panic(fmt.Sprintf("failed to do unmarshal screenshot post: %v", err))
}
return resp
}
func pricingBody(subAdamID string, f float64) *SetPriceReq {
tier := priceTiers[f]
if tier == "" {
panic(fmt.Errorf("unknown tier %f", f))
}
req, ok := priceReqs[tier]
if !ok {
req = getTierPricing(context.Background(), subAdamID, tier)
priceReqs[tier] = req
}
return req
}
func bod(in interface{}) io.Reader {
js, err := json.Marshal(in)
if err != nil {
panic(fmt.Errorf("error marshaling request body: %v", err))
}
return bytes.NewBuffer(js)
}
func do(ctx context.Context, r *http.Request, out interface{}) error {
r.Header.Add("Authority", "appstoreconnect.apple.com")
r.Header.Add("Method", r.Method)
r.Header.Add("Path", r.URL.Path)
r.Header.Add("Scheme", r.URL.Scheme)
r.Header.Add("Accept", "application/json, text/plain, */*")
r.Header.Add("Accept-Language", "en-US,en;q=0.9")
r.Header.Add("Content-Type", "application/json;charset=UTF-8")
r.Header.Add("cookie", appleCookie)
r.Header.Add("origin", "https://appstoreconnect.apple.com")
r.Header.Add("referer", fmt.Sprintf("https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/%s/addons", appleAppID))
r.Header.Add("sec-ch-ua", `"Chromium";v="94", "Google Chrome";v="94", ";Not A Brand";v="99"`)
r.Header.Add("sec-ch-ua-mobile", "?0")
r.Header.Add("sec-ch-ua-platform", `"Linux"`)
r.Header.Add("sec-fetch-dest", "empty")
r.Header.Add("sec-fetch-mode", "cors")
r.Header.Add("sec-fetch-site", "same-origin")
r.Header.Add("user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36")
r.Header.Add("x-csrf-itc", "itc")
rsp, err := appleClient.Do(r)
if err != nil {
return err
}
defer rsp.Body.Close()
bs, err := ioutil.ReadAll(rsp.Body)
if err != nil {
return err
}
if rsp.StatusCode >= 300 {
return fmt.Errorf("apple error: %d %s\n\n%s", rsp.StatusCode, rsp.Status, bs)
} else if out != nil {
return json.Unmarshal(bs, out)
}
return nil
}
func getTierPricing(ctx context.Context, subAdamID, tier string) *SetPriceReq {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(GetPricingUrl, appleHost, appleAppID, subAdamID, tier), nil)
if err != nil {
panic("failed to get price req " + err.Error())
}
var rsp *PricingResp
if err := do(ctx, req, &rsp); err != nil {
panic("failed to get prices" + err.Error())
}
sReq := &SetPriceReq{Subscriptions: nil}
for countryAlpha2, info := range rsp.Data {
sReq.Subscriptions = append(sReq.Subscriptions, ValueObj{
Value: AppSubValue{
Country: countryAlpha2,
Grandfathered: ValueObj{
Value: "FUTURE_NONE",
IsEditable: false,
IsRequired: false,
},
PriceTierEffectiveDate: nil,
PriceTierEndDate: nil,
TierStem: info.TierStem,
},
IsEditable: true,
IsRequired: false,
})
}
return sReq
}
func waitForIAP(ctx context.Context, sku string) (*CreateSubscriptionReq, error) {
for i := 0; i < 60; i++ {
time.Sleep(1 * time.Second)
iaps, err := getIAPs(ctx)
if err != nil {
return nil, err
}
for _, iap := range iaps {
if iap.VendorId == sku {
return getIAP(ctx, iap.AdamId)
}
}
}
return nil, fmt.Errorf("timeout for sku ID")
}
func getIAP(ctx context.Context, subAdamID string) (*CreateSubscriptionReq, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(GetIAPUrl, appleHost, appleAppID, subAdamID), nil)
if err != nil {
return nil, err
}
var rsp *IAPResp
if err := do(ctx, req, &rsp); err != nil {
return nil, err
}
return rsp.Data, nil
}
func getIAPs(ctx context.Context) ([]*IAPData, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(GetIAPsUrl, appleHost, appleAppID), nil)
if err != nil {
return nil, err
}
var rsp *IAPsResp
if err := do(ctx, req, &rsp); err != nil {
return nil, err
}
return rsp.Data, nil
}
func mustSucceed(rsp *AppleResp, format string, args ...interface{}) {
if rsp.StatusCode != "SUCCESS" {
msg := fmt.Sprintf(format, args...)
panic(fmt.Sprintf("%s: %v", msg, rsp))
}
}
type (
CreateSubscriptionReq struct {
SectionErrorKeys []interface{} `json:"sectionErrorKeys"`
SectionInfoKeys []interface{} `json:"sectionInfoKeys"`
SectionWarningKeys []interface{} `json:"sectionWarningKeys"`
Value interface{} `json:"value"`
ID interface{} `json:"id"`
AdamID string `json:"adamId,omitempty"`
PublicID string `json:"publicId,omitempty"`
AppAdamIds []string `json:"appAdamIds,omitempty"`
FamilyID string `json:"familyId"`
AddOnType string `json:"addOnType"`
IsNewsSubscription bool `json:"isNewsSubscription"`
IsReplaced bool `json:"isReplaced"`
ReplacementAdamID interface{} `json:"replacementAdamId"`
ReferenceName ValueObj `json:"referenceName"`
ProductID ValueObj `json:"productId"`
ClearedForSale ValueObj `json:"clearedForSale"`
PricingDurationType ValueObj `json:"pricingDurationType"`
PricingIntervals []interface{} `json:"pricingIntervals"`
UngrandfatheredIntervals interface{} `json:"ungrandfatheredIntervals"`
FreeTrialDurationType ValueObj `json:"freeTrialDurationType"`
BonusPeriodDurationType ValueObj `json:"bonusPeriodDurationType"`
Versions []Version `json:"versions"`
MissingRequiredPrivacyPolicyData bool `json:"missingRequiredPrivacyPolicyData"`
MissingRequiredFamilyDetail bool `json:"missingRequiredFamilyDetail"`
FamilyShareable interface{} `json:"familyShareable"`
}
ValueObj struct {
Value interface{} `json:"value"`
IsEditable bool `json:"isEditable"`
IsRequired bool `json:"isRequired"`
IsDeletable bool `json:"isDeletable"`
ErrorKeys interface{} `json:"errorKeys"`
MaxLength int `json:"maxLength,omitempty"`
MinLength int `json:"minLength,omitempty"`
}
RevScreenValue struct {
Size int `json:"size,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Checksum string `json:"checksum,omitempty"`
AssetToken string `json:"assetToken,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
OriginalFileName string `json:"originalFileName,omitempty"`
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
ThumbNailURL string `json:"thumbNailUrl,omitempty"`
}
ImgValue struct {
AssetToken interface{} `json:"assetToken"`
OriginalFileName interface{} `json:"originalFileName"`
Width interface{} `json:"width"`
Height interface{} `json:"height"`
Checksum interface{} `json:"checksum"`
}
Version struct {
ID interface{} `json:"id"`
Details ValueObj `json:"details"`
ContentHosting interface{} `json:"contentHosting"`
ContentHostingData interface{} `json:"contentHostingData"`
ReviewNotes ValueObj `json:"reviewNotes"`
ReviewScreenshot ValueObj `json:"reviewScreenshot"`
Merch MerchField `json:"merch"`
Status interface{} `json:"status"`
CanBeSubmitted bool `json:"canBeSubmitted"`
}
VersionDetails struct {
ID interface{} `json:"id"`
Name ValueObj `json:"name"`
Description ValueObj `json:"description"`
PublicationName interface{} `json:"publicationName"`
LocaleCode string `json:"localeCode"`
Status interface{} `json:"status"`
ErrorKeys interface{} `json:"errorKeys"`
}
MerchField struct {
Images []MerchImage `json:"images"`
ShowByDefault bool `json:"showByDefault"`
IsActive bool `json:"isActive"`
}
MerchImage struct {
ID interface{} `json:"id"`
Image ValueObj `json:"image"`
Status interface{} `json:"status"`
}
AppleResp struct {
Data interface{} `json:"data"`
Messages AppleMsg `json:"messages"`
StatusCode string `json:"statusCode"`
}
PricingResp struct {
Data map[string]PriceInfo `json:"data"`
AppleResp
}
IAPsResp struct {
Data []*IAPData `json:"data"`
AppleResp
}
IAPResp struct {
Data *CreateSubscriptionReq `json:"data"`
AppleResp
}
IAPData struct {
FamilyReferenceName string `json:"familyReferenceName"`
DurationDays int `json:"durationDays"`
NumberOfCodes int `json:"numberOfCodes"`
MaximumNumberOfCodes int `json:"maximumNumberOfCodes"`
AppMaximumNumberOfCodes int `json:"appMaximumNumberOfCodes"`
IsEditable bool `json:"isEditable"`
IsRequired bool `json:"isRequired"`
CanDeleteAddOn bool `json:"canDeleteAddOn"`
ErrorKeys interface{} `json:"errorKeys"`
IsEmptyValue bool `json:"isEmptyValue"`
ItcsubmitNextVersion bool `json:"itcsubmitNextVersion"`
AdamId string `json:"adamId"`
ReferenceName string `json:"referenceName"`
VendorId string `json:"vendorId"`
AddOnType string `json:"addOnType"`
Versions []struct {
ScreenshotUrl interface{} `json:"screenshotUrl"`
CanSubmit bool `json:"canSubmit"`
IssuesCount int `json:"issuesCount"`
ItunesConnectStatus string `json:"itunesConnectStatus"`
} `json:"versions"`
PurpleSoftwareAdamIds []string `json:"purpleSoftwareAdamIds"`
LastModifiedDate int64 `json:"lastModifiedDate"`
IsNewsSubscription bool `json:"isNewsSubscription"`
ITunesConnectStatus string `json:"iTunesConnectStatus"`
}
PriceInfo struct {
CountryName string `json:"countryName"`
FRetailPrice string `json:"fRetailPrice"`
FWholesalePrice string `json:"fWholesalePrice"`
FWholesalePrice2 string `json:"fWholesalePrice2"`
TierStem string `json:"tierStem"`
}
AppleMsg struct {
Warn []string `json:"warn"`
Error []string `json:"error"`
Info []string `json:"info"`
}
SetPriceReq struct {
Subscriptions []ValueObj `json:"subscriptions"`
}
AppSubValue struct {
Country string `json:"country"`
Grandfathered ValueObj `json:"grandfathered"`
PriceTierEffectiveDate interface{} `json:"priceTierEffectiveDate"`
PriceTierEndDate interface{} `json:"priceTierEndDate"`
TierStem string `json:"tierStem"`
}
UploadImageResp struct {
ContentType string `json:"contentType"`
DescriptionDoc interface{} `json:"descriptionDoc"`
DsID interface{} `json:"dsId"`
FileType interface{} `json:"fileType"`
HasAlpha bool `json:"hasAlpha"`
Height int `json:"height"`
ImgSvcTemplateURL interface{} `json:"imgSvcTemplateUrl"`
Length int `json:"length"`
LocalizedMessage interface{} `json:"localizedMessage"`
Md5 string `json:"md5"`
NonLocalizedMessage interface{} `json:"nonLocalizedMessage"`
SuggestionCode interface{} `json:"suggestionCode"`
Token string `json:"token"`
TokenType string `json:"tokenType"`
Type string `json:"type"`
WarningCodes interface{} `json:"warningCodes"`
Width int `json:"width"`
}
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment