Last active
August 29, 2024 18:07
-
-
Save astashov/79dd4ef4e91ea012710145623bfe0984 to your computer and use it in GitHub Desktop.
Script to update Apple localized prices for all countries using CSV from https://docs.google.com/spreadsheets/d/1BwSqpkqa98nk9Gh7238U7KQbvdvh_tGlXmQKE0Druwk Article: https://www.liftosaur.com/blog/posts/implementing-localized-pricing-for-your-app/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const fetch = require("node-fetch"); | |
const fs = require("fs"); | |
const jwt = require("jsonwebtoken"); | |
const util = require("util"); | |
// To get the private key, go to App Store Connect (appstoreconnect.apple.com), then "Users and Access" | |
// at the top. Then go to "Integrations" -> "App Store Connect API", under "Team Keys" create a new key, | |
// and you'll be able to download the private key for it. | |
const PRIVATE_KEY = fs.readFileSync("AuthKey_F2BLAHBLAH.p8", "utf8"); | |
// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find "Issuer ID" | |
const ISSUER_ID = "4b8896ba-0ab0-a7c5-acde-543b969a6c5c"; | |
// Id of your app | |
const APP_ID = "com.liftosaur.www"; | |
// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find Key ID for your | |
// private key you created following instructions above. | |
const KEY_ID = "F2BLAHBLAH"; | |
// You can find subscription ids on the subscriptions page under your app in App Store Connect | |
const SUBSCRIPTION_IDS = { | |
"6445212345": "monthly", | |
"6445312346": "yearly", | |
}; | |
// You can find in-app purchase ids on the in-app purchases page under your app in App Store Connect | |
const PRODUCT_ID = "645012347"; | |
const API_BASE_URL = "https://api.appstoreconnect.apple.com/v1"; | |
const API_BASE_URL_V2 = "https://api.appstoreconnect.apple.com/v2"; | |
function formatYYYYMMDD(date, separator = "-") { | |
const d = new Date(date); | |
let month = `${d.getMonth() + 1}`; | |
let day = `${d.getDate()}`; | |
const year = `${d.getFullYear()}`; | |
if (month.length < 2) { | |
month = `0${month}`; | |
} | |
if (day.length < 2) { | |
day = `0${day}`; | |
} | |
return [year, month, day].join(separator); | |
} | |
// Generate JWT token for authentication | |
function generateJWT() { | |
const token = jwt.sign( | |
{ | |
iss: ISSUER_ID, | |
iat: Math.floor(Date.now() / 1000), | |
exp: Math.floor(Date.now() / 1000) + 20 * 60, // 20 minutes expiration | |
bid: APP_ID, | |
aud: "appstoreconnect-v1", | |
}, | |
PRIVATE_KEY, | |
{ | |
algorithm: "ES256", | |
header: { | |
alg: "ES256", | |
kid: KEY_ID, | |
typ: "JWT", | |
}, | |
} | |
); | |
return token; | |
} | |
function getNewPrices() { | |
return fs | |
.readFileSync("apple_prices.csv", "utf8") | |
.split("\n") | |
.slice(1) | |
.map((line) => { | |
const [ | |
, | |
code, | |
currencyCode, | |
, | |
, | |
, | |
, | |
, | |
, | |
, | |
, | |
, | |
, | |
monthlyMarketing, | |
yearlyMarketing, | |
lifetimeMarketing, | |
] = line.split(",").map((v) => v.trim()); | |
return { | |
countryCode: code.trim(), | |
currencyCode: currencyCode.trim(), | |
monthly: parseFloat(monthlyMarketing.trim()), | |
yearly: parseFloat(yearlyMarketing.trim()), | |
lifetime: parseFloat(lifetimeMarketing.trim()), | |
}; | |
}); | |
} | |
const subscriptionToTerritoryToPricePoints = {}; | |
const productTerritoryToPricePoints = {}; | |
async function getFromApple(url) { | |
const token = generateJWT(); | |
console.log("Fetching page", url); | |
let results = []; | |
let json; | |
do { | |
const response = await fetch(url, { | |
method: "GET", | |
headers: { | |
Authorization: `Bearer ${token}`, | |
"Content-Type": "application/json", | |
}, | |
}); | |
json = await response.json(); | |
results = results.concat(json.data); | |
url = json.links.next; | |
console.log("Fetching next page", url); | |
} while (json.links.next); | |
return results; | |
} | |
function findClosestPricePoint(pricePoints, targetPrice) { | |
return pricePoints.reduce((prev, curr) => { | |
return Math.abs(curr.price - targetPrice) < Math.abs(prev.price - targetPrice) ? curr : prev; | |
}); | |
} | |
async function getSubscriptionPricePoints(subscriptionId, territory) { | |
const pricePoint = subscriptionToTerritoryToPricePoints[subscriptionId]?.[territory]; | |
if (pricePoint) { | |
return pricePoint; | |
} | |
let pp; | |
fs.mkdirSync(`pricepoints`, { recursive: true }); | |
if (fs.existsSync(`pricepoints/${subscriptionId}-${territory}.json`)) { | |
const pricePoints = fs.readFileSync(`pricepoints/${subscriptionId}-${territory}.json`, "utf8"); | |
pp = JSON.parse(pricePoints); | |
} else { | |
const url = `${API_BASE_URL}/subscriptions/${subscriptionId}/pricePoints?include=territory&filter%5Bterritory%5D=${territory}`; | |
const json = await getFromApple(url); | |
pp = json.map((d) => { | |
return { | |
id: d.id, | |
price: d.attributes.customerPrice, | |
}; | |
}); | |
pp.sort((a, b) => b.price - a.price); | |
fs.writeFileSync(`pricepoints/${subscriptionId}-${territory}.json`, JSON.stringify(pp, null, 2)); | |
} | |
subscriptionToTerritoryToPricePoints[subscriptionId] = subscriptionToTerritoryToPricePoints[subscriptionId] || {}; | |
subscriptionToTerritoryToPricePoints[subscriptionId][territory] = pp; | |
return pp; | |
} | |
async function getProductPricePoints(territory) { | |
const pricePoint = productTerritoryToPricePoints[territory]; | |
if (pricePoint) { | |
return pricePoint; | |
} | |
let pp; | |
fs.mkdirSync(`pricepoints`, { recursive: true }); | |
if (fs.existsSync(`pricepoints/${PRODUCT_ID}-${territory}.json`)) { | |
const pricePoints = fs.readFileSync(`pricepoints/${PRODUCT_ID}-${territory}.json`, "utf8"); | |
pp = JSON.parse(pricePoints); | |
} else { | |
const url = `${API_BASE_URL_V2}/inAppPurchases/${PRODUCT_ID}/pricePoints?include=territory&filter%5Bterritory%5D=${territory}`; | |
const json = await getFromApple(url); | |
pp = json.map((d) => { | |
return { | |
id: d.id, | |
price: d.attributes.customerPrice, | |
}; | |
}); | |
pp.sort((a, b) => b.price - a.price); | |
fs.writeFileSync(`pricepoints/${PRODUCT_ID}-${territory}.json`, JSON.stringify(pp, null, 2)); | |
} | |
productTerritoryToPricePoints[territory] = pp; | |
return pp; | |
} | |
// Function to update subscription price for a country | |
async function updateSubscriptionPrices(subscriptionId, key) { | |
const token = generateJWT(); | |
const newPrices = getNewPrices(); | |
for (const price of newPrices) { | |
const pricePoints = await getSubscriptionPricePoints(subscriptionId, price.countryCode); | |
if (pricePoints.length === 0) { | |
console.log("No price point found for", subscriptionId, price.countryCode, price[key]); | |
return; | |
} | |
const pricePoint = findClosestPricePoint(pricePoints, price[key]); | |
const data = { | |
data: { | |
type: "subscriptionPrices", | |
attributes: { | |
preserveCurrentPrice: false, | |
startDate: formatYYYYMMDD(Date.now()), | |
}, | |
relationships: { | |
subscription: { | |
data: { | |
type: "subscriptions", | |
id: subscriptionId, | |
}, | |
}, | |
subscriptionPricePoint: { | |
data: { | |
type: "subscriptionPricePoints", | |
id: pricePoint.id, | |
}, | |
}, | |
territory: { | |
data: { | |
type: "territories", | |
id: price.countryCode, | |
}, | |
}, | |
}, | |
}, | |
}; | |
console.log("Updating price for", key, price.countryCode, price[key], pricePoint.price); | |
const url = `${API_BASE_URL}/subscriptionPrices`; | |
const response = await fetch(url, { | |
method: "POST", | |
headers: { | |
Authorization: `Bearer ${token}`, | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify(data), | |
}); | |
const json = await response.json(); | |
console.log( | |
util.inspect(json, { | |
showHidden: false, | |
maxArrayLength: null, | |
depth: null, | |
}) | |
); | |
console.log("\n"); | |
} | |
} | |
async function updateProductPrices() { | |
const token = generateJWT(); | |
const newPrices = getNewPrices(); | |
const key = "lifetime"; | |
const manualPrices = await Promise.all( | |
newPrices.map(async (price) => { | |
const pricePoints = await getProductPricePoints(price.countryCode); | |
const pricePoint = findClosestPricePoint(pricePoints, price[key]); | |
return { | |
pricePoint, | |
price, | |
}; | |
}) | |
); | |
const data = { | |
data: { | |
type: "inAppPurchasePriceSchedules", | |
relationships: { | |
baseTerritory: { | |
data: { | |
type: "territories", | |
id: "USA", | |
}, | |
}, | |
inAppPurchase: { | |
data: { | |
type: "inAppPurchases", | |
id: PRODUCT_ID, | |
}, | |
}, | |
manualPrices: { | |
data: manualPrices.map(({ pricePoint }) => { | |
return { | |
type: "inAppPurchasePrices", | |
id: `pp-${pricePoint.id}`, | |
}; | |
}), | |
}, | |
}, | |
}, | |
included: manualPrices.map(({ pricePoint, price }) => { | |
return { | |
attributes: { | |
startDate: null, | |
endDate: null, | |
}, | |
id: `pp-${pricePoint.id}`, | |
relationships: { | |
inAppPurchasePricePoint: { | |
data: { | |
id: pricePoint.id, | |
type: "inAppPurchasePricePoints", | |
}, | |
}, | |
inAppPurchaseV2: { | |
data: { | |
id: PRODUCT_ID, | |
type: "inAppPurchases", | |
}, | |
}, | |
}, | |
type: "inAppPurchasePrices", | |
}; | |
}), | |
}; | |
console.log("Updating prices of lifetime"); | |
const url = `${API_BASE_URL}/inAppPurchasePriceSchedules`; | |
const response = await fetch(url, { | |
method: "POST", | |
headers: { | |
Authorization: `Bearer ${token}`, | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify(data), | |
}); | |
const json = await response.json(); | |
console.log( | |
util.inspect(json, { | |
showHidden: false, | |
maxArrayLength: null, | |
depth: null, | |
}) | |
); | |
console.log("\n"); | |
} | |
async function main() { | |
for (const [subscriptionId, key] of Object.entries(SUBSCRIPTION_IDS)) { | |
await updateSubscriptionPrices(subscriptionId, key); | |
} | |
await updateProductPrices(); | |
} | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment